Commit ed9f23ae8c5575b572e3c5b0c4bd0ecfe1128660

Authored by YunaiV
1 parent 719b139c

✨ feat(im): 重构群通知相关,对齐 openim 的消息编号(继续优化代码)

Showing 23 changed files with 362 additions and 222 deletions
yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/collection/CollectionUtils.java
... ... @@ -372,10 +372,13 @@ public class CollectionUtils {
372 372 return false;
373 373 }
374 374  
375   - public static <T> List<T> of(T t, Collection<T> ts) {
  375 + /**
  376 + * 把单元素 head 与集合 tail 合并成新 List(head 在前,tail 顺序保留)
  377 + */
  378 + public static <T> List<T> of(T head, Collection<T> tail) {
376 379 List<T> list = new ArrayList<>();
377   - list.add(t);
378   - CollUtil.addAll(list, ts);
  380 + list.add(head);
  381 + CollUtil.addAll(list, tail);
379 382 return list;
380 383 }
381 384  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
... ... @@ -39,7 +39,7 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
39 39 FRIEND_UPDATE(102, "好友更新", false, false), // 暂无对应类(客户端收到后自行拉取)
40 40  
41 41 // ========== 群事件(1501-1520 直接复用 OpenIM 段位编号;1530+ 我们独有扩展) ==========
42   - // 1500 mirror OpenIM GroupNotificationBegin marker,不使用
  42 + // 1500 对应 OpenIM GroupNotificationBegin 起始位,仅作占位,不使用
43 43 /**
44 44 * 对应 OpenIM:sdkws.GroupCreatedTips(GroupCreatedNotification 1501)
45 45 * 对应自己的类:GroupCreateNotification
... ... @@ -54,7 +54,7 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
54 54 GROUP_INFO_UPDATE(1502, "群信息变更", true, false),
55 55 /**
56 56 * 对应 OpenIM:sdkws.JoinGroupApplicationTips(JoinGroupApplicationNotification 1503)
57   - * TODO @AI 未实现:入群申请;本期 §1.3 Tier 3 不在范围,预留段位以便未来对齐 OpenIM
  57 + * TODO @AI 未实现:入群申请;本期不在范围,预留段位以便未来对齐 OpenIM
58 58 */
59 59 GROUP_JOIN_APPLICATION(1503, "入群申请", true, false),
60 60 /**
... ... @@ -65,12 +65,12 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
65 65 GROUP_MEMBER_QUIT(1504, "成员退群", true, false),
66 66 /**
67 67 * 对应 OpenIM:sdkws.GroupApplicationAcceptedTips(GroupApplicationAcceptedNotification 1505)
68   - * TODO @AI 未实现:入群申请通过;本期 §1.3 Tier 3 不在范围
  68 + * TODO @AI 未实现:入群申请通过;本期不在范围
69 69 */
70 70 GROUP_APPLICATION_ACCEPTED(1505, "入群申请通过", true, false),
71 71 /**
72 72 * 对应 OpenIM:sdkws.GroupApplicationRejectedTips(GroupApplicationRejectedNotification 1506)
73   - * TODO @AI 未实现:入群申请拒绝;本期 §1.3 Tier 3 不在范围
  73 + * TODO @AI 未实现:入群申请拒绝;本期不在范围
74 74 */
75 75 GROUP_APPLICATION_REJECTED(1506, "入群申请拒绝", true, false),
76 76 /**
... ... @@ -93,7 +93,7 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
93 93 GROUP_MEMBER_INVITE(1509, "成员加入", true, false),
94 94 /**
95 95 * 对应 OpenIM:sdkws.MemberEnterTips(MemberEnterNotification 1510)
96   - * TODO @AI 未实现:自由进群(链接 / 二维码进群);本期 §1.3 Tier 3 不在范围
  96 + * TODO @AI 未实现:自由进群(链接 / 二维码进群);本期不在范围
97 97 */
98 98 GROUP_MEMBER_ENTER(1510, "自由进群", true, false),
99 99 /**
... ... @@ -104,22 +104,22 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
104 104 GROUP_DISSOLVE(1511, "群解散", true, false),
105 105 /**
106 106 * 对应 OpenIM:sdkws.GroupMemberMutedTips(GroupMemberMutedNotification 1512)
107   - * TODO @AI 未实现:单成员禁言(管理员禁言某成员);本期 §1.3 Tier 3 不在范围【这个本期发版,是要实现的】
  107 + * TODO @AI 未实现:单成员禁言(管理员禁言某成员);本期发版要实现
108 108 */
109 109 GROUP_MEMBER_MUTED(1512, "成员禁言", true, false),
110 110 /**
111 111 * 对应 OpenIM:sdkws.GroupMemberCancelMutedTips(GroupMemberCancelMutedNotification 1513)
112   - * TODO @AI 未实现:单成员取消禁言;本期 §1.3 Tier 3 不在范围【这个本期发版,是要实现的】
  112 + * TODO @AI 未实现:单成员取消禁言;本期发版要实现
113 113 */
114 114 GROUP_MEMBER_CANCEL_MUTED(1513, "成员取消禁言", true, false),
115 115 /**
116 116 * 对应 OpenIM:sdkws.GroupMutedTips(GroupMutedNotification 1514)
117   - * TODO @AI 未实现:全群禁言(管理员把整个群设为禁言状态,所有成员都不能发消息);本期 §1.3 Tier 3 不在范围【这个本期发版,是要实现的】
  117 + * TODO @AI 未实现:全群禁言(管理员把整个群设为禁言状态,所有成员都不能发消息);本期发版要实现
118 118 */
119 119 GROUP_MUTED(1514, "全群禁言", true, false),
120 120 /**
121 121 * 对应 OpenIM:sdkws.GroupCancelMutedTips(GroupCancelMutedNotification 1515)
122   - * TODO @AI 未实现:全群取消禁言;本期 §1.3 Tier 3 不在范围【这个本期发版,是要实现的】
  122 + * TODO @AI 未实现:全群取消禁言;本期发版要实现
123 123 */
124 124 GROUP_CANCEL_MUTED(1515, "全群取消禁言", true, false),
125 125 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
1 1 package cn.iocoder.yudao.module.im.service.group;
2 2  
3 3 import cn.hutool.core.collection.CollUtil;
4   -import cn.hutool.core.util.StrUtil;
  4 +import cn.hutool.core.util.ObjUtil;
5 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
6 6 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
7 7 import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberUpdateReqVO;
... ... @@ -23,7 +23,8 @@ import java.time.LocalDateTime;
23 23 import java.util.*;
24 24  
25 25 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
26   -import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*;
  26 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
  27 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
27 28 import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MEMBER_IDS;
28 29 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.GROUP_MEMBER_NOT_IN_GROUP;
29 30  
... ... @@ -215,13 +216,18 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
215 216 .setId(member.getId());
216 217 groupMemberMapper.updateById(updateObj);
217 218  
218   - // 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 广播给全员
219   - if (StrUtil.isNotEmpty(updateReqVO.getDisplayUserName())) {
  219 + // 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 广播给全员;空串视为「清空昵称」也要广播;与旧值相同跳过
  220 + if (updateReqVO.getDisplayUserName() != null
  221 + && ObjUtil.notEqual(updateReqVO.getDisplayUserName(), member.getDisplayUserName())) {
220 222 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberNicknameUpdate(
221 223 groupId, userId, updateReqVO.getDisplayUserName()));
222 224 }
223   - // 3.2 muted / groupRemark 是个人字段,target 显式指定为自己仅推自己多端同步;payload 携带变更字段,前端按非 null 局部更新
224   - if (updateReqVO.getMuted() != null || updateReqVO.getGroupRemark() != null) {
  225 + // 3.2 muted / groupRemark 是个人字段,仅推自己做多端同步;与旧值都相同跳过
  226 + boolean mutedChanged = updateReqVO.getMuted() != null
  227 + && ObjUtil.notEqual(updateReqVO.getMuted(), member.getMuted());
  228 + boolean groupRemarkChanged = updateReqVO.getGroupRemark() != null
  229 + && ObjUtil.notEqual(updateReqVO.getGroupRemark(), member.getGroupRemark());
  230 + if (mutedChanged || groupRemarkChanged) {
225 231 groupMessageService.sendGroupMessage(userId, List.of(userId), ImGroupMessageSendDTO.ofGroupMemberSettingUpdate(
226 232 groupId, userId, updateReqVO.getMuted(), updateReqVO.getGroupRemark()));
227 233 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java
... ... @@ -18,7 +18,7 @@ import java.util.List;
18 18 import java.util.Map;
19 19  
20 20 /**
21   - * 群 Service 接口
  21 + * 用户群群 Service 接口
22 22 *
23 23 * @author 芋道源码
24 24 */
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -10,7 +10,11 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
10 10 import cn.iocoder.yudao.framework.common.pojo.PageResult;
11 11 import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
12 12 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
13   -import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
  13 +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO;
  14 +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO;
  15 +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO;
  16 +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO;
  17 +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO;
14 18 import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO;
15 19 import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO;
16 20 import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManagerBanReqVO;
... ... @@ -45,7 +49,7 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
45 49 import static cn.iocoder.yudao.module.im.enums.ImCommonConstants.*;
46 50  
47 51 /**
48   - * 群 Service 实现类
  52 + * 用户群 Service 实现类
49 53 *
50 54 * @author 芋道源码
51 55 */
... ... @@ -125,19 +129,26 @@ public class ImGroupServiceImpl implements ImGroupService {
125 129 ImGroupDO newGroup = BeanUtils.toBean(updateReqVO, ImGroupDO.class);
126 130 groupMapper.updateById(newGroup);
127 131  
128   - // 3. 按变更字段分别推送 GROUP_NAME / NOTICE / INFO_UPDATE 通知
  132 + // 3. 按变更字段分别推送 GROUP_NAME / NOTICE / INFO_UPDATE 通知;活跃成员只查一次复用,避免 3 次 Redis GET
  133 + // name / avatar 不允许空串(业务上必须非空),notice 允许空串(清空公告也是有效操作)
129 134 Long groupId = group.getId();
130   - if (StrUtil.isNotEmpty(updateReqVO.getName())) {
131   - groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupNameUpdate(
132   - groupId, userId, group.getName(), updateReqVO.getName()));
133   - }
134   - if (updateReqVO.getNotice() != null) {
135   - groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupNoticeUpdate(
136   - groupId, userId, group.getNotice(), updateReqVO.getNotice()));
137   - }
138   - if (StrUtil.isNotEmpty(updateReqVO.getAvatar())) {
139   - groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupInfoUpdate(
140   - groupId, userId, group.getAvatar(), updateReqVO.getAvatar()));
  135 + boolean nameChanged = StrUtil.isNotEmpty(updateReqVO.getName());
  136 + boolean noticeChanged = updateReqVO.getNotice() != null;
  137 + boolean avatarChanged = StrUtil.isNotEmpty(updateReqVO.getAvatar());
  138 + if (nameChanged || noticeChanged || avatarChanged) {
  139 + List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId);
  140 + if (nameChanged) {
  141 + groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupNameUpdate(
  142 + groupId, userId, group.getName(), updateReqVO.getName()));
  143 + }
  144 + if (noticeChanged) {
  145 + groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupNoticeUpdate(
  146 + groupId, userId, group.getNotice(), updateReqVO.getNotice()));
  147 + }
  148 + if (avatarChanged) {
  149 + groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupInfoUpdate(
  150 + groupId, userId, group.getAvatar(), updateReqVO.getAvatar()));
  151 + }
141 152 }
142 153  
143 154 // 4. 返回合并后的新群信息(updateReqVO 非空字段覆盖 group)
... ... @@ -194,8 +205,10 @@ public class ImGroupServiceImpl implements ImGroupService {
194 205 // 2. 批量添加群成员
195 206 groupMemberService.addGroupMembers(groupId, memberUserIds);
196 207  
197   - // 3. 发 GROUP_MEMBER_INVITE 通知给全员;新成员已 ENABLE,sendGroupMessage 查到的 active 自然包含
198   - groupMessageService.sendGroupMessage(userId,
  208 + // 3. 发 GROUP_MEMBER_INVITE 通知给全员;本地拼 receivers(已查的 active + 新邀请)避免缓存刚 evict 后强制走 DB
  209 + Set<Long> allReceivers = new HashSet<>(memberUserIds);
  210 + activeMembers.forEach(member -> allReceivers.add(member.getUserId()));
  211 + groupMessageService.sendGroupMessage(userId, allReceivers,
199 212 ImGroupMessageSendDTO.ofGroupMemberInvite(groupId, userId, memberUserIds));
200 213 }
201 214  
... ... @@ -208,7 +221,7 @@ public class ImGroupServiceImpl implements ImGroupService {
208 221 throw exception(GROUP_OWNER_CANNOT_QUIT);
209 222 }
210 223  
211   - // 3. 先发 GROUP_MEMBER_QUIT 通知:放在 quitter 移除前,sendGroupMessage 才能查到全员(含 quitter)
  224 + // 3. 先发广播,后移成员(见类 javadoc)
212 225 groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId));
213 226  
214 227 // 4.1 移除群成员
... ... @@ -241,7 +254,7 @@ public class ImGroupServiceImpl implements ImGroupService {
241 254  
242 255 // 2. 先发 GROUP_MEMBER_KICK 通知:放在被踢者移除前,sendGroupMessage 才能查到全员(含被踢者)
243 256 groupMessageService.sendGroupMessage(userId,
244   - ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, new ArrayList<>(targetUserIds)));
  257 + ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, targetUserIds));
245 258  
246 259 // 3.1 批量移除群成员
247 260 groupMemberService.removeGroupMembers(groupId, targetUserIds);
... ... @@ -279,7 +292,7 @@ public class ImGroupServiceImpl implements ImGroupService {
279 292  
280 293 // 3. 推送 GROUP_ADMIN_ADD 通知给全员
281 294 groupMessageService.sendGroupMessage(userId,
282   - ImGroupMessageSendDTO.ofGroupAdminAdd(groupId, userId, new ArrayList<>(changedUserIds)));
  295 + ImGroupMessageSendDTO.ofGroupAdminAdd(groupId, userId, changedUserIds));
283 296 }
284 297  
285 298 @Override
... ... @@ -306,7 +319,7 @@ public class ImGroupServiceImpl implements ImGroupService {
306 319  
307 320 // 3. 推送 GROUP_ADMIN_REMOVE 通知给全员
308 321 groupMessageService.sendGroupMessage(userId,
309   - ImGroupMessageSendDTO.ofGroupAdminRemove(groupId, userId, new ArrayList<>(changedUserIds)));
  322 + ImGroupMessageSendDTO.ofGroupAdminRemove(groupId, userId, changedUserIds));
310 323 }
311 324  
312 325 /**
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
... ... @@ -30,7 +30,9 @@ public interface ImGroupMessageService {
30 30 ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendReqVO reqVO);
31 31  
32 32 /**
33   - * 【系统调用】发送群聊消息:自动算可见用户 + 推送
  33 + * 【系统调用】发送群聊消息:内部查 active 成员 + 推送
  34 + * <p>
  35 + * 调用方批量调用会触发多次 active 成员查询;如果调用方已经持有成员快照,优先使用 {@link #sendGroupMessage(Long, Collection, ImGroupMessageSendDTO)} 方法,避免重复查询。
34 36 *
35 37 * @param senderId 发送人编号
36 38 * @param dto 消息 DTO
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java
... ... @@ -5,6 +5,8 @@ import cn.iocoder.yudao.module.im.service.websocket.dto.notification.*;
5 5 import jakarta.validation.constraints.NotNull;
6 6 import lombok.Data;
7 7  
  8 +import java.util.ArrayList;
  9 +import java.util.Collection;
8 10 import java.util.List;
9 11  
10 12 /**
... ... @@ -52,93 +54,108 @@ public class ImGroupMessageSendDTO {
52 54  
53 55 // ========== 群广播事件静态工厂(对应 ImMessageTypeEnum 群事件) ==========
54 56  
55   - public static ImGroupMessageSendDTO ofGroupCreate(Long groupId, Long operatorUserId, List<Long> memberUserIds) {
56   - GroupCreateNotification notification = new GroupCreateNotification()
57   - .setOperatorUserId(operatorUserId).setMemberUserIds(memberUserIds);
  57 + public static ImGroupMessageSendDTO ofGroupCreate(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
  58 + GroupCreateNotification notification = new GroupCreateNotification();
  59 + notification.setOperatorUserId(operatorUserId);
  60 + notification.setMemberUserIds(new ArrayList<>(memberUserIds));
58 61 return new ImGroupMessageSendDTO().setGroupId(groupId)
59 62 .setType(ImMessageTypeEnum.GROUP_CREATE.getType()).setContent(notification);
60 63 }
61 64  
62 65 public static ImGroupMessageSendDTO ofGroupInfoUpdate(Long groupId, Long operatorUserId, String oldAvatar, String newAvatar) {
63   - GroupInfoUpdateNotification notification = new GroupInfoUpdateNotification()
64   - .setOperatorUserId(operatorUserId).setOldAvatar(oldAvatar).setNewAvatar(newAvatar);
  66 + GroupInfoUpdateNotification notification = new GroupInfoUpdateNotification();
  67 + notification.setOperatorUserId(operatorUserId);
  68 + notification.setOldAvatar(oldAvatar);
  69 + notification.setNewAvatar(newAvatar);
65 70 return new ImGroupMessageSendDTO().setGroupId(groupId)
66 71 .setType(ImMessageTypeEnum.GROUP_INFO_UPDATE.getType()).setContent(notification);
67 72 }
68 73  
69 74 public static ImGroupMessageSendDTO ofGroupMemberQuit(Long groupId, Long operatorUserId) {
70   - GroupMemberQuitNotification notification = new GroupMemberQuitNotification()
71   - .setOperatorUserId(operatorUserId);
  75 + GroupMemberQuitNotification notification = new GroupMemberQuitNotification();
  76 + notification.setOperatorUserId(operatorUserId);
72 77 return new ImGroupMessageSendDTO().setGroupId(groupId)
73 78 .setType(ImMessageTypeEnum.GROUP_MEMBER_QUIT.getType()).setContent(notification);
74 79 }
75 80  
76 81 public static ImGroupMessageSendDTO ofGroupOwnerTransfer(Long groupId, Long operatorUserId, Long newOwnerUserId) {
77   - GroupOwnerTransferNotification notification = new GroupOwnerTransferNotification()
78   - .setOperatorUserId(operatorUserId).setNewOwnerUserId(newOwnerUserId);
  82 + GroupOwnerTransferNotification notification = new GroupOwnerTransferNotification();
  83 + notification.setOperatorUserId(operatorUserId);
  84 + notification.setNewOwnerUserId(newOwnerUserId);
79 85 return new ImGroupMessageSendDTO().setGroupId(groupId)
80 86 .setType(ImMessageTypeEnum.GROUP_OWNER_TRANSFER.getType()).setContent(notification);
81 87 }
82 88  
83   - public static ImGroupMessageSendDTO ofGroupMemberKick(Long groupId, Long operatorUserId, List<Long> memberUserIds) {
84   - GroupMemberKickNotification notification = new GroupMemberKickNotification()
85   - .setOperatorUserId(operatorUserId).setMemberUserIds(memberUserIds);
  89 + public static ImGroupMessageSendDTO ofGroupMemberKick(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
  90 + GroupMemberKickNotification notification = new GroupMemberKickNotification();
  91 + notification.setOperatorUserId(operatorUserId);
  92 + notification.setMemberUserIds(new ArrayList<>(memberUserIds));
86 93 return new ImGroupMessageSendDTO().setGroupId(groupId)
87 94 .setType(ImMessageTypeEnum.GROUP_MEMBER_KICK.getType()).setContent(notification);
88 95 }
89 96  
90   - public static ImGroupMessageSendDTO ofGroupMemberInvite(Long groupId, Long operatorUserId, List<Long> memberUserIds) {
91   - GroupMemberInviteNotification notification = new GroupMemberInviteNotification()
92   - .setOperatorUserId(operatorUserId).setMemberUserIds(memberUserIds);
  97 + public static ImGroupMessageSendDTO ofGroupMemberInvite(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
  98 + GroupMemberInviteNotification notification = new GroupMemberInviteNotification();
  99 + notification.setOperatorUserId(operatorUserId);
  100 + notification.setMemberUserIds(new ArrayList<>(memberUserIds));
93 101 return new ImGroupMessageSendDTO().setGroupId(groupId)
94 102 .setType(ImMessageTypeEnum.GROUP_MEMBER_INVITE.getType()).setContent(notification);
95 103 }
96 104  
97 105 public static ImGroupMessageSendDTO ofGroupDissolve(Long groupId, Long operatorUserId) {
98   - GroupDissolveNotification notification = new GroupDissolveNotification()
99   - .setOperatorUserId(operatorUserId);
  106 + GroupDissolveNotification notification = new GroupDissolveNotification();
  107 + notification.setOperatorUserId(operatorUserId);
100 108 return new ImGroupMessageSendDTO().setGroupId(groupId)
101 109 .setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent(notification);
102 110 }
103 111  
104 112 public static ImGroupMessageSendDTO ofGroupMemberNicknameUpdate(Long groupId, Long operatorUserId, String displayUserName) {
105   - GroupMemberNicknameUpdateNotification notification = new GroupMemberNicknameUpdateNotification()
106   - .setOperatorUserId(operatorUserId).setDisplayUserName(displayUserName);
  113 + GroupMemberNicknameUpdateNotification notification = new GroupMemberNicknameUpdateNotification();
  114 + notification.setOperatorUserId(operatorUserId);
  115 + notification.setDisplayUserName(displayUserName);
107 116 return new ImGroupMessageSendDTO().setGroupId(groupId)
108 117 .setType(ImMessageTypeEnum.GROUP_MEMBER_NICKNAME_UPDATE.getType()).setContent(notification);
109 118 }
110 119  
111   - public static ImGroupMessageSendDTO ofGroupAdminAdd(Long groupId, Long operatorUserId, List<Long> memberUserIds) {
112   - GroupAdminAddNotification notification = new GroupAdminAddNotification()
113   - .setOperatorUserId(operatorUserId).setMemberUserIds(memberUserIds);
  120 + public static ImGroupMessageSendDTO ofGroupAdminAdd(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
  121 + GroupAdminAddNotification notification = new GroupAdminAddNotification();
  122 + notification.setOperatorUserId(operatorUserId);
  123 + notification.setMemberUserIds(new ArrayList<>(memberUserIds));
114 124 return new ImGroupMessageSendDTO().setGroupId(groupId)
115 125 .setType(ImMessageTypeEnum.GROUP_ADMIN_ADD.getType()).setContent(notification);
116 126 }
117 127  
118   - public static ImGroupMessageSendDTO ofGroupAdminRemove(Long groupId, Long operatorUserId, List<Long> memberUserIds) {
119   - GroupAdminRemoveNotification notification = new GroupAdminRemoveNotification()
120   - .setOperatorUserId(operatorUserId).setMemberUserIds(memberUserIds);
  128 + public static ImGroupMessageSendDTO ofGroupAdminRemove(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
  129 + GroupAdminRemoveNotification notification = new GroupAdminRemoveNotification();
  130 + notification.setOperatorUserId(operatorUserId);
  131 + notification.setMemberUserIds(new ArrayList<>(memberUserIds));
121 132 return new ImGroupMessageSendDTO().setGroupId(groupId)
122 133 .setType(ImMessageTypeEnum.GROUP_ADMIN_REMOVE.getType()).setContent(notification);
123 134 }
124 135  
125 136 public static ImGroupMessageSendDTO ofGroupNoticeUpdate(Long groupId, Long operatorUserId, String oldNotice, String newNotice) {
126   - GroupNoticeUpdateNotification notification = new GroupNoticeUpdateNotification()
127   - .setOperatorUserId(operatorUserId).setOldNotice(oldNotice).setNewNotice(newNotice);
  137 + GroupNoticeUpdateNotification notification = new GroupNoticeUpdateNotification();
  138 + notification.setOperatorUserId(operatorUserId);
  139 + notification.setOldNotice(oldNotice);
  140 + notification.setNewNotice(newNotice);
128 141 return new ImGroupMessageSendDTO().setGroupId(groupId)
129 142 .setType(ImMessageTypeEnum.GROUP_NOTICE_UPDATE.getType()).setContent(notification);
130 143 }
131 144  
132 145 public static ImGroupMessageSendDTO ofGroupNameUpdate(Long groupId, Long operatorUserId, String oldName, String newName) {
133   - GroupNameUpdateNotification notification = new GroupNameUpdateNotification()
134   - .setOperatorUserId(operatorUserId).setOldName(oldName).setNewName(newName);
  146 + GroupNameUpdateNotification notification = new GroupNameUpdateNotification();
  147 + notification.setOperatorUserId(operatorUserId);
  148 + notification.setOldName(oldName);
  149 + notification.setNewName(newName);
135 150 return new ImGroupMessageSendDTO().setGroupId(groupId)
136 151 .setType(ImMessageTypeEnum.GROUP_NAME_UPDATE.getType()).setContent(notification);
137 152 }
138 153  
139 154 public static ImGroupMessageSendDTO ofGroupMemberSettingUpdate(Long groupId, Long operatorUserId, Boolean muted, String groupRemark) {
140   - GroupMemberSettingUpdateNotification notification = new GroupMemberSettingUpdateNotification()
141   - .setOperatorUserId(operatorUserId).setMuted(muted).setGroupRemark(groupRemark);
  155 + GroupMemberSettingUpdateNotification notification = new GroupMemberSettingUpdateNotification();
  156 + notification.setOperatorUserId(operatorUserId);
  157 + notification.setMuted(muted);
  158 + notification.setGroupRemark(groupRemark);
142 159 return new ImGroupMessageSendDTO().setGroupId(groupId)
143 160 .setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification);
144 161 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/BaseGroupNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
  2 +
  3 +import lombok.Data;
  4 +
  5 +/**
  6 + * 群事件通知基类:所有群事件 payload 共享 operatorUserId
  7 + */
  8 +@Data
  9 +public abstract class BaseGroupNotification {
  10 +
  11 + /**
  12 + * 操作人用户编号
  13 + */
  14 + private Long operatorUserId;
  15 +
  16 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupAdminAddNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5   -import java.util.List;
6   -
7 3 /**
8   - * 添加管理员事件通知
  4 + * 添加管理员事件通知(memberUserIds 为被设管理员的成员)
9 5 */
10   -@Data
11   -public class GroupAdminAddNotification {
12   -
13   - /**
14   - * 操作人(群主)用户编号
15   - */
16   - private Long operatorUserId;
17   - /**
18   - * 被设为管理员的成员用户编号列表
19   - */
20   - private List<Long> memberUserIds;
21   -
  6 +public class GroupAdminAddNotification extends MembersAffectedNotification {
22 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupAdminRemoveNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5   -import java.util.List;
6   -
7 3 /**
8   - * 撤销管理员事件通知
  4 + * 撤销管理员事件通知(memberUserIds 为被撤销管理员的成员)
9 5 */
10   -@Data
11   -public class GroupAdminRemoveNotification {
12   -
13   - /**
14   - * 操作人(群主)用户编号
15   - */
16   - private Long operatorUserId;
17   - /**
18   - * 被撤销管理员的成员用户编号列表
19   - */
20   - private List<Long> memberUserIds;
21   -
  6 +public class GroupAdminRemoveNotification extends MembersAffectedNotification {
22 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupCreateNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5   -import java.util.List;
6   -
7 3 /**
8   - * 群创建事件通知
  4 + * 群创建事件通知(memberUserIds 含创建者 + 初始邀请成员)
9 5 */
10   -@Data
11   -public class GroupCreateNotification {
12   -
13   - /**
14   - * 操作人用户编号
15   - */
16   - private Long operatorUserId;
17   - /**
18   - * 群成员用户编号列表(含创建者 + 初始邀请成员)
19   - */
20   - private List<Long> memberUserIds;
21   -
  6 +public class GroupCreateNotification extends MembersAffectedNotification {
22 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupDissolveNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5 3 /**
6 4 * 群解散事件通知
7 5 */
8   -@Data
9   -public class GroupDissolveNotification {
10   -
11   - /**
12   - * 操作人(群主)用户编号
13   - */
14   - private Long operatorUserId;
15   -
  6 +public class GroupDissolveNotification extends BaseGroupNotification {
16 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupInfoUpdateNotification.java
... ... @@ -3,16 +3,12 @@ package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
3 3 import lombok.Data;
4 4  
5 5 /**
6   - * 群信息变更事件通知(NAME / NOTICE 之外字段的兜底)
  6 + * 群信息变更事件通知(当前承载头像变更,NAME / NOTICE 走独立事件)
7 7 */
8 8 @Data
9   -public class GroupInfoUpdateNotification {
  9 +public class GroupInfoUpdateNotification extends BaseGroupNotification {
10 10  
11 11 /**
12   - * 操作人用户编号
13   - */
14   - private Long operatorUserId;
15   - /**
16 12 * 旧群头像
17 13 */
18 14 private String oldAvatar;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberInviteNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5   -import java.util.List;
6   -
7 3 /**
8   - * 成员加入事件通知
  4 + * 成员加入事件通知(memberUserIds 为被邀请人)
9 5 */
10   -@Data
11   -public class GroupMemberInviteNotification {
12   -
13   - /**
14   - * 操作人(邀请人)用户编号
15   - */
16   - private Long operatorUserId;
17   - /**
18   - * 被邀请人用户编号列表
19   - */
20   - private List<Long> memberUserIds;
21   -
  6 +public class GroupMemberInviteNotification extends MembersAffectedNotification {
22 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberKickNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5   -import java.util.List;
6   -
7 3 /**
8   - * 成员被移出事件通知
  4 + * 成员被移出事件通知(memberUserIds 为被移出成员)
9 5 */
10   -@Data
11   -public class GroupMemberKickNotification {
12   -
13   - /**
14   - * 操作人(群主 / 管理员)用户编号
15   - */
16   - private Long operatorUserId;
17   - /**
18   - * 被移出的成员用户编号列表
19   - */
20   - private List<Long> memberUserIds;
21   -
  6 +public class GroupMemberKickNotification extends MembersAffectedNotification {
22 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberNicknameUpdateNotification.java
... ... @@ -6,13 +6,9 @@ import lombok.Data;
6 6 * 成员组内昵称变更事件通知
7 7 */
8 8 @Data
9   -public class GroupMemberNicknameUpdateNotification {
  9 +public class GroupMemberNicknameUpdateNotification extends BaseGroupNotification {
10 10  
11 11 /**
12   - * 操作人(昵称变更者本人)用户编号
13   - */
14   - private Long operatorUserId;
15   - /**
16 12 * 群内昵称
17 13 */
18 14 private String displayUserName;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberQuitNotification.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
2 2  
3   -import lombok.Data;
4   -
5 3 /**
6 4 * 成员退群事件通知
7 5 */
8   -@Data
9   -public class GroupMemberQuitNotification {
10   -
11   - /**
12   - * 操作人(退群者本人)用户编号
13   - */
14   - private Long operatorUserId;
15   -
  6 +public class GroupMemberQuitNotification extends BaseGroupNotification {
16 7 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMemberSettingUpdateNotification.java
... ... @@ -4,20 +4,18 @@ import lombok.Data;
4 4  
5 5 /**
6 6 * 群成员个人设置变更事件通知(个人多端同步)
  7 + * <p>
  8 + * muted / groupRemark 字段 null 表示本次未变更,前端按非 null 局部更新
7 9 */
8 10 @Data
9   -public class GroupMemberSettingUpdateNotification {
  11 +public class GroupMemberSettingUpdateNotification extends BaseGroupNotification {
10 12  
11 13 /**
12   - * 操作人(设置变更者本人)用户编号
13   - */
14   - private Long operatorUserId;
15   - /**
16   - * 群免打扰,null 表示本次未变更
  14 + * 群免打扰
17 15 */
18 16 private Boolean muted;
19 17 /**
20   - * 群备注,null 表示本次未变更
  18 + * 群备注
21 19 */
22 20 private String groupRemark;
23 21  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupNameUpdateNotification.java
... ... @@ -6,13 +6,9 @@ import lombok.Data;
6 6 * 群名变更事件通知
7 7 */
8 8 @Data
9   -public class GroupNameUpdateNotification {
  9 +public class GroupNameUpdateNotification extends BaseGroupNotification {
10 10  
11 11 /**
12   - * 操作人用户编号
13   - */
14   - private Long operatorUserId;
15   - /**
16 12 * 旧群名
17 13 */
18 14 private String oldName;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupNoticeUpdateNotification.java
... ... @@ -6,13 +6,9 @@ import lombok.Data;
6 6 * 群公告变更事件通知
7 7 */
8 8 @Data
9   -public class GroupNoticeUpdateNotification {
  9 +public class GroupNoticeUpdateNotification extends BaseGroupNotification {
10 10  
11 11 /**
12   - * 操作人用户编号
13   - */
14   - private Long operatorUserId;
15   - /**
16 12 * 旧群公告
17 13 */
18 14 private String oldNotice;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupOwnerTransferNotification.java
... ... @@ -6,13 +6,9 @@ import lombok.Data;
6 6 * 群主转让事件通知
7 7 */
8 8 @Data
9   -public class GroupOwnerTransferNotification {
  9 +public class GroupOwnerTransferNotification extends BaseGroupNotification {
10 10  
11 11 /**
12   - * 操作人(旧群主)用户编号
13   - */
14   - private Long operatorUserId;
15   - /**
16 12 * 新群主用户编号
17 13 */
18 14 private Long newOwnerUserId;
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/MembersAffectedNotification.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.notification;
  2 +
  3 +import lombok.Data;
  4 +
  5 +import java.util.List;
  6 +
  7 +/**
  8 + * 涉及成员列表的群事件通知基类:用于 Create / Invite / Kick / AdminAdd / AdminRemove
  9 + */
  10 +@Data
  11 +public abstract class MembersAffectedNotification extends BaseGroupNotification {
  12 +
  13 + /**
  14 + * 受影响的成员用户编号列表
  15 + */
  16 + private List<Long> memberUserIds;
  17 +
  18 +}
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/sensitiveword/ImSensitiveWordServiceImplTest.java
... ... @@ -2,17 +2,23 @@ package cn.iocoder.yudao.module.im.service.sensitiveword;
2 2  
3 3 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
4 4 import cn.iocoder.yudao.framework.common.exception.ServiceException;
  5 +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
5 6 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  7 +import cn.iocoder.yudao.module.im.controller.admin.manager.sensitiveword.vo.ImSensitiveWordSaveReqVO;
6 8 import cn.iocoder.yudao.module.im.dal.dataobject.sensitiveword.ImSensitiveWordDO;
7 9 import cn.iocoder.yudao.module.im.dal.mysql.sensitiveword.ImSensitiveWordMapper;
  10 +import org.junit.jupiter.api.AfterEach;
8 11 import org.junit.jupiter.api.BeforeEach;
9 12 import org.junit.jupiter.api.Test;
10 13 import org.mockito.InjectMocks;
11 14 import org.mockito.Mock;
12 15  
  16 +import java.time.LocalDateTime;
13 17 import java.util.List;
14 18  
15 19 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_SENSITIVE_WORD_BLOCKED;
  20 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_DUPLICATED;
  21 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.SENSITIVE_WORD_NOT_EXISTS;
16 22 import static org.junit.jupiter.api.Assertions.*;
17 23 import static org.mockito.Mockito.*;
18 24  
... ... @@ -23,6 +29,8 @@ import static org.mockito.Mockito.*;
23 29 */
24 30 public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
25 31  
  32 + private static final Long TENANT_ID = 1L;
  33 +
26 34 @InjectMocks
27 35 private ImSensitiveWordServiceImpl sensitiveWordService;
28 36  
... ... @@ -31,15 +39,23 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
31 39  
32 40 @BeforeEach
33 41 public void setUp() {
34   - // 模拟 @PostConstruct 初始化:加载 2 个启用的敏感词
35   - when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
  42 + // 设置租户上下文;validateText 与 loadFresh 都依赖 TenantContextHolder
  43 + TenantContextHolder.setTenantId(TENANT_ID);
  44 + // mock 启用敏感词列表;LoadingCache 在首次 validateText 时懒加载,无需主动 init
  45 + // 用 lenient 避免 null / empty 等不触发 cache load 的用例报 UnnecessaryStubbing
  46 + lenient().when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
36 47 .thenReturn(List.of(
37 48 ImSensitiveWordDO.builder().id(1L).word("badword")
38 49 .status(CommonStatusEnum.ENABLE.getStatus()).build(),
39 50 ImSensitiveWordDO.builder().id(2L).word("违禁词")
40 51 .status(CommonStatusEnum.ENABLE.getStatus()).build()
41 52 ));
42   -// sensitiveWordService.init();
  53 + }
  54 +
  55 + @AfterEach
  56 + public void tearDown() {
  57 + // 清理租户上下文,避免污染其它测试
  58 + TenantContextHolder.clear();
43 59 }
44 60  
45 61 @Test
... ... @@ -74,24 +90,188 @@ public class ImSensitiveWordServiceImplTest extends BaseMockitoUnitTest {
74 90 }
75 91  
76 92 @Test
77   - public void testRefresh_reloadsFromMapper() {
78   - // 刷新前:init 调用 mapper 一次
79   - verify(imSensitiveWordMapper).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  93 + public void testValidateText_lazyLoadsCacheOnFirstCall() {
  94 + // 调用:首次 validateText 应触发 cache load
  95 + sensitiveWordService.validateText("hello world");
  96 +
  97 + // 断言:mapper 各调用 1 次(loadFresh 里先取 maxUpdateTime 再读词库)
  98 + verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
  99 + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  100 + }
  101 +
  102 + @Test
  103 + public void testValidateText_reusesCachedBsAcrossCalls() {
  104 + // 调用:连续两次 validateText
  105 + sensitiveWordService.validateText("hello world");
  106 + sensitiveWordService.validateText("another text");
  107 +
  108 + // 断言:第二次复用 cache,mapper 仍只被调用 1 次
  109 + verify(imSensitiveWordMapper, times(1)).selectMaxUpdateTime(TENANT_ID);
  110 + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  111 + }
  112 +
  113 + @Test
  114 + public void testCreateSensitiveWord_invalidatesCacheAndReloadsOnNextValidate() {
  115 + // 准备:首次 validateText 触发 cache load(旧词库)
  116 + sensitiveWordService.validateText("hello world");
  117 + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  118 +
  119 + // 准备:mapper 返回新词库(额外多一个 newbad),并让 createSensitiveWord 走通
  120 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
  121 + .thenReturn(List.of(
  122 + ImSensitiveWordDO.builder().id(1L).word("badword")
  123 + .status(CommonStatusEnum.ENABLE.getStatus()).build(),
  124 + ImSensitiveWordDO.builder().id(2L).word("违禁词")
  125 + .status(CommonStatusEnum.ENABLE.getStatus()).build(),
  126 + ImSensitiveWordDO.builder().id(3L).word("newbad")
  127 + .status(CommonStatusEnum.ENABLE.getStatus()).build()
  128 + ));
  129 + when(imSensitiveWordMapper.selectByWord("newbad")).thenReturn(null);
  130 +
  131 + // 调用:新增敏感词,触发 invalidate
  132 + ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
  133 + reqVO.setWord("newbad");
  134 + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
  135 + sensitiveWordService.createSensitiveWord(reqVO);
  136 +
  137 + // 调用:再次 validateText,应触发重新 load
  138 + ServiceException exception = assertThrows(ServiceException.class,
  139 + () -> sensitiveWordService.validateText("contains newbad here"));
  140 + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
  141 + // 旧词依然命中
  142 + assertThrows(ServiceException.class,
  143 + () -> sensitiveWordService.validateText("contains badword here"));
  144 +
  145 + // 断言:selectListByStatus 共被调用 2 次(首次 load + invalidate 后 reload)
  146 + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  147 + }
  148 +
  149 + @Test
  150 + public void testCreateSensitiveWord_duplicateWord_throws() {
  151 + // 准备:mock 已存在同名敏感词
  152 + when(imSensitiveWordMapper.selectByWord("dup")).thenReturn(
  153 + ImSensitiveWordDO.builder().id(99L).word("dup")
  154 + .status(CommonStatusEnum.ENABLE.getStatus()).build());
  155 +
  156 + // 调用 + 断言:重复敏感词抛 SENSITIVE_WORD_DUPLICATED
  157 + ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
  158 + reqVO.setWord("dup");
  159 + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
  160 + ServiceException exception = assertThrows(ServiceException.class,
  161 + () -> sensitiveWordService.createSensitiveWord(reqVO));
  162 + assertEquals(SENSITIVE_WORD_DUPLICATED.getCode(), exception.getCode());
  163 +
  164 + // 断言:未走到 insert
  165 + verify(imSensitiveWordMapper, never()).insert(any(ImSensitiveWordDO.class));
  166 + }
  167 +
  168 + @Test
  169 + public void testUpdateSensitiveWord_invalidatesCache() {
  170 + // 准备:首次 validateText 触发 cache load
  171 + sensitiveWordService.validateText("hello world");
  172 + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  173 +
  174 + // 准备:让 update 校验通过
  175 + when(imSensitiveWordMapper.selectById(1L)).thenReturn(
  176 + ImSensitiveWordDO.builder().id(1L).word("badword")
  177 + .status(CommonStatusEnum.ENABLE.getStatus()).build());
  178 + when(imSensitiveWordMapper.selectByWord("updatedbad")).thenReturn(null);
  179 + // 准备:reload 时返回新词库
  180 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
  181 + .thenReturn(List.of(
  182 + ImSensitiveWordDO.builder().id(1L).word("updatedbad")
  183 + .status(CommonStatusEnum.ENABLE.getStatus()).build()
  184 + ));
  185 +
  186 + // 调用:更新敏感词,触发 invalidate
  187 + ImSensitiveWordSaveReqVO reqVO = new ImSensitiveWordSaveReqVO();
  188 + reqVO.setId(1L);
  189 + reqVO.setWord("updatedbad");
  190 + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus());
  191 + sensitiveWordService.updateSensitiveWord(reqVO);
  192 +
  193 + // 调用 + 断言:再次 validateText 应使用新词库
  194 + ServiceException exception = assertThrows(ServiceException.class,
  195 + () -> sensitiveWordService.validateText("contains updatedbad here"));
  196 + assertEquals(MESSAGE_SENSITIVE_WORD_BLOCKED.getCode(), exception.getCode());
  197 +
  198 + // 断言:selectListByStatus 共被调用 2 次
  199 + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  200 + }
  201 +
  202 + @Test
  203 + public void testDeleteSensitiveWord_invalidatesCache() {
  204 + // 准备:首次 validateText 触发 cache load
  205 + sensitiveWordService.validateText("hello world");
  206 + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
80 207  
81   - // 准备:新列表不含原词
  208 + // 准备:让 delete 校验通过
  209 + when(imSensitiveWordMapper.selectById(1L)).thenReturn(
  210 + ImSensitiveWordDO.builder().id(1L).word("badword")
  211 + .status(CommonStatusEnum.ENABLE.getStatus()).build());
  212 + // 准备:reload 时返回剩余词库(只剩中文那条)
82 213 when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
83 214 .thenReturn(List.of(
84   - ImSensitiveWordDO.builder().id(3L).word("new").status(CommonStatusEnum.ENABLE.getStatus()).build()
  215 + ImSensitiveWordDO.builder().id(2L).word("违禁词")
  216 + .status(CommonStatusEnum.ENABLE.getStatus()).build()
85 217 ));
86 218  
87   - // 调用:刷新
88   -// sensitiveWordService.refresh();
  219 + // 调用:删除 badword,触发 invalidate
  220 + sensitiveWordService.deleteSensitiveWord(1L);
89 221  
90   - // 断言:原敏感词不再命中,新词命中
91   - assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword"));
  222 + // 调用 + 断言:badword 已被删,不再命中
  223 + assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
  224 + // 中文词依然在
92 225 assertThrows(ServiceException.class,
93   - () -> sensitiveWordService.validateText("contains new"));
  226 + () -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
  227 +
  228 + // 断言:selectListByStatus 共被调用 2 次
94 229 verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
95 230 }
96 231  
  232 + @Test
  233 + public void testDeleteSensitiveWord_notExists_throws() {
  234 + // 准备:selectById 返回 null
  235 + when(imSensitiveWordMapper.selectById(999L)).thenReturn(null);
  236 +
  237 + // 调用 + 断言:抛 SENSITIVE_WORD_NOT_EXISTS
  238 + ServiceException exception = assertThrows(ServiceException.class,
  239 + () -> sensitiveWordService.deleteSensitiveWord(999L));
  240 + assertEquals(SENSITIVE_WORD_NOT_EXISTS.getCode(), exception.getCode());
  241 +
  242 + // 断言:未走到 deleteById
  243 + verify(imSensitiveWordMapper, never()).deleteById(anyLong());
  244 + }
  245 +
  246 + @Test
  247 + public void testDeleteSensitiveWordList_invalidatesCache() {
  248 + // 准备:首次 validateText 触发 cache load
  249 + sensitiveWordService.validateText("hello world");
  250 + verify(imSensitiveWordMapper, times(1)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  251 +
  252 + // 准备:reload 时返回空词库
  253 + when(imSensitiveWordMapper.selectListByStatus(CommonStatusEnum.ENABLE.getStatus()))
  254 + .thenReturn(List.of());
  255 +
  256 + // 调用:批量删除,触发 invalidate
  257 + sensitiveWordService.deleteSensitiveWordList(List.of(1L, 2L));
  258 +
  259 + // 调用 + 断言:所有词都不再命中
  260 + assertDoesNotThrow(() -> sensitiveWordService.validateText("contains badword here"));
  261 + assertDoesNotThrow(() -> sensitiveWordService.validateText("这条消息里有违禁词哦"));
  262 +
  263 + // 断言:deleteByIds 被调用 1 次;selectListByStatus 共 2 次
  264 + verify(imSensitiveWordMapper, times(1)).deleteByIds(List.of(1L, 2L));
  265 + verify(imSensitiveWordMapper, times(2)).selectListByStatus(CommonStatusEnum.ENABLE.getStatus());
  266 + }
  267 +
  268 + @Test
  269 + public void testDeleteSensitiveWordList_emptyIds_skip() {
  270 + // 调用:空列表直接返回
  271 + sensitiveWordService.deleteSensitiveWordList(List.of());
  272 +
  273 + // 断言:mapper 不被调用
  274 + verify(imSensitiveWordMapper, never()).deleteByIds(anyList());
  275 + }
  276 +
97 277 }
... ...