gaoluyang
2 天以前 b64a0deae5b5d33f9e20671a68936b27f0b9b00b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
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))
}