gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/home/store/rtcStore.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,450 @@
import type { ImRtcApi } from '#/api/im/rtc'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { getCurrentUserId } from '#/views/im/utils/auth'
import {
  ImConversationType,
  type ImRtcCallEndReasonValue,
  ImRtcCallStage,
  type ImRtcCallStageValue,
  ImRtcCallStatus,
  type ImRtcParticipantStatusValue
} from '../../utils/constants'
import { useFriendStore } from './friendStore'
import { useGroupStore } from './groupStore'
type GroupActiveCallCache = {
  participantsLoaded?: boolean // æ˜¯å¦å·²æ‹‰å–完整参与者列表
} & ImRtcApi.RtcGroupCallRespVO
// RTC_CALL é€šè¯ä¿¡ä»¤è½½è·ï¼›æŒ‰ status åŒºåˆ†å­ç±»åž‹è¯­ä¹‰
export interface ImRtcCallNotification {
  status: ImRtcParticipantStatusValue
  room: string
  conversationType: number
  mediaType: number
  groupId?: number
  // INVITE ä¸“属:被叫接通需要的 LiveKit è¿žæŽ¥å‚æ•° + ä¸»å«å±•示信息
  livekitUrl?: string
  token?: string
  inviterUserId?: number
  inviterNickname?: string
  inviterAvatar?: string
  // INVITE ä¸“属:本次被邀请人列表;包含收件人自身,前端来电小条按需过滤展示「邀请的其他人」
  inviteeIds?: number[]
  // REJECT ä¸“属:操作者展示信息(其它子类型走 RTC_CALL_END)
  operatorUserId?: number
  operatorNickname?: string
  operatorAvatar?: string
}
// RTC_PARTICIPANT_CONNECTED é€šè¯å‚与者加入载荷(LiveKit webhook è½¬æŽ¨ï¼‰
export interface ImRtcParticipantConnectedNotification {
  room: string
  userId: number
  conversationType: number
  groupId?: number
  // ç¾¤èŠåœºæ™¯éžé‚€è¯·æˆå‘˜é¦–次填充胶囊条用
  mediaType?: number
  inviterUserId?: number
}
// RTC_PARTICIPANT_DISCONNECTED é€šè¯å‚与者离开载荷(LiveKit webhook è½¬æŽ¨ï¼‰
export interface ImRtcParticipantDisconnectedNotification {
  room: string
  userId: number
  conversationType: number
  groupId?: number
}
// RTC_CALL_END é€šè¯ç»“束载荷(入消息流;私聊渲染消息气泡,群聊渲染系统提示行)
export interface ImRtcCallEndNotification {
  room: string
  conversationType: number
  mediaType: number
  endReason: ImRtcCallEndReasonValue
  durationSeconds?: number
  // æ“ä½œè€…聚合字段:HANGUP/CANCEL/REJECT è§¦å‘人;webhook å…œåº•为 null
  operatorUserId?: number
  operatorNickname?: string
  operatorAvatar?: string
}
export const useRtcStore = defineStore('imRtc', () => {
  /** å½“前阶段 */
  const stage = ref<ImRtcCallStageValue>(ImRtcCallStage.IDLE)
  /** å½“前通话;invite / accept / refreshToken æ‹¿åˆ°çš„完整信息 */
  const call = ref<ImRtcApi.RtcCallRespVO | null>(null)
  /** æ¥ç”µè½½è·ï¼›ä»… INCOMING é˜¶æ®µä½¿ç”¨ï¼›status å›ºå®š INVITING,其它字段 INVITE ä¸“属 */
  const incomingPayload = ref<ImRtcCallNotification | null>(null)
  /** è¿›å…¥ RUNNING çš„æ—¶é—´æˆ³ï¼›ç”¨äºŽé€šè¯æ—¶é•¿å±•示;reset æ—¶æ¸…é›¶ */
  const startedAt = ref(0)
  /** æ˜¯å¦å¤„于通话相关阶段 */
  const isActive = computed(() => stage.value !== ImRtcCallStage.IDLE)
  /**
   * å¯¹ç«¯å±•示名;按阶段 + ä¼šè¯ç±»åž‹åˆ†æ”¯ï¼š
   * INCOMING å–来电载荷的 inviterNickname;群通话取群名;私聊查 friendStore åæŸ¥å¯¹ç«¯ userId
   */
  const peerNickname = computed<string>(() => {
    if (stage.value === ImRtcCallStage.INCOMING) {
      return incomingPayload.value?.inviterNickname || ''
    }
    const c = call.value
    if (!c) return ''
    if (c.conversationType === ImConversationType.GROUP) {
      return useGroupStore().getGroup(c.groupId ?? 0)?.name || ''
    }
    const peerUserId = resolvePrivatePeerUserId(c)
    return (peerUserId && useFriendStore().getFriend(peerUserId)?.nickname) || ''
  })
  /** å¯¹ç«¯å¤´åƒï¼›ç­–略同 peerNickname */
  const peerAvatar = computed<string>(() => {
    if (stage.value === ImRtcCallStage.INCOMING) {
      return incomingPayload.value?.inviterAvatar || ''
    }
    const c = call.value
    if (!c) return ''
    if (c.conversationType === ImConversationType.GROUP) {
      return useGroupStore().getGroup(c.groupId ?? 0)?.avatar || ''
    }
    const peerUserId = resolvePrivatePeerUserId(c)
    return (peerUserId && useFriendStore().getFriend(peerUserId)?.avatar) || ''
  })
  /** ç§èŠåœºæ™¯å¯¹ç«¯ userId:自己是主叫则取首个 invitee,否则取 inviter */
  function resolvePrivatePeerUserId(c: ImRtcApi.RtcCallRespVO): number | undefined {
    const myId = getCurrentUserId()
    return c.inviterId === myId ? c.inviteeIds?.[0] : c.inviterId
  }
  /** ç¾¤æ´»è·ƒé€šè¯ç´¢å¼•ï¼›groupId -> ç¾¤é€šè¯æ‘˜è¦ï¼›ç”¨äºŽç¾¤èŠé¡¶éƒ¨èƒ¶å›Šæ¡ */
  const groupActiveCalls = ref<Map<number, GroupActiveCallCache>>(new Map())
  /**
   * å·²é€€å‡º / å·²æ‹’绝的用户编号集合;群通话场景内 pending å ä½æ¸²æŸ“时排除;
   * æ¥æºï¼šå‚与者离开通知 + ç¾¤é€šè¯å•人拒绝的 operatorUserId;通话结束(reset)时清空
   */
  const leftUserIds = ref<Set<number>>(new Set())
  /** æ˜¯å¦å·²è®°å½•某 userId å·²é€€å‡º / æ‹’绝 */
  function isUserLeft(userId: number): boolean {
    return leftUserIds.value.has(userId)
  }
  /** æ ‡è®°æŸä¸ª userId å·²é€€å‡º / æ‹’绝;用于 pending å ä½æ¸²æŸ“时排除 */
  function markUserLeft(userId: number) {
    if (!userId || leftUserIds.value.has(userId)) {
      return
    }
    leftUserIds.value = new Set([...leftUserIds.value, userId])
  }
  /**
   * ä¸»å«å‘起通话;按会话类型 + status å†³å®š stage;
   * ç¾¤é€šè¯ï¼šå‘起人直接进 RUNNING å¤šäººå¡ç‰‡è§†å›¾ï¼Œæˆ¿å†…可能只有自己,等其他人陆续加入;
   * ç§èŠï¼šæŒ‰ status èµ°ï¼›RUNNING(已加入已有通话场景)→ RUNNING;CREATED â†’ INVITING ç­‰è¢«å«æŽ¥é€š
   */
  function startInviting(data: ImRtcApi.RtcCallRespVO) {
    call.value = data
    // ç¾¤é€šè¯åœºæ™¯å†™å…¥æœ¬åœ°èƒ¶å›Šæ¡ç¼“å­˜
    syncGroupActiveCall(data)
    // æ›´æ–° stage çŠ¶æ€
    if (data.conversationType === ImConversationType.GROUP) {
      stage.value = ImRtcCallStage.RUNNING
      startedAt.value = Date.now()
      return
    }
    const running = data.status === ImRtcCallStatus.RUNNING
    stage.value = running ? ImRtcCallStage.RUNNING : ImRtcCallStage.INVITING
    if (running) {
      startedAt.value = Date.now()
    }
  }
  /** è¢«å«æ”¶åˆ°æ¥ç”µï¼›åˆ‡åˆ° INCOMING;接收 RTC_CALL(INVITE) payload */
  function showIncoming(payload: ImRtcCallNotification) {
    if (isActive.value) {
      return
    }
    incomingPayload.value = payload
    stage.value = ImRtcCallStage.INCOMING
    // æŒ‰ inviter å…œåº•首次填充胶囊条
    syncGroupActiveCall({
      conversationType: payload.conversationType,
      room: payload.room,
      groupId: payload.groupId,
      mediaType: payload.mediaType,
      inviterId: payload.inviterUserId ?? 0,
      joinedUserIds: payload.inviterUserId ? [payload.inviterUserId] : [],
      inviteeIds: payload.inviteeIds
    })
  }
  /** è¿›å…¥é€šè¯ä¸­é˜¶æ®µ */
  function enterRunning(data: ImRtcApi.RtcCallRespVO) {
    call.value = data
    // ç¦»å¼€ INCOMING é˜¶æ®µï¼›æ¸…空来电载荷
    incomingPayload.value = null
    stage.value = ImRtcCallStage.RUNNING
    startedAt.value = Date.now()
    // æŽ¥é€šåŽç”¨ RespVO å®Œæ•´è¦†ç›–胶囊条
    syncGroupActiveCall(data)
  }
  /**
   * ç¾¤é€šè¯åœºæ™¯åŒæ­¥æœ¬åœ° groupActiveCalls ç¼“存;非群通话或缺 groupId ç›´æŽ¥è¿”回;
   * ä¸ä¾èµ–后端 webhook æŽ¨é€çš„ RTC_PARTICIPANT_CONNECTED é¦–次填充,避免胶囊条出现延迟;
   * è¢«å«åœºæ™¯é€šçŸ¥è½½è·æ—  joinedUserIds,调用方按主叫人兜底,后续 getActiveCall / å‚与者事件刷新成完整列表
   */
  function syncGroupActiveCall(input: {
    conversationType: number
    groupId?: number
    inviteeIds?: number[]
    inviterId: number
    joinedUserIds?: number[]
    mediaType: number
    room: string
  }) {
    if (input.conversationType !== ImConversationType.GROUP || !input.groupId) {
      return
    }
    // å†™å…¥æˆ–更新群活跃通话缓存
    setGroupCall({
      room: input.room,
      groupId: input.groupId,
      mediaType: input.mediaType,
      inviterId: input.inviterId,
      joinedUserIds: input.joinedUserIds ?? [],
      inviteeIds: input.inviteeIds ?? []
    })
  }
  /** é‡ç½®ï¼›é€šè¯ç»“束统一调用 */
  function reset() {
    stage.value = ImRtcCallStage.IDLE
    call.value = null
    incomingPayload.value = null
    startedAt.value = 0
    leftUserIds.value = new Set()
  }
  /** é€šè¯ä¸­è¿½åŠ è¢«é‚€è¯·äººï¼›è®© participants ç½‘格出现 pending å ä½ã€èƒ¶å›Šæ¡åŒæ­¥æ›´æ–° */
  function appendInvitees(userIds: number[]) {
    if (!call.value || userIds.length === 0) {
      return
    }
    const existing = call.value.inviteeIds ?? []
    const merged = [...new Set([...existing, ...userIds])]
    if (merged.length === existing.length) {
      return
    }
    call.value = { ...call.value, inviteeIds: merged }
    syncGroupActiveCall(call.value)
  }
  // ==================== ç¾¤é€šè¯èƒ¶å›Šæ¡çŠ¶æ€ ====================
  /**
   * ç¾¤é€šè¯å¼€å§‹ / çŠ¶æ€åˆ·æ–°ï¼šå†™å…¥ / æ›´æ–° groupActiveCalls;展示用「胶囊条」;
   * æˆ¿å†…成员同步交给 LiveKit å®¢æˆ·ç«¯äº‹ä»¶ï¼ˆParticipantConnected / Disconnected);
   * èƒ¶å›Šæ¡ä¸å®žæ—¶åˆ·æ–° joinedUserIds / inviteeIds,展开 / åŠ å…¥æ—¶å†èµ° getActiveCall æŽ¥å£æ‹‰æœ€æ–°
   */
  function setGroupCall(payload: ImRtcApi.RtcGroupCallRespVO, participantsLoaded?: boolean) {
    if (!payload?.groupId) {
      return
    }
    useGroupStore().markGroupActiveCallLoaded(payload.groupId)
    // æµ…比较:room / mediaType / joinedUserIds / inviteeIds éƒ½æ²¡å˜å°±è·³è¿‡ï¼Œé¿å…ä¸‹æ¸¸ watcher æ— æ„ä¹‰é‡ç®—
    const existing = groupActiveCalls.value.get(payload.groupId)
    const nextParticipantsLoaded =
      participantsLoaded ?? (existing?.room === payload.room && !!existing.participantsLoaded)
    if (
      existing &&
      isSameGroupCall(existing, payload) &&
      !!existing.participantsLoaded === nextParticipantsLoaded
    ) {
      return
    }
    const newGroupActiveCalls = new Map(groupActiveCalls.value)
    newGroupActiveCalls.set(payload.groupId, {
      ...payload,
      participantsLoaded: nextParticipantsLoaded
    })
    groupActiveCalls.value = newGroupActiveCalls
  }
  /** æ¸…空指定群的通话缓存 */
  function clearGroupCallCache(groupId?: number) {
    if (!groupId) {
      groupActiveCalls.value = new Map()
      return
    }
    const next = new Map(groupActiveCalls.value)
    next.delete(groupId)
    groupActiveCalls.value = next
  }
  /** åˆ¤æ–­ç¾¤é€šè¯æ˜¯å¦å·²è¡¥é½ */
  function isGroupCallParticipantsLoaded(groupId: number, room?: string): boolean {
    const call = groupActiveCalls.value.get(groupId)
    return (
      !!groupId &&
      !!room &&
      !!call &&
      call.room === room &&
      !!call.participantsLoaded
    )
  }
  /** ä¸¤æ¡ç¾¤é€šè¯æ‘˜è¦å†…容相等(room / mediaType / inviterId / ä¸¤ä¸ª userId æ•°ç»„逐项相等) */
  function isSameGroupCall(a: ImRtcApi.RtcGroupCallRespVO, b: ImRtcApi.RtcGroupCallRespVO): boolean {
    if (a.room !== b.room || a.mediaType !== b.mediaType || a.inviterId !== b.inviterId) {
      return false
    }
    return isSameNumberList(a.joinedUserIds ?? [], b.joinedUserIds ?? []) &&
      isSameNumberList(a.inviteeIds ?? [], b.inviteeIds ?? [])
  }
  /** åˆ¤æ–­ä¸¤ä¸ªç”¨æˆ·ç¼–号列表是否逐项相等 */
  function isSameNumberList(a: number[], b: number[]): boolean {
    if (a.length !== b.length) {
      return false
    }
    return a.every((item, index) => item === b[index])
  }
  /** ç¾¤é€šè¯ç»“束:从 groupActiveCalls ç§»é™¤ï¼›èƒ¶å›Šæ¡æ¶ˆå¤± */
  function removeGroupCall(groupId: number, room?: string) {
    if (!groupId) {
      return
    }
    const existing = groupActiveCalls.value.get(groupId)
    if (room && existing?.room !== room) {
      return
    }
    clearGroupCallCache(groupId)
    useGroupStore().markGroupActiveCallLoaded(groupId)
  }
  /** èŽ·å–ç¾¤å½“å‰æ´»è·ƒé€šè¯ï¼›ç”¨äºŽèƒ¶å›Šæ¡æŒ‰ groupId æŸ¥è¯¢ */
  function getGroupCall(groupId: number): ImRtcApi.RtcGroupCallRespVO | undefined {
    return groupActiveCalls.value.get(groupId)
  }
  /** é€šè¯å‚与者加入:把 userId åŠ è¿› joinedUserIds;群聊场景无活跃记录时首次填充胶囊条 */
  function applyParticipantConnected(payload: ImRtcParticipantConnectedNotification) {
    const isGroup = payload.conversationType === ImConversationType.GROUP
    if (!isGroup || !payload.groupId) {
      return
    }
    // èƒ¶å›Šæ¡æ‡’填充:本端可能在通话开始后才打开该群会话,没收到过 setGroupCall;
    // æ­¤å¤„用加入通知建一条最小记录,inviteeIds ç•™ç©ºï¼Œå±•å¼€ popover / åŠ å…¥æ—¶å†èµ° getActiveCall è¡¥
    const existing = groupActiveCalls.value.get(payload.groupId)
    if (!existing) {
      setGroupCall({
        room: payload.room,
        groupId: payload.groupId,
        mediaType: payload.mediaType ?? 0,
        inviterId: payload.inviterUserId ?? 0,
        joinedUserIds: [payload.userId],
        inviteeIds: []
      })
      return
    }
    if (existing.room !== payload.room) {
      return
    }
    const joined = existing.joinedUserIds ?? []
    if (joined.includes(payload.userId)) {
      return
    }
    setGroupCall({ ...existing, joinedUserIds: [...joined, payload.userId] })
  }
  /** é€šè¯å‚与者离开:从 joinedUserIds ç§»é™¤ï¼›åŒæ—¶æ ‡è®° leftUserIds(pending å ä½æ¸²æŸ“排除) */
  function applyParticipantDisconnected(payload: ImRtcParticipantDisconnectedNotification) {
    markUserLeft(payload.userId)
    const isGroup = payload.conversationType === ImConversationType.GROUP
    if (!isGroup || !payload.groupId) {
      return
    }
    dropFromGroupActiveCall(payload.groupId, payload.room, payload.userId)
  }
  /** ç¾¤é€šè¯å•人拒绝邀请:标记 leftUserIds + ä»Žèƒ¶å›Šæ¡ inviteeIds ç§»é™¤ï¼ˆç§èŠæ‹’绝走 RTC_CALL_END,不入本通道) */
  function applyParticipantRejected(
    payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
  ) {
    if (!payload.operatorUserId) {
      return
    }
    markUserLeft(payload.operatorUserId)
    if (payload.conversationType === ImConversationType.GROUP && payload.groupId) {
      dropFromGroupActiveCall(payload.groupId, payload.room, payload.operatorUserId)
    }
  }
  /** ç¾¤é€šè¯å•人振铃超时;对 banner çš„处理与拒接一致(语义独立、实现共享) */
  function applyParticipantNoAnswer(
    payload: Pick<ImRtcCallNotification, 'conversationType' | 'groupId' | 'operatorUserId' | 'room'>
  ) {
    applyParticipantRejected(payload)
  }
  /** ä»ŽæŒ‡å®šç¾¤æ´»è·ƒé€šè¯çš„ joined / pending åˆ—表里同步移除某用户;用于 disconnect / reject è®©èƒ¶å›Šæ¡ä¸å†å±•示 */
  function dropFromGroupActiveCall(groupId: number, room: string, userId: number) {
    const existing = groupActiveCalls.value.get(groupId)
    if (!existing || existing.room !== room) {
      return
    }
    const joined = existing.joinedUserIds ?? []
    const invitee = existing.inviteeIds ?? []
    const nextJoined = joined.filter((id) => id !== userId)
    const nextInvitee = invitee.filter((id) => id !== userId)
    if (nextJoined.length === joined.length && nextInvitee.length === invitee.length) {
      return
    }
    if (nextJoined.length === 0 && nextInvitee.length === 0) {
      removeGroupCall(groupId, room)
      return
    }
    setGroupCall({
      ...existing,
      joinedUserIds: nextJoined,
      inviteeIds: nextInvitee
    })
  }
  return {
    stage,
    call,
    incomingPayload,
    peerNickname,
    peerAvatar,
    startedAt,
    isActive,
    startInviting,
    showIncoming,
    enterRunning,
    reset,
    appendInvitees,
    markUserLeft,
    isUserLeft,
    setGroupCall,
    isGroupCallParticipantsLoaded,
    clearGroupCallCache,
    removeGroupCall,
    getGroupCall,
    applyParticipantConnected,
    applyParticipantDisconnected,
    applyParticipantRejected,
    applyParticipantNoAnswer
  }
})