Commit a06fb9e995f23c68c7a799b61932b348282da2ef

Authored by YunaiV
1 parent 9b44ed74

fix(im):批量修复群管理、RTC 和消息链路问题

- 修复群管理行锁、管理员角色更新、群主转让、置顶消息并发问题
- 修复好友申请 maxId 游标、重复申请排序、通知类型校验和消息内容结构校验
- 修复消息统计口径、RTC token 鉴权、离会通知、前端拉取取消和媒体重试
- 优化表情批量删除、WebSocket 推送注释、群 READ 字段和相关单测
- 更新 bug_todo、bug_done 和 bug_rejected,剩余 9 个待修
Showing 27 changed files with 489 additions and 119 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/friend/ImFriendRequestController.java
... ... @@ -78,13 +78,13 @@ public class ImFriendRequestController {
78 78 }
79 79  
80 80 @GetMapping("/list")
81   - @Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 lastRequestId 加载更多)")
  81 + @Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)")
82 82 public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList(
83 83 @Parameter(description = "当前列表最旧记录的 id;首页不传")
84   - @RequestParam(value = "lastRequestId", required = false) Long lastRequestId,
  84 + @RequestParam(value = "maxId", required = false) Long maxId,
85 85 @Parameter(description = "单次拉取条数", required = true)
86 86 @RequestParam("limit") @Min(1) @Max(200) Integer limit) {
87   - List<ImFriendRequestDO> list = friendRequestService.getMyFriendRequestList(getLoginUserId(), lastRequestId, limit);
  87 + List<ImFriendRequestDO> list = friendRequestService.getMyFriendRequestList(getLoginUserId(), maxId, limit);
88 88 return success(buildList(list));
89 89 }
90 90  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/face/ImFacePackItemManagerController.java
... ... @@ -59,8 +59,8 @@ public class ImFacePackItemManagerController {
59 59 @Operation(summary = "批量删除表情")
60 60 @Parameter(name = "ids", description = "编号列表", required = true)
61 61 @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:delete')")
62   - public CommonResult<Boolean> deleteFacePackItemList(@RequestParam("ids")
63   - @Size(max = 100, message = "批量删除最多 100 条") List<Long> ids) {
  62 + public CommonResult<Boolean> deleteFacePackItemList(
  63 + @RequestParam("ids") @Size(max = 100, message = "批量删除最多 100 条") List<Long> ids) {
64 64 facePackItemService.deleteFacePackItemList(ids);
65 65 return success(true);
66 66 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapper.java
... ... @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
9 9 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
10 10 import org.apache.ibatis.annotations.Mapper;
11 11  
  12 +import java.time.LocalDateTime;
12 13 import java.util.List;
13 14  
14 15 /**
... ... @@ -26,14 +27,19 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
26 27 }
27 28  
28 29 /**
29   - * 拉取「我相关」的好友申请列表;游标分页:lastRequestId 为 null 拉首页,非 null 拉 id 严格小于它的下一页
  30 + * 拉取「我相关」的好友申请列表
30 31 */
31   - default List<ImFriendRequestDO> selectMyList(Long userId, Long lastRequestId, int limit) {
32   - // 先放扩展过滤再放双向 OR;否则 .and() 返回 LambdaQueryWrapper 基类,丢失 ltIfPresent 等扩展方法
  32 + default List<ImFriendRequestDO> selectMyList(Long userId, LocalDateTime maxRequestUpdateTime,
  33 + Long maxId, int limit) {
33 34 LambdaQueryWrapperX<ImFriendRequestDO> wrapper = new LambdaQueryWrapperX<>();
34   - wrapper.ltIfPresent(ImFriendRequestDO::getId, lastRequestId)
35   - .and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId)
36   - .or().eq(ImFriendRequestDO::getToUserId, userId))
  35 + wrapper.and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId)
  36 + .or().eq(ImFriendRequestDO::getToUserId, userId));
  37 + if (maxRequestUpdateTime != null && maxId != null) {
  38 + wrapper.and(w -> w.lt(ImFriendRequestDO::getUpdateTime, maxRequestUpdateTime)
  39 + .or(n -> n.eq(ImFriendRequestDO::getUpdateTime, maxRequestUpdateTime)
  40 + .lt(ImFriendRequestDO::getId, maxId)));
  41 + }
  42 + wrapper.orderByDesc(ImFriendRequestDO::getUpdateTime)
37 43 .orderByDesc(ImFriendRequestDO::getId)
38 44 .last("LIMIT " + limit);
39 45 return selectList(wrapper);
... ... @@ -49,7 +55,8 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
49 55 * <p>
50 56 * handleContent / handleTime 走 LambdaUpdateWrapper.set 显式置 null,updateById 默认会忽略 null 字段
51 57 */
52   - default int updateByIdReset(Long id, String applyContent, String displayName, Integer addSource) {
  58 + default int updateByIdReset(Long id, String applyContent, String displayName, Integer addSource,
  59 + LocalDateTime updateTime) {
53 60 return update(null, new LambdaUpdateWrapper<ImFriendRequestDO>()
54 61 .eq(ImFriendRequestDO::getId, id)
55 62 .set(ImFriendRequestDO::getApplyContent, applyContent)
... ... @@ -57,7 +64,8 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
57 64 .set(ImFriendRequestDO::getAddSource, addSource)
58 65 .set(ImFriendRequestDO::getHandleResult, ImFriendRequestHandleResultEnum.UNHANDLED.getResult())
59 66 .set(ImFriendRequestDO::getHandleContent, null)
60   - .set(ImFriendRequestDO::getHandleTime, null));
  67 + .set(ImFriendRequestDO::getHandleTime, null)
  68 + .set(ImFriendRequestDO::getUpdateTime, updateTime));
61 69 }
62 70  
63 71 default PageResult<ImFriendRequestDO> selectPage(ImFriendRequestManagerPageReqVO reqVO) {
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMapper.java
... ... @@ -15,6 +15,12 @@ import org.apache.ibatis.annotations.Mapper;
15 15 @Mapper
16 16 public interface ImGroupMapper extends BaseMapperX<ImGroupDO> {
17 17  
  18 + default ImGroupDO selectByIdForUpdate(Long id) {
  19 + return selectOne(new LambdaQueryWrapperX<ImGroupDO>()
  20 + .eq(ImGroupDO::getId, id)
  21 + .last("FOR UPDATE"));
  22 + }
  23 +
18 24 default PageResult<ImGroupDO> selectPage(ImGroupManagerPageReqVO reqVO) {
19 25 return selectPage(reqVO, new LambdaQueryWrapperX<ImGroupDO>()
20 26 .likeIfPresent(ImGroupDO::getName, reqVO.getName())
... ... @@ -25,4 +31,4 @@ public interface ImGroupMapper extends BaseMapperX&lt;ImGroupDO&gt; {
25 31 .orderByDesc(ImGroupDO::getId));
26 32 }
27 33  
28   -}
29 34 \ No newline at end of file
  35 +}
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/group/ImGroupMemberMapper.java
... ... @@ -45,6 +45,14 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
45 45 .eq(ImGroupMemberDO::getStatus, status));
46 46 }
47 47  
  48 + default List<ImGroupMemberDO> selectListByGroupIdAndStatusAndRoles(Long groupId, Integer status,
  49 + Collection<Integer> roles) {
  50 + return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>()
  51 + .eq(ImGroupMemberDO::getGroupId, groupId)
  52 + .eq(ImGroupMemberDO::getStatus, status)
  53 + .in(ImGroupMemberDO::getRole, roles));
  54 + }
  55 +
48 56 default List<ImGroupMemberDO> selectListByUserIdAndStatus(Long userId, Integer status) {
49 57 return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>()
50 58 .eq(ImGroupMemberDO::getUserId, userId)
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/statistics/ImStatisticsManagerMapper.java
... ... @@ -18,6 +18,8 @@ import java.util.Map;
18 18 @Mapper
19 19 public interface ImStatisticsManagerMapper {
20 20  
  21 + String NORMAL_MESSAGE_CONDITION = "type IN (101,102,103,104,105,107,108,115,125) AND status <> 2";
  22 +
21 23 // ==================== 用户 ====================
22 24  
23 25 /**
... ... @@ -39,10 +41,12 @@ public interface ImStatisticsManagerMapper {
39 41 */
40 42 @Select("SELECT COUNT(DISTINCT user_id) FROM (" +
41 43 " SELECT sender_id AS user_id FROM im_private_message " +
42   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  44 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  45 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
43 46 " UNION ALL " +
44 47 " SELECT sender_id AS user_id FROM im_group_message " +
45   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  48 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  49 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
46 50 ") t")
47 51 Long selectActiveUserCount(@Param("beginTime") LocalDateTime beginTime,
48 52 @Param("endTime") LocalDateTime endTime);
... ... @@ -63,10 +67,12 @@ public interface ImStatisticsManagerMapper {
63 67 */
64 68 @Select("SELECT day AS date, COUNT(DISTINCT user_id) AS count FROM (" +
65 69 " SELECT DATE(send_time) AS day, sender_id AS user_id FROM im_private_message " +
66   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  70 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  71 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
67 72 " UNION ALL " +
68 73 " SELECT DATE(send_time) AS day, sender_id AS user_id FROM im_group_message " +
69   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  74 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  75 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
70 76 ") t GROUP BY day")
71 77 List<Map<String, Object>> selectActiveUserDailyCount(@Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime);
72 78  
... ... @@ -113,7 +119,8 @@ public interface ImStatisticsManagerMapper {
113 119 * 区间内私聊消息数
114 120 */
115 121 @Select("SELECT COUNT(*) FROM im_private_message " +
116   - "WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}")
  122 + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  123 + " AND send_time >= #{beginTime} AND send_time < #{endTime}")
117 124 Long selectPrivateMessageCount(@Param("beginTime") LocalDateTime beginTime,
118 125 @Param("endTime") LocalDateTime endTime);
119 126  
... ... @@ -121,7 +128,8 @@ public interface ImStatisticsManagerMapper {
121 128 * 区间内群聊消息数
122 129 */
123 130 @Select("SELECT COUNT(*) FROM im_group_message " +
124   - "WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}")
  131 + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  132 + " AND send_time >= #{beginTime} AND send_time < #{endTime}")
125 133 Long selectGroupMessageCount(@Param("beginTime") LocalDateTime beginTime,
126 134 @Param("endTime") LocalDateTime endTime);
127 135  
... ... @@ -129,7 +137,8 @@ public interface ImStatisticsManagerMapper {
129 137 * 区间内每日私聊消息数(按天分组)
130 138 */
131 139 @Select("SELECT DATE(send_time) AS date, COUNT(*) AS count FROM im_private_message " +
132   - "WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime} " +
  140 + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  141 + " AND send_time >= #{beginTime} AND send_time < #{endTime} " +
133 142 "GROUP BY DATE(send_time)")
134 143 List<Map<String, Object>> selectPrivateMessageDailyCount(@Param("beginTime") LocalDateTime beginTime,
135 144 @Param("endTime") LocalDateTime endTime);
... ... @@ -138,7 +147,8 @@ public interface ImStatisticsManagerMapper {
138 147 * 区间内每日群聊消息数(按天分组)
139 148 */
140 149 @Select("SELECT DATE(send_time) AS date, COUNT(*) AS count FROM im_group_message " +
141   - "WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime} " +
  150 + "WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  151 + " AND send_time >= #{beginTime} AND send_time < #{endTime} " +
142 152 "GROUP BY DATE(send_time)")
143 153 List<Map<String, Object>> selectGroupMessageDailyCount(@Param("beginTime") LocalDateTime beginTime,
144 154 @Param("endTime") LocalDateTime endTime);
... ... @@ -150,10 +160,12 @@ public interface ImStatisticsManagerMapper {
150 160 */
151 161 @Select("SELECT type, COUNT(*) AS count FROM (" +
152 162 " SELECT type FROM im_private_message " +
153   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  163 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  164 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
154 165 " UNION ALL " +
155 166 " SELECT type FROM im_group_message " +
156   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  167 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  168 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
157 169 ") t GROUP BY type")
158 170 List<Map<String, Object>> selectMessageTypeDistribution(@Param("beginTime") LocalDateTime beginTime,
159 171 @Param("endTime") LocalDateTime endTime);
... ... @@ -165,10 +177,12 @@ public interface ImStatisticsManagerMapper {
165 177 */
166 178 @Select("SELECT user_id AS userId, COUNT(*) AS messageCount FROM (" +
167 179 " SELECT sender_id AS user_id FROM im_private_message " +
168   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  180 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  181 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
169 182 " UNION ALL " +
170 183 " SELECT sender_id AS user_id FROM im_group_message " +
171   - " WHERE deleted = 0 AND send_time >= #{beginTime} AND send_time < #{endTime}" +
  184 + " WHERE deleted = 0 AND " + NORMAL_MESSAGE_CONDITION +
  185 + " AND send_time >= #{beginTime} AND send_time < #{endTime}" +
172 186 ") t GROUP BY user_id ORDER BY messageCount DESC LIMIT #{limit}")
173 187 List<Map<String, Object>> selectTopSenders(@Param("beginTime") LocalDateTime beginTime,
174 188 @Param("endTime") LocalDateTime endTime,
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
... ... @@ -20,7 +20,7 @@ public interface ErrorCodeConstants {
20 20 ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群");
21 21 ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭");
22 22 ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭");
23   - ErrorCode MESSAGE_GROUP_READ_USERS_DENIED = new ErrorCode(1_040_300_012, "仅消息发送者可查看已读列表");
  23 + ErrorCode MESSAGE_CONTENT_INVALID = new ErrorCode(1_040_300_013, "消息内容格式不正确");
24 24  
25 25 // ========== 群 (1-040-400-000) ==========
26 26 ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在");
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
... ... @@ -7,6 +7,7 @@ import lombok.Getter;
7 7 import lombok.RequiredArgsConstructor;
8 8  
9 9 import java.util.Arrays;
  10 +import java.util.Set;
10 11  
11 12 /**
12 13 * IM 消息类型枚举
... ... @@ -339,6 +340,27 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
339 340  
340 341 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new);
341 342  
  343 + private static final Set<Integer> FRIEND_NOTIFICATION_TYPES = Set.of(
  344 + FRIEND_REQUEST_APPROVED.type,
  345 + FRIEND_REQUEST_REJECTED.type,
  346 + FRIEND_REQUEST_RECEIVED.type,
  347 + FRIEND_ADD.type,
  348 + FRIEND_DELETE.type,
  349 + FRIEND_BLOCK.type,
  350 + FRIEND_UNBLOCK.type,
  351 + FRIEND_INFO_UPDATED.type,
  352 + FRIEND_UPDATE.type);
  353 +
  354 + private static final Set<Integer> GROUP_REQUEST_NOTIFICATION_TYPES = Set.of(
  355 + GROUP_REQUEST_RECEIVED.type,
  356 + GROUP_REQUEST_APPROVED.type,
  357 + GROUP_REQUEST_REJECTED.type);
  358 +
  359 + private static final Set<Integer> RTC_NOTIFICATION_TYPES = Set.of(
  360 + RTC_CALL.type,
  361 + RTC_PARTICIPANT_CONNECTED.type,
  362 + RTC_PARTICIPANT_DISCONNECTED.type);
  363 +
342 364 /**
343 365 * 类型
344 366 */
... ... @@ -379,4 +401,25 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
379 401 return result;
380 402 }
381 403  
  404 + /**
  405 + * 判断是否为好友通知
  406 + */
  407 + public static boolean isFriendNotification(Integer type) {
  408 + return type != null && FRIEND_NOTIFICATION_TYPES.contains(type);
  409 + }
  410 +
  411 + /**
  412 + * 判断是否为群申请定向通知
  413 + */
  414 + public static boolean isGroupRequestNotification(Integer type) {
  415 + return type != null && GROUP_REQUEST_NOTIFICATION_TYPES.contains(type);
  416 + }
  417 +
  418 + /**
  419 + * 判断是否为通话信令通知
  420 + */
  421 + public static boolean isRtcNotification(Integer type) {
  422 + return type != null && RTC_NOTIFICATION_TYPES.contains(type);
  423 + }
  424 +
382 425 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/mq/consumer/friend/AdminUserProfileUpdateConsumer.java
1 1 package cn.iocoder.yudao.module.im.mq.consumer.friend;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
3 4 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
4 5 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
5 6 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
... ... @@ -33,22 +34,36 @@ public class AdminUserProfileUpdateConsumer {
33 34 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
34 35 @Async // Spring Event 默认在 Producer 发送的线程,通过 @Async 实现异步;事务提交后触发,避免回滚误推幽灵通知 / Consumer 抢在 commit 前读旧值
35 36 public void onMessage(AdminUserProfileUpdateMessage message) {
36   - log.info("[onMessage][消息内容({})]", message);
37   - Long userId = message.getUserId();
38   - // 1. 过滤双向有效好友
39   - List<ImFriendDO> friends = friendService.getMutualEnableFriendList(userId);
40   - if (friends.isEmpty()) {
41   - return;
42   - }
  37 + try {
  38 + log.info("[onMessage][消息内容({})]", message);
  39 + // 1. 过滤双向有效好友
  40 + if (message == null || message.getUserId() == null) {
  41 + return;
  42 + }
  43 + Long userId = message.getUserId();
  44 + List<ImFriendDO> friends = friendService.getMutualEnableFriendList(userId);
  45 + if (CollUtil.isEmpty(friends)) {
  46 + return;
  47 + }
43 48  
44   - // 2. 给每个好友的多端推 FRIEND_INFO_UPDATED;payload 里 operatorUserId / friendUserId 都是「资料被改的人」
45   - for (ImFriendDO friend : friends) {
46   - FriendInfoUpdatedNotification payload = (FriendInfoUpdatedNotification) new FriendInfoUpdatedNotification()
47   - .setOperatorUserId(userId).setFriendUserId(userId);
48   - websocketService.sendPrivateMessageAsync(friend.getFriendUserId(), ImPrivateMessageDTO.ofFriendNotification(
49   - ImMessageTypeEnum.FRIEND_INFO_UPDATED.getType(), userId, friend.getFriendUserId(), payload));
  49 + // 2. 给每个好友的多端推 FRIEND_INFO_UPDATED;payload 里 operatorUserId / friendUserId 都是「资料被改的人」
  50 + int successCount = 0;
  51 + for (ImFriendDO friend : friends) {
  52 + try {
  53 + FriendInfoUpdatedNotification payload = (FriendInfoUpdatedNotification) new FriendInfoUpdatedNotification()
  54 + .setOperatorUserId(userId).setFriendUserId(userId);
  55 + websocketService.sendPrivateMessageAsync(friend.getFriendUserId(), ImPrivateMessageDTO.ofFriendNotification(
  56 + ImMessageTypeEnum.FRIEND_INFO_UPDATED.getType(), userId, friend.getFriendUserId(), payload));
  57 + successCount++;
  58 + } catch (Exception e) {
  59 + log.warn("[onMessage][userId({}) friendUserId({}) 推送失败]",
  60 + userId, friend.getFriendUserId(), e);
  61 + }
  62 + }
  63 + log.info("[onMessage][userId({}) 推送 FRIEND_INFO_UPDATED 给 {} 位好友]", userId, successCount);
  64 + } catch (Exception e) {
  65 + log.error("[onMessage][消息内容({}) 处理失败]", message, e);
50 66 }
51   - log.info("[onMessage][userId({}) 推送 FRIEND_INFO_UPDATED 给 {} 位好友]", userId, friends.size());
52 67 }
53 68  
54 69 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestService.java
... ... @@ -41,14 +41,14 @@ public interface ImFriendRequestService {
41 41 void refuseFriendRequest(Long userId, Long requestId, String handleContent);
42 42  
43 43 /**
44   - * 查询「我相关」的申请列表(含我发起的、别人加我的);游标分页:传 lastRequestId 拉它之前的下一页
  44 + * 查询「我相关」的申请列表(含我发起的、别人加我的);游标分页:传 maxId 拉更早一页
45 45 *
46   - * @param userId 用户编号
47   - * @param lastRequestId 当前列表最旧记录的 id;首页传 null
48   - * @param limit 单次拉取条数(page size,由前端常量控制)
49   - * @return 申请记录列表,按 id 倒序
  46 + * @param userId 用户编号
  47 + * @param maxId 当前列表最旧记录的 id;首页传 null
  48 + * @param limit 单次拉取条数(page size,由前端常量控制)
  49 + * @return 申请记录列表,按更新时间、id 倒序
50 50 */
51   - List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long lastRequestId, Integer limit);
  51 + List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long maxId, Integer limit);
52 52  
53 53 /**
54 54 * 按 id 单查申请记录;通用读接口,调用方自行做越权过滤
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImpl.java
... ... @@ -95,12 +95,13 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
95 95 // 2. 落库:upsert 语义;同一对 (from, to) 唯一,已有记录覆盖申请理由 / 备注 / 来源 + 重置为未处理 + 清空旧处理痕迹
96 96 ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId);
97 97 if (request != null) {
  98 + LocalDateTime now = LocalDateTime.now();
98 99 friendRequestMapper.updateByIdReset(request.getId(),
99   - reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource());
  100 + reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now);
100 101 request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName())
101 102 .setAddSource(reqVO.getAddSource())
102 103 .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult())
103   - .setHandleContent(null).setHandleTime(null);
  104 + .setHandleContent(null).setHandleTime(null).setUpdateTime(now);
104 105 } else {
105 106 request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class)
106 107 .setFromUserId(fromUserId).setToUserId(toUserId)
... ... @@ -195,8 +196,15 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
195 196 }
196 197  
197 198 @Override
198   - public List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long lastRequestId, Integer limit) {
199   - return friendRequestMapper.selectMyList(userId, lastRequestId, limit);
  199 + public List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long maxId, Integer limit) {
  200 + ImFriendRequestDO maxRequest = maxId != null ? friendRequestMapper.selectById(maxId) : null;
  201 + if (maxId != null && maxRequest == null) {
  202 + return List.of();
  203 + }
  204 + return friendRequestMapper.selectMyList(userId,
  205 + maxRequest != null ? maxRequest.getUpdateTime() : null,
  206 + maxRequest != null ? maxRequest.getId() : null,
  207 + limit);
200 208 }
201 209  
202 210 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberService.java
... ... @@ -185,7 +185,7 @@ public interface ImGroupMemberService {
185 185 * @param userIds 用户编号集合
186 186 * @param role 新角色,见 {@link cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum}
187 187 */
188   - void updateGroupMemberRole(Long groupId, Collection<Long> userIds, Integer role);
  188 + int updateGroupMemberRole(Long groupId, Collection<Long> userIds, Integer role);
189 189  
190 190 /**
191 191 * 统计群内指定 role 的活跃成员数量
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImpl.java
... ... @@ -76,14 +76,9 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
76 76  
77 77 @Override
78 78 public List<ImGroupMemberDO> getGroupMemberListByOwnerAndAdmin(Long groupId) {
79   - // TODO @AI:把条件往下传;这样减少加载数据量!
80   - List<ImGroupMemberDO> members = getActiveGroupMemberListByGroupId(groupId);
81   - if (CollUtil.isEmpty(members)) {
82   - return Collections.emptyList();
83   - }
84   - return members.stream()
85   - .filter(member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()))
86   - .toList();
  79 + // TODO DONE @AI:把条件往下传;这样减少加载数据量!
  80 + return groupMemberMapper.selectListByGroupIdAndStatusAndRoles(groupId, CommonStatusEnum.ENABLE.getStatus(),
  81 + List.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole()));
87 82 }
88 83  
89 84 /**
... ... @@ -239,11 +234,11 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
239 234 }
240 235  
241 236 @Override
242   - public void updateGroupMemberRole(Long groupId, Collection<Long> userIds, Integer role) {
  237 + public int updateGroupMemberRole(Long groupId, Collection<Long> userIds, Integer role) {
243 238 if (CollUtil.isEmpty(userIds)) {
244   - return;
  239 + return 0;
245 240 }
246   - groupMemberMapper.updateListByGroupIdAndUserIds(groupId, userIds, new ImGroupMemberDO().setRole(role));
  241 + return groupMemberMapper.updateListByGroupIdAndUserIds(groupId, userIds, new ImGroupMemberDO().setRole(role));
247 242 }
248 243  
249 244 @Override
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
... ... @@ -319,7 +319,7 @@ public class ImGroupServiceImpl implements ImGroupService {
319 319 Long groupId = reqVO.getGroupId();
320 320 Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds());
321 321 // 1.1 仅群主可操作
322   - validateGroupOwner(groupId, userId);
  322 + validateGroupOwnerForUpdate(groupId, userId);
323 323 // 1.2 校验目标都是有效成员且非群主
324 324 Map<Long, ImGroupMemberDO> targetMap = convertMap(
325 325 groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId);
... ... @@ -340,7 +340,11 @@ public class ImGroupServiceImpl implements ImGroupService {
340 340 }
341 341  
342 342 // 2. 批量更新角色
343   - groupMemberService.updateGroupMemberRole(groupId, changedUserIds, ImGroupMemberRoleEnum.ADMIN.getRole());
  343 + int affected = groupMemberService.updateGroupMemberRole(groupId, changedUserIds,
  344 + ImGroupMemberRoleEnum.ADMIN.getRole());
  345 + if (affected != changedUserIds.size()) {
  346 + throw exception(GROUP_ADMIN_TARGET_NOT_IN_GROUP);
  347 + }
344 348  
345 349 // 3. 推送 GROUP_ADMIN_ADD 通知给全员
346 350 groupMessageService.sendGroupMessage(userId,
... ... @@ -353,7 +357,7 @@ public class ImGroupServiceImpl implements ImGroupService {
353 357 Long groupId = reqVO.getGroupId();
354 358 Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds());
355 359 // 1.1 仅群主可操作
356   - validateGroupOwner(groupId, userId);
  360 + validateGroupOwnerForUpdate(groupId, userId);
357 361 // 1.2 校验目标都是有效成员且非群主
358 362 Map<Long, ImGroupMemberDO> targetMap = convertMap(
359 363 groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId);
... ... @@ -367,7 +371,11 @@ public class ImGroupServiceImpl implements ImGroupService {
367 371 }
368 372  
369 373 // 2. 批量更新角色
370   - groupMemberService.updateGroupMemberRole(groupId, changedUserIds, ImGroupMemberRoleEnum.NORMAL.getRole());
  374 + int affected = groupMemberService.updateGroupMemberRole(groupId, changedUserIds,
  375 + ImGroupMemberRoleEnum.NORMAL.getRole());
  376 + if (affected != changedUserIds.size()) {
  377 + throw exception(GROUP_ADMIN_TARGET_NOT_IN_GROUP);
  378 + }
371 379  
372 380 // 3. 推送 GROUP_ADMIN_REMOVE 通知给全员
373 381 groupMessageService.sendGroupMessage(userId,
... ... @@ -396,7 +404,7 @@ public class ImGroupServiceImpl implements ImGroupService {
396 404 Long groupId = transferReqVO.getGroupId();
397 405 Long newOwnerUserId = transferReqVO.getNewOwnerUserId();
398 406 // 1.1 仅老群主可执行
399   - validateGroupOwner(groupId, userId);
  407 + validateGroupOwnerForUpdate(groupId, userId);
400 408 // 1.2 不能转让给自己
401 409 if (ObjUtil.equal(userId, newOwnerUserId)) {
402 410 throw exception(GROUP_TRANSFER_OWNER_TO_SELF);
... ... @@ -404,11 +412,19 @@ public class ImGroupServiceImpl implements ImGroupService {
404 412 // 1.3 新群主必须是群的有效成员
405 413 ImGroupMemberDO newOwner = groupMemberService.validateMemberInGroup(groupId, newOwnerUserId);
406 414  
407   - // 2.1 更新群表 owner_user_id
  415 + // 2.1 更新成员角色
  416 + int newOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, Set.of(newOwner.getUserId()),
  417 + ImGroupMemberRoleEnum.OWNER.getRole());
  418 + if (newOwnerAffected != 1) {
  419 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  420 + }
  421 + int oldOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, Set.of(userId),
  422 + ImGroupMemberRoleEnum.NORMAL.getRole());
  423 + if (oldOwnerAffected != 1) {
  424 + throw exception(GROUP_MEMBER_NOT_IN_GROUP);
  425 + }
  426 + // 2.2 更新群主编号
408 427 groupMapper.updateById(new ImGroupDO().setId(groupId).setOwnerUserId(newOwnerUserId));
409   - // 2.2 旧群主 role → MEMBER;新群主 role → OWNER
410   - groupMemberService.updateGroupMemberRole(groupId, Set.of(userId), ImGroupMemberRoleEnum.NORMAL.getRole());
411   - groupMemberService.updateGroupMemberRole(groupId, Set.of(newOwner.getUserId()), ImGroupMemberRoleEnum.OWNER.getRole());
412 428  
413 429 // 3. 推送 GROUP_OWNER_TRANSFER 通知给全员
414 430 groupMessageService.sendGroupMessage(userId,
... ... @@ -420,7 +436,7 @@ public class ImGroupServiceImpl implements ImGroupService {
420 436 @Transactional(rollbackFor = Exception.class)
421 437 public void pinGroupMessage(Long userId, Long groupId, Long messageId) {
422 438 // 1.1 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable
423   - ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId);
  439 + ImGroupDO group = validateOwnerOrAdminAndGetGroupForUpdate(groupId, userId);
424 440 // 1.2 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回
425 441 ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId);
426 442 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
... ... @@ -457,7 +473,7 @@ public class ImGroupServiceImpl implements ImGroupService {
457 473 @Transactional(rollbackFor = Exception.class)
458 474 public void unpinGroupMessage(Long userId, Long groupId, Long messageId) {
459 475 // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable
460   - ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId);
  476 + ImGroupDO group = validateOwnerOrAdminAndGetGroupForUpdate(groupId, userId);
461 477 // 2. 幂等校验
462 478 List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds()));
463 479 if (!pinned.contains(messageId)) {
... ... @@ -471,7 +487,9 @@ public class ImGroupServiceImpl implements ImGroupService {
471 487 ImGroupMessageSendDTO.ofGroupMessageUnpin(groupId, userId, messageId));
472 488 }
473 489  
474   - /** 校验登录用户是群主 / 管理员,同时返回 group(复用 validateGroupExists 的查询,避免调用方再查一次) */
  490 + /**
  491 + * 校验登录用户是群主或管理员,同时返回群信息
  492 + */
475 493 private ImGroupDO validateOwnerOrAdminAndGetGroup(Long groupId, Long userId) {
476 494 ImGroupDO group = validateGroupExists(groupId);
477 495 ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);
... ... @@ -481,6 +499,18 @@ public class ImGroupServiceImpl implements ImGroupService {
481 499 return group;
482 500 }
483 501  
  502 + /**
  503 + * 校验登录用户是群主或管理员,同时锁定群信息
  504 + */
  505 + private ImGroupDO validateOwnerOrAdminAndGetGroupForUpdate(Long groupId, Long userId) {
  506 + ImGroupDO group = validateGroupExistsForUpdate(groupId);
  507 + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);
  508 + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole())) {
  509 + throw exception(GROUP_NOT_OWNER_OR_ADMIN);
  510 + }
  511 + return group;
  512 + }
  513 +
484 514 // ==================== 群的读操作 ====================
485 515  
486 516 @Override
... ... @@ -498,9 +528,11 @@ public class ImGroupServiceImpl implements ImGroupService {
498 528 return group;
499 529 }
500 530  
501   - // todo @AI:这个是不是可以放到 groupmemberservice 里面?
502 531 @Override
503 532 public void validateMemberCountLimit(Long groupId, int addCount) {
  533 + // 1. 锁定群记录
  534 + validateGroupExistsForUpdate(groupId);
  535 + // 2. 校验成员数量上限
504 536 int activeCount = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId).size();
505 537 int maxMember = imProperties.getGroup().getMaxMember();
506 538 if (activeCount + addCount > maxMember) {
... ... @@ -508,18 +540,28 @@ public class ImGroupServiceImpl implements ImGroupService {
508 540 }
509 541 }
510 542  
511   - /**
512   - * 校验当前用户是群主,否则抛 GROUP_NOT_OWNER
513   - */
514   - @SuppressWarnings("UnusedReturnValue")
515   - private ImGroupDO validateGroupOwner(Long groupId, Long userId) {
516   - ImGroupDO group = validateGroupExists(groupId);
  543 + private ImGroupDO validateGroupOwnerForUpdate(Long groupId, Long userId) {
  544 + ImGroupDO group = validateGroupExistsForUpdate(groupId);
517 545 if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) {
518 546 throw exception(GROUP_NOT_OWNER);
519 547 }
520 548 return group;
521 549 }
522 550  
  551 + private ImGroupDO validateGroupExistsForUpdate(Long groupId) {
  552 + ImGroupDO group = groupMapper.selectByIdForUpdate(groupId);
  553 + if (group == null) {
  554 + throw exception(GROUP_NOT_EXISTS);
  555 + }
  556 + if (Boolean.TRUE.equals(group.getBanned())) {
  557 + throw exception(GROUP_BANNED);
  558 + }
  559 + if (CommonStatusEnum.DISABLE.getStatus().equals(group.getStatus())) {
  560 + throw exception(GROUP_DISSOLVED);
  561 + }
  562 + return group;
  563 + }
  564 +
523 565 private ImGroupDO validateGroupNotDissolved(Long id) {
524 566 ImGroupDO group = getSelf().getGroup(id);
525 567 if (group == null) {
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
... ... @@ -91,12 +91,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
91 91 senderId, reqVO.getClientMessageId(), existing.getId());
92 92 return existing;
93 93 }
94   - // 1.2 校验群存在、发送人仍在群中
  94 + // 1.2 消息内容校验
  95 + ImMessageUtils.validateUserMessageContent(reqVO.getType(), reqVO.getContent());
  96 + // 1.3 校验群存在、发送人仍在群中
95 97 ImGroupDO group = groupService.validateGroupExists(reqVO.getGroupId());
96 98 ImGroupMemberDO senderMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId);
97   - // 1.3 禁言校验
  99 + // 1.4 禁言校验
98 100 validateMuteStatus(group, senderMember);
99   - // 1.4 文本消息敏感词过滤
  101 + // 1.5 文本消息敏感词过滤
100 102 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
101 103 sensitiveWordService.validateText(reqVO.getContent());
102 104 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImPrivateMessageServiceImpl.java
... ... @@ -69,9 +69,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
69 69 senderId, reqVO.getClientMessageId(), existing.getId());
70 70 return existing;
71 71 }
72   - // 1.2 好友校验
  72 + // 1.2 消息内容校验
  73 + ImMessageUtils.validateUserMessageContent(reqVO.getType(), reqVO.getContent());
  74 + // 1.3 好友校验
73 75 friendService.validateFriend(senderId, reqVO.getReceiverId());
74   - // 1.3 文本消息敏感词过滤
  76 + // 1.4 文本消息敏感词过滤
75 77 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
76 78 sensitiveWordService.validateText(reqVO.getContent());
77 79 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/rtc/ImRtcCallServiceImpl.java
... ... @@ -38,6 +38,7 @@ import jakarta.annotation.Resource;
38 38 import lombok.SneakyThrows;
39 39 import lombok.extern.slf4j.Slf4j;
40 40 import org.springframework.stereotype.Service;
  41 +import org.springframework.transaction.annotation.Transactional;
41 42 import org.springframework.validation.annotation.Validated;
42 43  
43 44 import java.time.Duration;
... ... @@ -100,6 +101,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
100 101  
101 102 @Override
102 103 @SneakyThrows
  104 + @Transactional(rollbackFor = Exception.class)
103 105 public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) {
104 106 validateEnabled();
105 107 // 1. 校验入参与场景
... ... @@ -176,6 +178,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
176 178  
177 179 @Override
178 180 @SneakyThrows
  181 + @Transactional(rollbackFor = Exception.class)
179 182 public void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) {
180 183 validateEnabled();
181 184 // 1.1 校验通话存在且活跃
... ... @@ -242,6 +245,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
242 245 }
243 246  
244 247 @Override
  248 + @Transactional(rollbackFor = Exception.class)
245 249 public ImRtcCallDO joinCall(Long userId, String room) {
246 250 validateEnabled();
247 251 // 1.1 校验通话存在且活跃
... ... @@ -312,6 +316,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
312 316 }
313 317  
314 318 @Override
  319 + @Transactional(rollbackFor = Exception.class)
315 320 public ImRtcCallDO acceptCall(Long userId, String room) {
316 321 validateEnabled();
317 322 // 1.1 校验通话存在且活跃
... ... @@ -341,6 +346,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
341 346 }
342 347  
343 348 @Override
  349 + @Transactional(rollbackFor = Exception.class)
344 350 public void rejectCall(Long userId, String room) {
345 351 validateEnabled();
346 352 // 1.1 校验通话存在且活跃
... ... @@ -370,6 +376,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
370 376 }
371 377  
372 378 @Override
  379 + @Transactional(rollbackFor = Exception.class)
373 380 public void cancelCall(Long userId, String room) {
374 381 validateEnabled();
375 382 // 1.1 校验通话存在且活跃
... ... @@ -388,6 +395,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
388 395 }
389 396  
390 397 @Override
  398 + @Transactional(rollbackFor = Exception.class)
391 399 public void leaveCall(Long userId, String room) {
392 400 validateEnabled();
393 401 // 1.1 校验通话存在且活跃
... ... @@ -397,11 +405,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
397 405  
398 406 // 2. 当前状态 → LEFT;条件 UPDATE 防并发反复改
399 407 LocalDateTime now = LocalDateTime.now();
400   - rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(),
  408 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(),
401 409 new ImRtcParticipantDO().setId(participant.getId()).setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now));
  410 + if (updated == 0) {
  411 + return;
  412 + }
402 413  
403   - // 3. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
404   - // 群通话单人离开:LiveKit `ParticipantDisconnected` 自动通知房内成员;业务后端不另外推
  414 + // 3. 群通话已入会参与者离开时推送离线通知
  415 + if (ImConversationTypeEnum.isGroup(call.getConversationType())
  416 + && ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
  417 + pushParticipantDisconnectedNotification(call, userId);
  418 + }
  419 +
  420 + // 4. 触发关房判定:私聊任一方离开必关;群通话仅在「无人在房 + 无人响铃」时关
405 421 endSessionIfTerminal(call, userId);
406 422 }
407 423  
... ... @@ -474,10 +490,17 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
474 490  
475 491 @Override
476 492 public String signCallToken(Long userId, String room) {
  493 + validateEnabled();
  494 + ImRtcCallDO call = validateCallActive(room);
  495 + ImRtcParticipantDO participant = validateParticipant(call, userId);
  496 + if (!ImRtcParticipantStatusEnum.isJoined(participant.getStatus())) {
  497 + throw exception(RTC_NOT_PARTICIPANT);
  498 + }
477 499 return signToken(userId, resolveDisplayName(adminUserApi.getUser(userId), userId), room);
478 500 }
479 501  
480 502 @Override
  503 + @Transactional(rollbackFor = Exception.class)
481 504 public void handleLiveKitEvent(LiveKitWebhookEventDTO event) {
482 505 if (event == null || event.getEvent() == null) {
483 506 return;
... ... @@ -580,6 +603,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
580 603 }
581 604  
582 605 @Override
  606 + @Transactional(rollbackFor = Exception.class)
583 607 public int cleanupZombieCalls(int thresholdMinutes) {
584 608 // 阈值由调用方(Job)保证 > 0;低于 1 分钟会误杀刚发起的合理零人态
585 609 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
... ... @@ -610,6 +634,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
610 634 }
611 635  
612 636 @Override
  637 + @Transactional(rollbackFor = Exception.class)
613 638 public int timeoutInvitingParticipants(int thresholdMinutes) {
614 639 // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态
615 640 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
... ... @@ -618,6 +643,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
618 643 }
619 644  
620 645 @Override
  646 + @Transactional(rollbackFor = Exception.class)
621 647 public void noAnswerCallCheck(Long userId, String room) {
622 648 // 鉴权:仅该 room 参与者可触发;失败静默,不暴露错误
623 649 ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(room, userId);
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java
... ... @@ -10,7 +10,7 @@ import java.util.Collections;
10 10 /**
11 11 * IM WebSocket 推送 Service 接口
12 12 * <p>
13   - * 统一封装 WebSocket 消息推送,所有方法默认异步执行。
  13 + * 统一封装 WebSocket 消息推送,事务内调用时提交后异步执行。
14 14 *
15 15 * @author 芋道源码
16 16 */
... ... @@ -27,9 +27,9 @@ public interface ImWebSocketService {
27 27 }
28 28  
29 29 /**
30   - * 异步批量推送私聊消息给多个用户;用于一条 payload 扇出到多个收件人
  30 + * 异步批量推送私聊消息给多个用户;用于同一份 DTO 扇出到多个收件人
31 31 * <p>
32   - * 相比逐个 sendPrivateMessageAsync,仅注册一个 afterCommit 回调 + 一个 @Async 任务,大群参与者事件(1602 / 1603)下避免 N 次线程池调度
  32 + * 相比逐个发送,仅注册一次 afterCommit 回调和异步任务。
33 33 *
34 34 * @param userIds 目标用户编号列表
35 35 * @param dto 私聊消息 DTO
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java
... ... @@ -37,7 +37,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
37 37  
38 38 @Override
39 39 public void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto) {
40   - // 说明:通过 executeAfterCommitOrNow 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交
  40 + // 说明:通过 executeAfterTransaction 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交
41 41 // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理)
42 42 executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto));
43 43 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImGroupMessageDTO.java
... ... @@ -73,6 +73,10 @@ public class ImGroupMessageDTO {
73 73 * 群回执已读人数
74 74 */
75 75 private Integer readCount;
  76 + /**
  77 + * 已读位置
  78 + */
  79 + private Long readId;
76 80  
77 81 // ========== 静态工厂方法 ==========
78 82  
... ... @@ -96,7 +100,7 @@ public class ImGroupMessageDTO {
96 100 */
97 101 public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) {
98 102 return new ImGroupMessageDTO()
99   - .setId(readId).setType(ImMessageTypeEnum.READ.getType())
  103 + .setId(readId).setReadId(readId).setType(ImMessageTypeEnum.READ.getType())
100 104 .setSenderId(senderId).setGroupId(groupId);
101 105 }
102 106  
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImPrivateMessageDTO.java
1 1 package cn.iocoder.yudao.module.im.service.websocket.dto;
2 2  
  3 +import cn.hutool.core.lang.Assert;
3 4 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
4 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
5 6 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
... ... @@ -116,6 +117,7 @@ public class ImPrivateMessageDTO {
116 117 */
117 118 public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId,
118 119 Long receiverUserId, BaseFriendNotification payload) {
  120 + validateNotification(type, payload, ImMessageTypeEnum.isFriendNotification(type));
119 121 return new ImPrivateMessageDTO().setType(type)
120 122 .setSenderId(operatorUserId).setReceiverId(receiverUserId)
121 123 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
... ... @@ -123,7 +125,7 @@ public class ImPrivateMessageDTO {
123 125  
124 126 // ==================== 群定向私聊通知 ====================
125 127  
126   - // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】
  128 + // TODO DONE @AI:群申请定向通知继续走私聊通道
127 129 /**
128 130 * 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流)
129 131 * <p>
... ... @@ -138,6 +140,7 @@ public class ImPrivateMessageDTO {
138 140 */
139 141 public static ImPrivateMessageDTO ofGroupNotification(Integer type, Long operatorUserId,
140 142 Long receiverUserId, BaseGroupNotification payload) {
  143 + validateNotification(type, payload, ImMessageTypeEnum.isGroupRequestNotification(type));
141 144 return new ImPrivateMessageDTO().setType(type)
142 145 .setSenderId(operatorUserId).setReceiverId(receiverUserId)
143 146 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
... ... @@ -145,7 +148,6 @@ public class ImPrivateMessageDTO {
145 148  
146 149 // ==================== 实时通话信令 ====================
147 150  
148   - // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】
149 151 /**
150 152 * 构建通话信令推送 DTO(走私聊通道仅推参与方,不入消息流)
151 153 * <p>
... ... @@ -159,9 +161,16 @@ public class ImPrivateMessageDTO {
159 161 */
160 162 public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId,
161 163 Long receiverUserId, Object payload) {
  164 + validateNotification(type, payload, ImMessageTypeEnum.isRtcNotification(type));
162 165 return new ImPrivateMessageDTO().setType(type)
163 166 .setSenderId(senderId).setReceiverId(receiverUserId)
164 167 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
165 168 }
166 169  
  170 + private static void validateNotification(Integer type, Object payload, boolean supported) {
  171 + Assert.notNull(type, "消息类型不能为空");
  172 + Assert.notNull(payload, "消息内容不能为空");
  173 + Assert.isTrue(supported, "消息类型不匹配 type={}", type);
  174 + }
  175 +
167 176 }
... ...
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/util/ImMessageUtils.java
1 1 package cn.iocoder.yudao.module.im.util;
2 2  
  3 +import cn.hutool.core.collection.CollUtil;
3 4 import cn.hutool.core.convert.Convert;
4 5 import cn.hutool.core.util.StrUtil;
5 6 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  7 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
6 8 import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
7 9  
  10 +import java.util.Collection;
8 11 import java.util.LinkedHashMap;
9 12 import java.util.Map;
10 13  
  14 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  15 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.MESSAGE_CONTENT_INVALID;
  16 +
11 17 /**
12 18 * IM 消息内容相关工具类
13 19 */
14 20 public class ImMessageUtils {
15 21  
16 22 /**
  23 + * 文本消息最大长度
  24 + */
  25 + private static final int TEXT_MAX_LENGTH = 4096;
  26 + /**
  27 + * URL 最大长度
  28 + */
  29 + private static final int URL_MAX_LENGTH = 2048;
  30 + /**
  31 + * 合并消息最大条数
  32 + */
  33 + private static final int MERGE_MESSAGE_MAX_COUNT = 100;
  34 +
  35 + /**
  36 + * 校验用户发送的消息内容
  37 + *
  38 + * @param type 消息类型
  39 + * @param content 消息内容
  40 + */
  41 + public static void validateUserMessageContent(Integer type, String content) {
  42 + ImMessageTypeEnum messageType;
  43 + try {
  44 + messageType = ImMessageTypeEnum.validate(type);
  45 + } catch (IllegalArgumentException e) {
  46 + throw exception(MESSAGE_CONTENT_INVALID);
  47 + }
  48 + if (!messageType.isNormal()) {
  49 + throw exception(MESSAGE_CONTENT_INVALID);
  50 + }
  51 + Map<String, Object> map = JsonUtils.parseMap(content);
  52 + if (map == null) {
  53 + throw exception(MESSAGE_CONTENT_INVALID);
  54 + }
  55 + if (ImMessageTypeEnum.TEXT == messageType) {
  56 + validateTextContent(map);
  57 + } else if (ImMessageTypeEnum.IMAGE == messageType || ImMessageTypeEnum.FACE == messageType) {
  58 + validateUrl(getString(map, "url"));
  59 + } else if (ImMessageTypeEnum.VOICE == messageType) {
  60 + validateUrl(getString(map, "url"));
  61 + validatePositiveNumber(map.get("duration"));
  62 + } else if (ImMessageTypeEnum.VIDEO == messageType) {
  63 + validateUrl(getString(map, "url"));
  64 + } else if (ImMessageTypeEnum.FILE == messageType) {
  65 + validateUrl(getString(map, "url"));
  66 + validateNotBlank(getString(map, "name"));
  67 + } else if (ImMessageTypeEnum.CARD == messageType) {
  68 + validateCardContent(map);
  69 + } else if (ImMessageTypeEnum.MERGE == messageType) {
  70 + validateMergeContent(map);
  71 + } else if (ImMessageTypeEnum.MATERIAL == messageType) {
  72 + validateMaterialContent(map);
  73 + }
  74 + }
  75 +
  76 + private static void validateTextContent(Map<String, Object> map) {
  77 + String text = getString(map, "content");
  78 + validateNotBlank(text);
  79 + if (text.length() > TEXT_MAX_LENGTH) {
  80 + throw exception(MESSAGE_CONTENT_INVALID);
  81 + }
  82 + }
  83 +
  84 + private static void validateCardContent(Map<String, Object> map) {
  85 + validateNumber(map.get("targetType"));
  86 + validateNumber(map.get("targetId"));
  87 + validateNotBlank(getString(map, "name"));
  88 + }
  89 +
  90 + private static void validateMergeContent(Map<String, Object> map) {
  91 + validateNotBlank(getString(map, "title"));
  92 + Object messages = map.get("messages");
  93 + if (!(messages instanceof Collection<?> messageList)
  94 + || CollUtil.isEmpty(messageList)
  95 + || messageList.size() > MERGE_MESSAGE_MAX_COUNT) {
  96 + throw exception(MESSAGE_CONTENT_INVALID);
  97 + }
  98 + }
  99 +
  100 + private static void validateMaterialContent(Map<String, Object> map) {
  101 + if (map.get("materialId") == null && StrUtil.isBlank(getString(map, "title"))) {
  102 + throw exception(MESSAGE_CONTENT_INVALID);
  103 + }
  104 + validateUrlIfPresent(getString(map, "url"));
  105 + validateUrlIfPresent(getString(map, "coverUrl"));
  106 + }
  107 +
  108 + private static String getString(Map<String, Object> map, String field) {
  109 + return Convert.toStr(map.get(field), null);
  110 + }
  111 +
  112 + private static void validateNotBlank(String value) {
  113 + if (StrUtil.isBlank(value)) {
  114 + throw exception(MESSAGE_CONTENT_INVALID);
  115 + }
  116 + }
  117 +
  118 + private static void validateNumber(Object value) {
  119 + if (Convert.toLong(value, null) == null) {
  120 + throw exception(MESSAGE_CONTENT_INVALID);
  121 + }
  122 + }
  123 +
  124 + private static void validatePositiveNumber(Object value) {
  125 + Integer number = Convert.toInt(value, null);
  126 + if (number == null || number < 0) {
  127 + throw exception(MESSAGE_CONTENT_INVALID);
  128 + }
  129 + }
  130 +
  131 + private static void validateUrlIfPresent(String url) {
  132 + if (StrUtil.isBlank(url)) {
  133 + return;
  134 + }
  135 + validateUrl(url);
  136 + }
  137 +
  138 + private static void validateUrl(String url) {
  139 + validateNotBlank(url);
  140 + if (url.length() > URL_MAX_LENGTH) {
  141 + throw exception(MESSAGE_CONTENT_INVALID);
  142 + }
  143 + String lowerUrl = StrUtil.trim(url).toLowerCase();
  144 + if (StrUtil.startWithAny(lowerUrl, "javascript:", "data:", "vbscript:", "file:")) {
  145 + throw exception(MESSAGE_CONTENT_INVALID);
  146 + }
  147 + }
  148 +
  149 + /**
17 150 * 从 content 解析客户端写入的 quote.messageId
18 151 *
19 152 * @param content 原 content(JSON)
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/dal/mysql/friend/ImFriendRequestMapperTest.java
... ... @@ -29,8 +29,8 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest {
29 29 mapper.insert(buildRequest(2L, 1L));
30 30 mapper.insert(buildRequest(3L, 4L));
31 31  
32   - // 调用:lastRequestId=null 拉首页
33   - List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, 10);
  32 + // 调用:cursor 为空,拉首页
  33 + List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, null, 10);
34 34  
35 35 // 断言:双向 OR 命中两条,无关用户被排除
36 36 assertEquals(2, list.size());
... ... @@ -47,8 +47,9 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest {
47 47 mapper.insert(r2);
48 48 mapper.insert(r3);
49 49  
50   - // 调用:lastRequestId = r3.id,拉 id < r3.id 的下一页
51   - List<ImFriendRequestDO> next = mapper.selectMyList(1L, r3.getId(), 10);
  50 + // 调用:cursor = r3,拉比 r3 更早的下一页
  51 + ImFriendRequestDO cursor = mapper.selectById(r3.getId());
  52 + List<ImFriendRequestDO> next = mapper.selectMyList(1L, cursor.getUpdateTime(), cursor.getId(), 10);
52 53  
53 54 // 断言:仅含 r1 / r2,按 id 倒序
54 55 assertEquals(2, next.size());
... ... @@ -64,7 +65,7 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest {
64 65 }
65 66  
66 67 // 调用:limit = 2
67   - List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, 2);
  68 + List<ImFriendRequestDO> list = mapper.selectMyList(1L, null, null, 2);
68 69  
69 70 // 断言:手写 LIMIT 真生效
70 71 assertEquals(2, list.size());
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/face/ImFacePackItemServiceImplTest.java
... ... @@ -156,9 +156,22 @@ public class ImFacePackItemServiceImplTest extends BaseMockitoUnitTest {
156 156  
157 157 @Test
158 158 public void testDeleteFacePackItemList_success() {
159   - service.deleteFacePackItemList(Arrays.asList(1L, 2L));
  159 + List<Long> ids = Arrays.asList(1L, 2L);
  160 + when(facePackItemMapper.deleteByIds(ids)).thenReturn(2);
160 161  
161   - verify(facePackItemMapper).deleteByIds(Arrays.asList(1L, 2L));
  162 + service.deleteFacePackItemList(ids);
  163 +
  164 + verify(facePackItemMapper).deleteByIds(ids);
  165 + }
  166 +
  167 + @Test
  168 + public void testDeleteFacePackItemList_ignoreMissingIds() {
  169 + List<Long> ids = Arrays.asList(1L, 2L);
  170 + when(facePackItemMapper.deleteByIds(ids)).thenReturn(1);
  171 +
  172 + service.deleteFacePackItemList(ids);
  173 +
  174 + verify(facePackItemMapper).deleteByIds(ids);
162 175 }
163 176  
164 177 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/friend/ImFriendRequestServiceImplTest.java
... ... @@ -19,6 +19,7 @@ import org.mockito.ArgumentCaptor;
19 19 import org.mockito.InjectMocks;
20 20 import org.mockito.Mock;
21 21  
  22 +import java.time.LocalDateTime;
22 23 import java.util.List;
23 24  
24 25 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
... ... @@ -176,7 +177,8 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
176 177 ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
177 178  
178 179 // 断言:复用旧记录,未触发 insert
179   - verify(friendRequestMapper).updateByIdReset(eq(100L), eq("再来一次"), eq("老张"), eq(2));
  180 + verify(friendRequestMapper).updateByIdReset(eq(100L), eq("再来一次"), eq("老张"), eq(2),
  181 + any(java.time.LocalDateTime.class));
180 182 verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
181 183 assertEquals(100L, result.getId());
182 184 assertEquals("再来一次", result.getApplyContent());
... ... @@ -300,12 +302,29 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
300 302 @Test
301 303 public void testGetMyFriendRequestList_delegate() {
302 304 // 准备:mapper 返回 mock 数据
303   - ImFriendRequestDO one = new ImFriendRequestDO().setId(1L);
304   - when(friendRequestMapper.selectMyList(1L, 99L, 20)).thenReturn(java.util.Collections.singletonList(one));
305   -
306   - // 调用 + 断言:参数透传、结果原样返回
  305 + LocalDateTime updateTime = LocalDateTime.now();
  306 + ImFriendRequestDO cursor = new ImFriendRequestDO();
  307 + cursor.setId(99L);
  308 + cursor.setUpdateTime(updateTime);
  309 + ImFriendRequestDO one = new ImFriendRequestDO();
  310 + one.setId(1L);
  311 + when(friendRequestMapper.selectById(99L)).thenReturn(cursor);
  312 + when(friendRequestMapper.selectMyList(1L, updateTime, 99L, 20))
  313 + .thenReturn(java.util.Collections.singletonList(one));
  314 +
  315 + // 调用 + 断言:cursor 由 Service 查询后传给 Mapper
307 316 assertEquals(1, friendRequestService.getMyFriendRequestList(1L, 99L, 20).size());
308   - verify(friendRequestMapper).selectMyList(1L, 99L, 20);
  317 + verify(friendRequestMapper).selectMyList(1L, updateTime, 99L, 20);
  318 + }
  319 +
  320 + @Test
  321 + public void testGetMyFriendRequestList_cursorNotExists() {
  322 + // 准备:cursor 不存在
  323 + when(friendRequestMapper.selectById(99L)).thenReturn(null);
  324 +
  325 + // 调用 + 断言:返回空列表
  326 + assertTrue(friendRequestService.getMyFriendRequestList(1L, 99L, 20).isEmpty());
  327 + verify(friendRequestMapper, never()).selectMyList(anyLong(), any(), anyLong(), anyInt());
309 328 }
310 329  
311 330 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupMemberServiceImplTest.java
... ... @@ -331,4 +331,18 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
331 331 assertTrue(userIds.isEmpty());
332 332 }
333 333  
  334 + @Test
  335 + public void testGetGroupMemberListByOwnerAndAdmin_passesRoles() {
  336 + List<Integer> roles = List.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole());
  337 + List<ImGroupMemberDO> members = List.of(ImGroupMemberDO.builder().groupId(10L).userId(1L).build());
  338 + when(groupMemberMapper.selectListByGroupIdAndStatusAndRoles(10L, CommonStatusEnum.ENABLE.getStatus(), roles))
  339 + .thenReturn(members);
  340 +
  341 + List<ImGroupMemberDO> result = groupMemberService.getGroupMemberListByOwnerAndAdmin(10L);
  342 +
  343 + assertEquals(members, result);
  344 + verify(groupMemberMapper).selectListByGroupIdAndStatusAndRoles(
  345 + 10L, CommonStatusEnum.ENABLE.getStatus(), roles);
  346 + }
  347 +
334 348 }
... ...
yudao-module-im/src/test/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImplTest.java
... ... @@ -757,7 +757,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
757 757  
758 758 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
759 759 .status(CommonStatusEnum.ENABLE.getStatus()).build();
760   - when(groupMapper.selectById(10L)).thenReturn(group);
  760 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
761 761 // 目标 3 是普通成员
762 762 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
763 763 ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus())
... ... @@ -765,6 +765,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
765 765 // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限
766 766 when(groupMemberService.getGroupMemberCountByRole(10L, ImGroupMemberRoleEnum.ADMIN.getRole()))
767 767 .thenReturn(1L);
  768 + when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(),
  769 + eq(ImGroupMemberRoleEnum.ADMIN.getRole()))).thenReturn(1);
768 770  
769 771 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
770 772 reqVO.setGroupId(10L);
... ... @@ -786,7 +788,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
786 788  
787 789 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
788 790 .status(CommonStatusEnum.ENABLE.getStatus()).build();
789   - when(groupMapper.selectById(10L)).thenReturn(group);
  791 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
790 792 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
791 793 ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus())
792 794 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
... ... @@ -813,7 +815,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
813 815  
814 816 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
815 817 .status(CommonStatusEnum.ENABLE.getStatus()).build();
816   - when(groupMapper.selectById(10L)).thenReturn(group);
  818 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
817 819 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
818 820 ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus())
819 821 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()));
... ... @@ -836,7 +838,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
836 838  
837 839 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
838 840 .status(CommonStatusEnum.ENABLE.getStatus()).build();
839   - when(groupMapper.selectById(10L)).thenReturn(group);
  841 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
840 842 // 目标已是 ADMIN:再加无需操作
841 843 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
842 844 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
... ... @@ -863,10 +865,12 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
863 865  
864 866 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
865 867 .status(CommonStatusEnum.ENABLE.getStatus()).build();
866   - when(groupMapper.selectById(10L)).thenReturn(group);
  868 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
867 869 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
868 870 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
869 871 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
  872 + when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(),
  873 + eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
870 874  
871 875 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
872 876 reqVO.setGroupId(10L);
... ... @@ -888,7 +892,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
888 892  
889 893 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
890 894 .status(CommonStatusEnum.ENABLE.getStatus()).build();
891   - when(groupMapper.selectById(10L)).thenReturn(group);
  895 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
892 896 // 目标已是 MEMBER:撤销无需操作
893 897 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
894 898 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
... ... @@ -914,10 +918,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
914 918  
915 919 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
916 920 .status(CommonStatusEnum.ENABLE.getStatus()).build();
917   - when(groupMapper.selectById(10L)).thenReturn(group);
  921 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
918 922 when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn(
919 923 ImGroupMemberDO.builder().groupId(10L).userId(2L)
920 924 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build());
  925 + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(2L)),
  926 + eq(ImGroupMemberRoleEnum.OWNER.getRole()))).thenReturn(1);
  927 + when(groupMemberService.updateGroupMemberRole(eq(10L), eq(Set.of(1L)),
  928 + eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
921 929  
922 930 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
923 931 reqVO.setGroupId(10L);
... ... @@ -945,7 +953,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
945 953  
946 954 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
947 955 .status(CommonStatusEnum.ENABLE.getStatus()).build();
948   - when(groupMapper.selectById(10L)).thenReturn(group);
  956 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
949 957  
950 958 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
951 959 reqVO.setGroupId(10L);
... ... @@ -966,7 +974,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
966 974  
967 975 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
968 976 .status(CommonStatusEnum.ENABLE.getStatus()).build();
969   - when(groupMapper.selectById(10L)).thenReturn(group);
  977 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
970 978  
971 979 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
972 980 reqVO.setGroupId(10L);
... ...