gaoluyang
昨天 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
import type { Group, GroupDO, GroupMember, Message } from '../types'
 
import type { ImGroupApi } from '#/api/im/group'
import type { ImGroupMemberApi } from '#/api/im/group/member'
 
import { CommonStatusEnum } from '#/packages/constants/src'
 
import { acceptHMRUpdate, defineStore } from 'pinia'
 
import { getGroup as apiGetGroup, getMyGroupList as apiGetMyGroupList } from '#/api/im/group'
import { getGroupMember as apiGetGroupMember, getGroupMemberList as apiGetGroupMemberList, updateGroupMember as apiUpdateGroupMember } from '#/api/im/group/member'
import { getCurrentUserId } from '#/views/im/utils/auth'
 
import {
  ImContentType,
  ImConversationType,
  ImGroupMemberRole,
  ImMessageStatus
} from '../../utils/constants'
import { getDb } from '../../utils/db'
import { type GroupNotificationPayload } from '../../utils/message'
import { getGroupDisplayName } from '../../utils/user'
import { useConversationStore } from './conversationStore'
import { useGroupRequestStore } from './groupRequestStore'
 
/** clear() 时递增;旧账号 in-flight 的成员请求返回后比对一致才写 store */
let storeEpoch = 0
 
/**
 * fetchGroupMemberList 并发去重表:同 groupId 同时进的请求共用一个 Promise
 *
 * key 必须带 userId——账号切换时 A 的请求不能被 B 复用,否则 IIFE 内部的 saveGroupMemberList 会把 A 的成员数据写进 B 的 IDB 桶
 */
const pendingMemberFetches = new Map<string, Promise<GroupMember[]>>()
const pendingMemberKey = (userId: number, groupId: number) => `${userId}:${groupId}`
 
/**
 * fetchGroupMember 单成员并发去重表:同 (groupId, memberUserId) 同时进的请求共用一个 Promise
 *
 * 跟整群表分开:单成员 fetch 跟整群 fetch 语义不同(单成员不回填 me 的 silent),不能互相代替
 */
const pendingSingleMemberFetches = new Map<string, Promise<GroupMember | null>>()
 
const pendingSingleMemberKey = (userId: number, groupId: number, memberUserId: number) =>
  `${userId}:${groupId}:${memberUserId}`
 
/** 构建群 IndexedDB 记录 */
function buildGroupDO(group: Group): GroupDO {
  const {
    activeCallExpired: _activeCallExpired,
    activeCallLoaded: _activeCallLoaded,
    infoLoaded: _infoLoaded,
    members: _members,
    membersLoaded: _membersLoaded,
    membersExpired: _membersExpired,
    ...record
  } = group
  return record
}
 
/** 判断当前用户是否在 payload.memberUserIds 里(GROUP_CREATE / INVITE / KICK 自判用) */
function isSelfInPayloadMembers(payload: GroupNotificationPayload): boolean {
  const selfUserId = getCurrentUserId()
  return !!selfUserId && (payload.memberUserIds || []).includes(selfUserId)
}
 
/** 刷新我管理的群申请红点 */
function refreshUnhandledGroupRequests(): void {
  useGroupRequestStore()
    .fetchUnhandledGroupRequestList()
    .catch(() => undefined)
}
 
/**
 * IM 群 Store
 *
 * 负责:
 * - 拉取 / 缓存当前登录用户加入的群列表
 * - 按 groupId 懒加载群成员(供 ConversationGroupSide / MentionPicker / MessageReadStatus 消费)
 * - 成员"已读 / 未读"等聚合查询由 MessageReadStatus 另行组合
 */
export const useGroupStore = defineStore('imGroupStore', {
  state: () => ({
    groups: [] as Group[],
    loaded: false, // 仅 fetchGroupList 成功后置位;loadGroupList(IDB)不置位,否则后台 SWR 刷新会被缓存命中跳过
    groupMembersExpired: false // 进入 IM / 重连后置位;IDB 里的成员桶延迟加载到内存时,也要按过期处理
  }),
 
  getters: {
    getGroup:
      (state) =>
      (id: number): Group | undefined => {
        return state.groups.find((g) => g.id === id)
      },
    /** 群成员 userId → GroupMember 索引;调用方按 userId 反查昵称 / 头像等元信息 */
    getGroupMemberMap:
      (state) =>
      (id: number): Map<number, GroupMember> => {
        const group = state.groups.find((g) => g.id === id)
        return new Map((group?.members || []).map((m) => [m.userId, m]))
      }
  },
 
  actions: {
    // ==================== 本地缓存 ====================
 
    /** 从 IndexedDB 恢复群列表 */
    async loadGroupList(): Promise<boolean> {
      try {
        const cached = await getDb().getAll<GroupDO>('groups')
        if (!cached || cached.length === 0) {
          return false
        }
        this.groups = cached
        return true
      } catch (error) {
        console.warn('[IM groupStore] 本地群缓存读取失败', error)
        return false
      }
    },
 
    /** 保存群列表 */
    saveGroupList(): void {
      void getDb()
        .transaction(['groups'], 'readwrite', async (tx) => {
          const db = getDb()
          await db.clearStore('groups', tx)
          for (const group of this.groups) {
            await db.put('groups', buildGroupDO(group), tx)
          }
        })
        .catch((error) => console.warn('[IM groupStore] 本地群缓存写入失败', error))
    },
 
    /** 保存单个群 */
    async saveGroupRecord(group: Group | undefined): Promise<void> {
      if (!group) {
        return
      }
      await getDb().put('groups', buildGroupDO(group))
    },
 
    /** 保存单个群 */
    saveGroup(group: Group | undefined): void {
      void this.saveGroupRecord(group).catch((error) =>
        console.warn('[IM groupStore] 本地群写入失败', error)
      )
    },
 
    /** 从 IndexedDB 恢复指定群成员 */
    async loadGroupMemberList(groupId: number): Promise<GroupMember[] | null> {
      // in-memory 已"完整"加载(fetchGroupMemberList 跑过或上次冷启动从 IDB 整桶恢复过):直接复用;
      // 单成员补齐(fetchGroupMember)写进的 partial members 不在此返回缓存——其 membersLoaded=false
      const cachedGroup = this.getGroup(groupId)
      if (cachedGroup?.members && cachedGroup.membersLoaded) {
        return cachedGroup.members
      }
      try {
        const cached = await getDb().getAllByIndex<GroupMember>(
          'groupMembers',
          'groupId',
          groupId
        )
        if (!cached || cached.length === 0) {
          return null
        }
        // 把 IDB 拿到的成员落到对应 group
        const group = this.getGroup(groupId)
        if (group) {
          group.members = cached
          group.memberCount = cached.length
          group.membersLoaded = true
          group.membersExpired = this.groupMembersExpired
        } else {
          // group 还没就位:仅 in-memory 占位(name='' 表示未知),不调 upsertGroup —— 避免把假名灌进 conversation.name + groups IDB 桶;
          // 后续,等 fetchGroupList 浅合并时,被真名覆盖
          this.groups.push({
            id: groupId,
            name: '',
            members: cached,
            memberCount: cached.length,
            membersLoaded: true,
            membersExpired: this.groupMembersExpired
          })
        }
        return cached
      } catch (error) {
        console.warn('[IM groupStore] 本地群成员缓存读取失败', { groupId }, error)
        return null
      }
    },
 
    /** 保存指定群成员 */
    saveGroupMemberList(groupId: number): void {
      const members = this.getGroup(groupId)?.members
      if (!members) {
        return
      }
      void getDb()
        .transaction(['groupMembers'], 'readwrite', async (tx) => {
          const db = getDb()
          await db.deleteByIndex('groupMembers', 'groupId', groupId, tx)
          for (const member of members) {
            if (member.id) {
              await db.put('groupMembers', member, tx)
            }
          }
        })
        .catch((error) =>
          console.warn(`[IM groupStore] 本地群成员缓存写入失败 (groupId=${groupId})`, error)
        )
    },
 
    // ==================== 远端拉取 ====================
 
    /** 拉取群列表;同步刷新对应群聊会话的展示名 / 头像 + 落 IDB */
    async fetchGroupList(force = false) {
      if (this.loaded && !force) {
        return
      }
      const requestEpoch = storeEpoch
      const requestUserId = getCurrentUserId()
      // 拉取当前登录用户加入的所有群(不带成员;成员按需再走 fetchGroupMemberList)
      const list = await apiGetMyGroupList()
      if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
        return
      }
      const fresh = (list || []).map((group) => convertGroup(group))
      // 合并而非全量替换:成员缓存只在成员列表接口维护,群个人设置以群列表接口为准
      const groupMap = new Map(this.groups.map((group) => [group.id, group]))
      this.groups = fresh.map((group) => {
        const existing = groupMap.get(group.id)
        if (!existing) {
          return { ...group, activeCallExpired: true, infoLoaded: true }
        }
        return {
          ...group,
          infoLoaded: true,
          activeCallExpired: existing.activeCallExpired,
          activeCallLoaded: existing.activeCallLoaded,
          members: existing.members,
          memberCount: existing.memberCount ?? group.memberCount,
          membersLoaded: existing.membersLoaded,
          membersExpired: existing.membersExpired
        }
      })
      this.loaded = true
      const conversationStore = useConversationStore()
      for (const group of this.groups) {
        conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
          name: getGroupDisplayName(group),
          avatar: group.avatar,
          silent: group.silent
        })
      }
      this.saveGroupList()
      this.preloadMembersForEmptyAvatarGroups()
    },
 
    /** 失效全部群详情缓存 */
    markAllGroupInfoExpired() {
      for (const group of this.groups) {
        group.infoLoaded = false
      }
    },
 
    /** 预加载空群头像的成员列表,供 GroupAvatar 异步合成群头像 */
    preloadMembersForEmptyAvatarGroups() {
      for (const group of this.groups) {
        if (
          group.avatar ||
          group.joinStatus === CommonStatusEnum.DISABLE ||
          (group.membersLoaded && !group.membersExpired && group.members?.length)
        ) {
          continue
        }
        const force = !!group.membersLoaded && !group.membersExpired && !group.members?.length
        this.fetchGroupMemberList(group.id, force).catch((error) => {
          console.warn('[IM groupStore] 预加载群头像成员失败', { groupId: group.id }, error)
        })
      }
    },
 
    /** 失效全部群成员缓存 */
    markAllGroupMembersExpired() {
      this.groupMembersExpired = true
      for (const group of this.groups) {
        if (group.membersLoaded) {
          group.membersExpired = true
        }
      }
    },
 
    /** 失效全部群通话探测缓存 */
    markAllGroupActiveCallsExpired() {
      for (const group of this.groups) {
        group.activeCallExpired = true
      }
    },
 
    /** 标记群通话探测已加载 */
    markGroupActiveCallLoaded(groupId: number) {
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      group.activeCallLoaded = true
      group.activeCallExpired = false
    },
 
    /** 判断群通话是否需要重新探测 */
    isGroupActiveCallExpired(groupId: number): boolean {
      const group = this.getGroup(groupId)
      return !group?.activeCallLoaded || !!group.activeCallExpired
    },
 
    /** 失效指定群成员缓存 */
    markGroupMembersExpired(groupId: number) {
      const group = this.getGroup(groupId)
      if (group?.membersLoaded) {
        group.membersExpired = true
      }
    },
 
    /** 单群刷新:用 /im/group/get 拉一份最新元数据再 upsert,常用于 GROUP_UPDATE 推送后或手动 reload */
    async fetchGroupInfo(groupId: number, force = false) {
      const cached = this.getGroup(groupId)
      if (cached?.infoLoaded && !force) {
        return
      }
      try {
        const data = await apiGetGroup(groupId)
        if (!data) {
          return
        }
        this.upsertGroup({ ...convertGroup(data), infoLoaded: true })
      } catch (error) {
        console.warn('[IM groupStore] fetchGroupInfo 失败', error)
      }
    },
 
    /** 按群拉取成员(in-memory 缓存 + 并发去重,force=true 强刷)+ 落 IDB */
    fetchGroupMemberList(groupId: number, force = false): Promise<GroupMember[]> {
      // in-memory "完整"加载过才命中——单成员补齐写入的 partial members 不在此返回(membersLoaded=false)
      const cached = this.getGroup(groupId)
      if (cached && cached.members && cached.membersLoaded && !cached.membersExpired && !force) {
        return Promise.resolve(cached.members)
      }
      // 未登录:不发起请求也不登记 in-flight,避免污染单飞表
      const requestUserId = getCurrentUserId()
      if (!requestUserId) {
        return Promise.resolve([])
      }
      const requestEpoch = storeEpoch
      // 同 (userId, groupId) 已经有正在飞的请求:直接复用,避免重复打接口
      const key = pendingMemberKey(requestUserId, groupId)
      const inflight = pendingMemberFetches.get(key)
      if (inflight) {
        return inflight
      }
      const promise = (async () => {
        // 拉接口 + 单 pass 转换:同时捕获 me 的原始 VO,给下面回填 user-per-group 字段(silent / groupRemark)用
        const list = await apiGetGroupMemberList(groupId)
        if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
          return []
        }
        let meRaw: ImGroupMemberApi.GroupMemberRespVO | undefined
        const members = (list || []).map((member) => {
          if (member.userId === requestUserId) {
            meRaw = member
          }
          return convertGroupMember(member, groupId)
        })
        const silent = !!meRaw?.silent
        const groupRemark = meRaw?.groupRemark || ''
 
        // 必须 await 之后重新 getGroup,避免 fetchGroupList 已并发写入真实 group 的 race
        const group = this.getGroup(groupId)
        const isPlaceholder = !group
        let groupFieldsChanged = false
        if (group) {
          group.members = members
          group.memberCount = members.length
          group.membersLoaded = true
          group.membersExpired = false
          // silent / groupRemark 任一变化才同步到 conversation 和 IDB;groupRemark 变化要顺带刷会话名
          if (group.silent !== silent || group.groupRemark !== groupRemark) {
            group.silent = silent
            group.groupRemark = groupRemark
            groupFieldsChanged = true
            const conversationStore = useConversationStore()
            conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
              name: getGroupDisplayName(group),
              silent
            })
          }
        } else {
          // group 还没就位:仅 in-memory push 占位(name='' 表示未知),不调 upsertGroup——避免把假名灌进 conversation.name + groups IDB 桶;
          // 后续,等 fetchGroupList 浅合并时,被真名覆盖
          this.groups.push({
            id: groupId,
            name: '',
            members,
            memberCount: members.length,
            silent,
            groupRemark,
            membersLoaded: true,
            membersExpired: false
          })
        }
 
        // groups 桶仅在 user-per-group 字段实际变化时写——避免一次批量进群引发多次整桶重写
        this.saveGroupMemberList(groupId)
        if (!isPlaceholder && groupFieldsChanged) {
          this.saveGroup(group)
        }
        return members
        // 无论成功 / 失败都要从单飞表清掉,否则后续同 group 请求永远拿到这个 stale Promise
      })().finally(() => pendingMemberFetches.delete(key))
 
      // 把 Promise 登记进单飞表,让此后短时间内的同 (userId, groupId) 请求复用
      pendingMemberFetches.set(key, promise)
      return promise
    },
 
    /**
     * 按 (groupId, memberUserId) 单成员补齐——deriveLastSenderDisplayName 兜底场景用
     *
     * 跟 fetchGroupMemberList 区别:只拉这一个成员,不动 me 的 silent / groupRemark(不是 me 的话拿不到);
     * 命中时把成员 upsert 进 group.members 数组并落 IDB,让后续渲染能用 displayUserName
     */
    fetchGroupMember(groupId: number, memberUserId: number): Promise<GroupMember | null> {
      // in-memory 命中直接返回,不打接口
      const cached = this.getGroup(groupId)?.members?.find((m) => m.userId === memberUserId)
      if (cached) {
        return Promise.resolve(cached)
      }
      // 未登录:不发起请求也不登记 in-flight,避免污染单飞表
      const requestUserId = getCurrentUserId()
      if (!requestUserId) {
        return Promise.resolve(null)
      }
      const requestEpoch = storeEpoch
      // 同 (userId, groupId, memberUserId) 已经有正在飞的请求:直接复用
      const key = pendingSingleMemberKey(requestUserId, groupId, memberUserId)
      const inflight = pendingSingleMemberFetches.get(key)
      if (inflight) {
        return inflight
      }
      const promise = (async () => {
        const data = await apiGetGroupMember(groupId, memberUserId)
        if (!data) {
          return null
        }
        if (requestEpoch !== storeEpoch || getCurrentUserId() !== requestUserId) {
          return null
        }
        const member = convertGroupMember(data, groupId)
        // 把这一条 upsert 进 group.members 仅供 in-memory 渲染兜底;group 还没就位则用 placeholder
        // 注意:不写 IDB——成员桶语义是"全量",存"1 人桶"会污染下次冷启动的 loadGroupMemberList
        const group = this.getGroup(groupId)
        if (group) {
          const memberList = group.members ?? []
          const index = memberList.findIndex((m) => m.userId === memberUserId)
          if (index === -1) {
            memberList.push(member)
          } else {
            memberList[index] = member
          }
          group.members = memberList
        } else {
          // memberCount 不设:后续 fetchGroupList 合并 `existing.memberCount ?? fresh.memberCount` 时,
          // 占位值会顶替真实值(fresh 不带 memberCount),等 fetchGroupMemberList 跑过才能拿到真实数
          this.groups.push({
            id: groupId,
            name: '',
            members: [member]
          })
        }
        return member
      })().finally(() => pendingSingleMemberFetches.delete(key))
      pendingSingleMemberFetches.set(key, promise)
      return promise
    },
 
    /** 按 id 插入或合并群(命中则浅合并保留旧字段,未命中则追加),同步把展示名 / 头像 / 免打扰推到对应会话 */
    upsertGroup(group: Group) {
      void this.upsertGroupAndSave(group).catch((error) =>
        console.warn('[IM groupStore] 本地群写入失败', error)
      )
    },
 
    /** 按 id 插入或合并群 */
    async upsertGroupAndSave(group: Group): Promise<void> {
      const index = this.groups.findIndex((g) => g.id === group.id)
      if (index === -1) {
        this.groups.push(group)
      } else {
        this.groups[index] = { ...this.groups[index], ...group }
      }
      // 同步推到 conversation:群名 / 头像 / 免打扰是会话列表展示用的,必须紧随 group 变更
      const merged = this.getGroup(group.id) ?? group
      const conversationStore = useConversationStore()
      conversationStore.updateConversation(ImConversationType.GROUP, group.id, {
        name: getGroupDisplayName(merged),
        avatar: merged.avatar,
        silent: merged.silent
      })
      // 持久化到 IDB(fire-and-forget)
      await this.saveGroupRecord(merged)
    },
 
    /** 本地移除群缓存和群会话;群解散(GROUP_DEL)、退群、被踢都复用 */
    removeGroup(id: number) {
      // 本地硬删(区别于好友删除的软删保留记录);级联清群聊会话避免列表里留死群
      this.groups = this.groups.filter((g) => g.id !== id)
      const conversationStore = useConversationStore()
      conversationStore.removeGroupConversation(id)
      void getDb()
        .transaction(['groups', 'groupMembers'], 'readwrite', async (tx) => {
          const db = getDb()
          await db.delete('groups', id, tx)
          await db.deleteByIndex('groupMembers', 'groupId', id, tx)
        })
        .catch((error) => console.warn(`[IM groupStore] 群缓存删除失败 (groupId=${id})`, error))
    },
 
    /** 切换免打扰:推后端 + 落本地 + 同步会话列表的 silent,避免 silent 图标 / 总未读 / 提示音判断与设置漂移;和 friendStore.setFriendSilent 对齐 */
    async setGroupSilent(id: number, silent: boolean) {
      await apiUpdateGroupMember({ groupId: id, silent })
      const group = this.getGroup(id)
      if (!group) {
        return
      }
      group.silent = silent
      const conversationStore = useConversationStore()
      conversationStore.updateConversation(ImConversationType.GROUP, id, { silent })
      this.saveGroup(group)
    },
 
    /** 批量更新群成员角色;本地不命中则忽略,等 fetchGroupMemberList 兜底 */
    updateGroupMemberRoleList(groupId: number, userIds: number[], role: number) {
      const group = this.getGroup(groupId)
      if (!group?.members?.length) {
        return
      }
      // 命中目标且角色已变化才标记 changed,避免无变化时整数组重建触发响应式
      const idSet = new Set(userIds)
      let changed = false
      const newMembers = group.members.map((member) => {
        if (!idSet.has(member.userId) || member.role === role) {
          return member
        }
        changed = true
        return { ...member, role }
      })
      // 有变化才整组替换,让响应式只在真有更新时通知下游
      if (changed) {
        group.members = newMembers
        this.saveGroupMemberList(groupId)
      }
    },
 
    /** 群主转让:群表 ownerUserId 改为新值;旧群主 role → NORMAL;新群主 role → OWNER */
    transferGroupOwner(groupId: number, oldOwnerId: number, newOwnerId: number) {
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      if (group.ownerUserId !== newOwnerId) {
        group.ownerUserId = newOwnerId
      }
      this.updateGroupMemberRoleList(groupId, [oldOwnerId], ImGroupMemberRole.NORMAL)
      this.updateGroupMemberRoleList(groupId, [newOwnerId], ImGroupMemberRole.OWNER)
      this.saveGroup(group)
    },
 
    /** 本地剔除群成员(GROUP_MEMBER_QUIT / KICK 事件);不命中则等 fetchGroupMemberList 兜底 */
    removeLocalGroupMemberList(groupId: number, userIds: number[]) {
      const group = this.getGroup(groupId)
      if (!group?.members?.length || userIds.length === 0) {
        return
      }
      const idSet = new Set(userIds)
      const next = group.members.filter((member) => !idSet.has(member.userId))
      if (next.length === group.members.length) {
        return
      }
      group.members = next
      group.memberCount = next.length
      this.saveGroupMemberList(groupId)
    },
 
    /** 本地更新群成员的 status(自己退群 / 被踢的本地预置;让 isMember 立即收敛到 stranger,不依赖 removeGroup 的整群移除) */
    updateGroupMemberStatus(groupId: number, userId: number, status: number) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === userId)
      if (!member || member.status === status) {
        return
      }
      member.status = status
      this.saveGroupMemberList(groupId)
    },
 
    /** 本地更新群成员的 displayUserName(GROUP_MEMBER_NICKNAME_UPDATE 事件);不命中则等 fetchGroupMemberList 兜底 */
    updateGroupMemberDisplayUserName(groupId: number, userId: number, displayUserName: string) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === userId)
      if (!member || member.displayUserName === displayUserName) {
        return
      }
      member.displayUserName = displayUserName
      this.saveGroupMemberList(groupId)
    },
 
    /** 局部更新群字段(name / notice / avatar 等);未命中本地缓存时静默忽略,等 fetchGroupList 兜底;新值跟旧值都相同时跳过响应式 + IDB 写 */
    updateGroupFields(groupId: number, fields: Partial<Group>) {
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      const changed = (Object.keys(fields) as (keyof Group)[]).some((k) => group[k] !== fields[k])
      if (!changed) {
        return
      }
      Object.assign(group, fields)
      const conversationStore = useConversationStore()
      conversationStore.updateConversation(ImConversationType.GROUP, groupId, {
        name: getGroupDisplayName(group),
        avatar: group.avatar,
        silent: group.silent
      })
      this.saveGroup(group)
    },
 
    /**
     * 接收 GROUP_* 群广播事件,按 type 分发到对应私有 action
     *
     * WebSocket 实时收走 messageStore.insertMessage 旁路调用
     * store 里没缓存的群静默忽略,等 fetchGroupList 兜底
     */
    applyGroupNotification(groupId: number, type: number, content?: string) {
      if (!groupId) {
        return
      }
      let payload: GroupNotificationPayload
      try {
        payload = content ? JSON.parse(content) : {}
      } catch (error) {
        console.warn(
          '[IM groupStore] applyGroupNotification 解析 content 失败',
          { groupId, type, contentLength: content?.length ?? 0 },
          error
        )
        return
      }
      switch (type) {
        case ImContentType.GROUP_ADMIN_ADD: {
          this.updateGroupMemberRoleList(
            groupId,
            payload.memberUserIds || [],
            ImGroupMemberRole.ADMIN
          )
          this.markGroupMembersExpired(groupId)
          // 自己被加为管理员,原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
          if (isSelfInPayloadMembers(payload)) {
            refreshUnhandledGroupRequests()
          }
          break
        }
        case ImContentType.GROUP_ADMIN_REMOVE: {
          this.updateGroupMemberRoleList(
            groupId,
            payload.memberUserIds || [],
            ImGroupMemberRole.NORMAL
          )
          this.markGroupMembersExpired(groupId)
          if (isSelfInPayloadMembers(payload)) {
            refreshUnhandledGroupRequests()
          }
          break
        }
        case ImContentType.GROUP_BANNED: {
          this.updateGroupFields(groupId, { banned: !!payload.banned })
          break
        }
        case ImContentType.GROUP_CANCEL_MUTED: {
          this.updateGroupFields(groupId, { mutedAll: false })
          break
        }
        case ImContentType.GROUP_CREATE: {
          this.applyGroupCreateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_DISSOLVE: {
          this.removeGroup(groupId)
          break
        }
        case ImContentType.GROUP_INFO_UPDATE: {
          this.applyGroupInfoUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_CANCEL_MUTED: {
          this.applyGroupMemberCancelMutedNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_ENTER: {
          this.applyGroupMemberEnterNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_INVITE: {
          this.applyGroupMemberInviteNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_KICK: {
          this.applyGroupMemberKickNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_MUTED: {
          this.applyGroupMemberMutedNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_NICKNAME_UPDATE: {
          this.applyGroupMemberNicknameUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MEMBER_QUIT: {
          this.applyGroupMemberQuitNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MESSAGE_PIN: {
          this.applyGroupMessagePinNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MESSAGE_UNPIN: {
          this.applyGroupMessageUnpinNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_MUTED: {
          this.updateGroupFields(groupId, { mutedAll: true })
          break
        }
        case ImContentType.GROUP_NAME_UPDATE: {
          this.applyGroupNameUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_NOTICE_UPDATE: {
          this.applyGroupNoticeUpdateNotification(groupId, payload)
          break
        }
        case ImContentType.GROUP_OWNER_TRANSFER: {
          this.applyGroupOwnerTransferNotification(groupId, payload)
          break
        }
      }
    },
 
    /** 创建群广播:群未就位时拉群详情 */
    async applyGroupCreateNotification(groupId: number, payload: GroupNotificationPayload) {
      if (!isSelfInPayloadMembers(payload)) {
        return
      }
      const selfUserId = getCurrentUserId()
      const selfIsOperator = !!selfUserId && payload.operatorUserId === selfUserId
      if (selfIsOperator && this.getGroup(groupId)) {
        return
      }
      await this.fetchGroupInfo(groupId, true)
    },
 
    /** 群名变更:按 newName 局部更新本地群名 */
    applyGroupNameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      if (payload.newName) {
        this.updateGroupFields(groupId, { name: payload.newName })
      }
    },
 
    /** 群公告变更:按 newNotice 局部更新(允许空串作为「清空公告」) */
    applyGroupNoticeUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      this.updateGroupFields(groupId, { notice: payload.newNotice ?? '' })
    },
 
    /** 群信息变更:同步头像、进群审批 */
    applyGroupInfoUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      const fields: Partial<Group> = {}
      if (payload.newAvatar) {
        fields.avatar = payload.newAvatar
      }
      if (payload.newJoinApproval != null) {
        fields.joinApproval = payload.newJoinApproval
      }
      if (Object.keys(fields).length > 0) {
        this.updateGroupFields(groupId, fields)
      }
    },
 
    /** 成员加入:被邀请者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表(新成员 nickname / avatar 不在 payload) */
    async applyGroupMemberInviteNotification(groupId: number, payload: GroupNotificationPayload) {
      // 自己刚被拉进来:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
      if (isSelfInPayloadMembers(payload) && !this.getGroup(groupId)) {
        await this.fetchGroupInfo(groupId, true)
      }
      this.markGroupMembersExpired(groupId)
      this.fetchGroupMemberList(groupId, true).catch(() => undefined)
    },
 
    /** 自由进群:进群者本端 group 未就位先 fetchGroupInfo 初次拉取;所有人都刷成员列表 */
    async applyGroupMemberEnterNotification(groupId: number, payload: GroupNotificationPayload) {
      const selfUserId = getCurrentUserId()
      // 自己自由进群:必须 await fetchGroupInfo 让群入 state.groups,否则 fetchGroupMemberList 的 guard 会兜空
      if (selfUserId && payload.entrantUserId === selfUserId && !this.getGroup(groupId)) {
        await this.fetchGroupInfo(groupId, true)
      }
      this.markGroupMembersExpired(groupId)
      this.fetchGroupMemberList(groupId, true).catch(() => undefined)
    },
 
    /** 成员退群:退群者本人先把 self.status 置 DISABLE 再 removeGroup(保留状态语义 + 维持 groups 列表干净);其他成员从本地列表移除 quitter */
    applyGroupMemberQuitNotification(groupId: number, payload: GroupNotificationPayload) {
      const selfUserId = getCurrentUserId()
      if (selfUserId && payload.operatorUserId === selfUserId) {
        this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
        this.removeGroup(groupId)
      } else if (payload.operatorUserId) {
        this.removeLocalGroupMemberList(groupId, [payload.operatorUserId])
        this.markGroupMembersExpired(groupId)
      }
    },
 
    /** 成员被移出:被踢者本人先把 self.status 置 DISABLE 再 removeGroup;其他成员从本地列表移除被踢者 */
    applyGroupMemberKickNotification(groupId: number, payload: GroupNotificationPayload) {
      const memberIds = payload.memberUserIds || []
      const selfUserId = getCurrentUserId()
      if (isSelfInPayloadMembers(payload)) {
        if (selfUserId) {
          this.updateGroupMemberStatus(groupId, selfUserId, CommonStatusEnum.DISABLE)
        }
        this.removeGroup(groupId)
      } else if (memberIds.length > 0) {
        this.removeLocalGroupMemberList(groupId, memberIds)
        this.markGroupMembersExpired(groupId)
      }
    },
 
    /** 成员昵称变更:按 operatorUserId 局部更新对应 member.displayUserName */
    applyGroupMemberNicknameUpdateNotification(groupId: number, payload: GroupNotificationPayload) {
      if (payload.operatorUserId) {
        this.updateGroupMemberDisplayUserName(
          groupId,
          payload.operatorUserId,
          payload.displayUserName ?? ''
        )
        this.markGroupMembersExpired(groupId)
      }
    },
 
    /** 群主转让:旧群主 → NORMAL,新群主 → OWNER;新群主自己侧重新拉申请列表 */
    applyGroupOwnerTransferNotification(groupId: number, payload: GroupNotificationPayload) {
      if (payload.operatorUserId && payload.newOwnerUserId) {
        this.transferGroupOwner(groupId, payload.operatorUserId, payload.newOwnerUserId)
        this.markGroupMembersExpired(groupId)
      }
      // 自己接管群主:原本看不到的群下未处理申请现在变可见,重新拉一次 unhandledList
      const selfUserId = getCurrentUserId()
      if (selfUserId && payload.newOwnerUserId === selfUserId) {
        refreshUnhandledGroupRequests()
      } else if (selfUserId && payload.operatorUserId === selfUserId) {
        refreshUnhandledGroupRequests()
      }
    },
 
    /** 群消息置顶:从 payload 取消息展示数据加入置顶列表 */
    applyGroupMessagePinNotification(groupId: number, payload: GroupNotificationPayload) {
      const message = payload.message
      if (!message) {
        return
      }
      const group = this.getGroup(groupId)
      if (!group) {
        return
      }
      // 幂等:已存在同 messageId 不重复 push
      const existing = group.pinnedMessages || []
      if (existing.some((msg) => msg.id === message.id)) {
        return
      }
      group.pinnedMessages = [
        ...existing,
        {
          id: message.id,
          clientMessageId: '',
          senderId: message.senderId,
          type: message.type,
          content: message.content,
          status: ImMessageStatus.NORMAL,
          sendTime: new Date(message.sendTime).getTime(),
          targetId: message.groupId || groupId,
          selfSend: message.senderId === getCurrentUserId(),
          atUserIds: message.atUserIds ? [...message.atUserIds] : [],
          receiverUserIds: message.receiverUserIds ? [...message.receiverUserIds] : []
        }
      ]
      this.saveGroup(group)
    },
 
    /** 群消息取消置顶:按 messageId 从本地置顶列表中移除 */
    applyGroupMessageUnpinNotification(groupId: number, payload: GroupNotificationPayload) {
      if (!payload.messageId) {
        return
      }
      const group = this.getGroup(groupId)
      if (!group?.pinnedMessages?.length) {
        return
      }
      const newPinnedMessages = group.pinnedMessages.filter((m) => m.id !== payload.messageId)
      if (newPinnedMessages.length === group.pinnedMessages.length) {
        return
      }
      group.pinnedMessages = newPinnedMessages
      this.saveGroup(group)
    },
 
    /** 单成员禁言:更新目标成员的 muteEndTime */
    applyGroupMemberMutedNotification(groupId: number, payload: GroupNotificationPayload) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
      if (member && payload.muteEndTime) {
        member.muteEndTime = payload.muteEndTime
        this.saveGroupMemberList(groupId)
        this.markGroupMembersExpired(groupId)
      }
    },
 
    /** 单成员取消禁言:清空目标成员的 muteEndTime */
    applyGroupMemberCancelMutedNotification(groupId: number, payload: GroupNotificationPayload) {
      const group = this.getGroup(groupId)
      const member = group?.members?.find((m) => m.userId === payload.mutedUserId)
      if (member) {
        member.muteEndTime = undefined
        this.saveGroupMemberList(groupId)
        this.markGroupMembersExpired(groupId)
      }
    },
 
    /** 切账号时仅清 in-memory,IDB 按 userId 分桶天然隔离,回切秒开 */
    clear() {
      this.groups = []
      this.loaded = false
      this.groupMembersExpired = false
      // 账号切换:递增 epoch 废弃旧账号 in-flight 的成员请求
      storeEpoch++
      // 单飞表跟 in-memory state 一起重置;旧账号 in-flight 的请求 finally 也会自己 delete key,提前清空只是更干脆
      pendingMemberFetches.clear()
      pendingSingleMemberFetches.clear()
    }
  }
})
 
function convertGroup(group: ImGroupApi.GroupRespVO): Group {
  return {
    id: group.id,
    name: group.name,
    avatar: group.avatar,
    notice: group.notice,
    ownerUserId: group.ownerUserId,
    pinnedMessages: group.pinnedMessages?.map((message) => convertGroupMessageVO(message)),
    mutedAll: group.mutedAll,
    banned: group.banned,
    joinApproval: group.joinApproval,
    joinStatus: group.joinStatus,
    groupRemark: group.groupRemark,
    silent: group.silent
  }
}
 
/** 后端 ImGroupMessageApi.GroupMessageRespVO -> 前端 Message:补 targetId / selfSend / sendTime 等派生字段 */
function convertGroupMessageVO(
  message: NonNullable<ImGroupApi.GroupRespVO['pinnedMessages']>[number]
): Message {
  const currentUserId = getCurrentUserId()
  return {
    id: message.id,
    clientMessageId: message.clientMessageId || '',
    type: message.type,
    content: message.content,
    status: message.status,
    sendTime: new Date(message.sendTime).getTime(),
    senderId: message.senderId,
    targetId: message.groupId,
    selfSend: !!currentUserId && message.senderId === currentUserId,
    atUserIds: message.atUserIds || [],
    receiverUserIds: message.receiverUserIds || [],
    receiptStatus: message.receiptStatus,
    readCount: message.readCount
  }
}
 
function convertGroupMember(member: ImGroupMemberApi.GroupMemberRespVO, groupId: number): GroupMember {
  return {
    id: member.id,
    userId: member.userId,
    groupId,
    nickname: member.nickname || String(member.userId),
    avatar: member.avatar,
    displayUserName: member.displayUserName,
    status: member.status,
    role: member.role,
    muteEndTime: member.muteEndTime
  }
}
 
export const useGroupStoreWithOut = () => useGroupStore()
 
// dev: 让 Pinia 的 actions 改动支持 HMR,免去每次改 store 都要硬刷
if (import.meta.hot) {
  import.meta.hot.accept(acceptHMRUpdate(useGroupStore, import.meta.hot))
}