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,13 +78,13 @@ public class ImFriendRequestController {
78 } 78 }
79 79
80 @GetMapping("/list") 80 @GetMapping("/list")
81 - @Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 lastRequestId 加载更多)") 81 + @Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)")
82 public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList( 82 public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList(
83 @Parameter(description = "当前列表最旧记录的 id;首页不传") 83 @Parameter(description = "当前列表最旧记录的 id;首页不传")
84 - @RequestParam(value = "lastRequestId", required = false) Long lastRequestId, 84 + @RequestParam(value = "maxId", required = false) Long maxId,
85 @Parameter(description = "单次拉取条数", required = true) 85 @Parameter(description = "单次拉取条数", required = true)
86 @RequestParam("limit") @Min(1) @Max(200) Integer limit) { 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 return success(buildList(list)); 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,8 +59,8 @@ public class ImFacePackItemManagerController {
59 @Operation(summary = "批量删除表情") 59 @Operation(summary = "批量删除表情")
60 @Parameter(name = "ids", description = "编号列表", required = true) 60 @Parameter(name = "ids", description = "编号列表", required = true)
61 @PreAuthorize("@ss.hasPermission('im:manager:face-pack-item:delete')") 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 facePackItemService.deleteFacePackItemList(ids); 64 facePackItemService.deleteFacePackItemList(ids);
65 return success(true); 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,6 +9,7 @@ import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
9 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; 9 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
10 import org.apache.ibatis.annotations.Mapper; 10 import org.apache.ibatis.annotations.Mapper;
11 11
  12 +import java.time.LocalDateTime;
12 import java.util.List; 13 import java.util.List;
13 14
14 /** 15 /**
@@ -26,14 +27,19 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; { @@ -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 LambdaQueryWrapperX<ImFriendRequestDO> wrapper = new LambdaQueryWrapperX<>(); 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 .orderByDesc(ImFriendRequestDO::getId) 43 .orderByDesc(ImFriendRequestDO::getId)
38 .last("LIMIT " + limit); 44 .last("LIMIT " + limit);
39 return selectList(wrapper); 45 return selectList(wrapper);
@@ -49,7 +55,8 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; { @@ -49,7 +55,8 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
49 * <p> 55 * <p>
50 * handleContent / handleTime 走 LambdaUpdateWrapper.set 显式置 null,updateById 默认会忽略 null 字段 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 return update(null, new LambdaUpdateWrapper<ImFriendRequestDO>() 60 return update(null, new LambdaUpdateWrapper<ImFriendRequestDO>()
54 .eq(ImFriendRequestDO::getId, id) 61 .eq(ImFriendRequestDO::getId, id)
55 .set(ImFriendRequestDO::getApplyContent, applyContent) 62 .set(ImFriendRequestDO::getApplyContent, applyContent)
@@ -57,7 +64,8 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; { @@ -57,7 +64,8 @@ public interface ImFriendRequestMapper extends BaseMapperX&lt;ImFriendRequestDO&gt; {
57 .set(ImFriendRequestDO::getAddSource, addSource) 64 .set(ImFriendRequestDO::getAddSource, addSource)
58 .set(ImFriendRequestDO::getHandleResult, ImFriendRequestHandleResultEnum.UNHANDLED.getResult()) 65 .set(ImFriendRequestDO::getHandleResult, ImFriendRequestHandleResultEnum.UNHANDLED.getResult())
59 .set(ImFriendRequestDO::getHandleContent, null) 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 default PageResult<ImFriendRequestDO> selectPage(ImFriendRequestManagerPageReqVO reqVO) { 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,6 +15,12 @@ import org.apache.ibatis.annotations.Mapper;
15 @Mapper 15 @Mapper
16 public interface ImGroupMapper extends BaseMapperX<ImGroupDO> { 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 default PageResult<ImGroupDO> selectPage(ImGroupManagerPageReqVO reqVO) { 24 default PageResult<ImGroupDO> selectPage(ImGroupManagerPageReqVO reqVO) {
19 return selectPage(reqVO, new LambdaQueryWrapperX<ImGroupDO>() 25 return selectPage(reqVO, new LambdaQueryWrapperX<ImGroupDO>()
20 .likeIfPresent(ImGroupDO::getName, reqVO.getName()) 26 .likeIfPresent(ImGroupDO::getName, reqVO.getName())
@@ -25,4 +31,4 @@ public interface ImGroupMapper extends BaseMapperX&lt;ImGroupDO&gt; { @@ -25,4 +31,4 @@ public interface ImGroupMapper extends BaseMapperX&lt;ImGroupDO&gt; {
25 .orderByDesc(ImGroupDO::getId)); 31 .orderByDesc(ImGroupDO::getId));
26 } 32 }
27 33
28 -}  
29 \ No newline at end of file 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,6 +45,14 @@ public interface ImGroupMemberMapper extends BaseMapperX&lt;ImGroupMemberDO&gt; {
45 .eq(ImGroupMemberDO::getStatus, status)); 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 default List<ImGroupMemberDO> selectListByUserIdAndStatus(Long userId, Integer status) { 56 default List<ImGroupMemberDO> selectListByUserIdAndStatus(Long userId, Integer status) {
49 return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>() 57 return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>()
50 .eq(ImGroupMemberDO::getUserId, userId) 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,6 +18,8 @@ import java.util.Map;
18 @Mapper 18 @Mapper
19 public interface ImStatisticsManagerMapper { 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,10 +41,12 @@ public interface ImStatisticsManagerMapper {
39 */ 41 */
40 @Select("SELECT COUNT(DISTINCT user_id) FROM (" + 42 @Select("SELECT COUNT(DISTINCT user_id) FROM (" +
41 " SELECT sender_id AS user_id FROM im_private_message " + 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 " UNION ALL " + 46 " UNION ALL " +
44 " SELECT sender_id AS user_id FROM im_group_message " + 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 ") t") 50 ") t")
47 Long selectActiveUserCount(@Param("beginTime") LocalDateTime beginTime, 51 Long selectActiveUserCount(@Param("beginTime") LocalDateTime beginTime,
48 @Param("endTime") LocalDateTime endTime); 52 @Param("endTime") LocalDateTime endTime);
@@ -63,10 +67,12 @@ public interface ImStatisticsManagerMapper { @@ -63,10 +67,12 @@ public interface ImStatisticsManagerMapper {
63 */ 67 */
64 @Select("SELECT day AS date, COUNT(DISTINCT user_id) AS count FROM (" + 68 @Select("SELECT day AS date, COUNT(DISTINCT user_id) AS count FROM (" +
65 " SELECT DATE(send_time) AS day, sender_id AS user_id FROM im_private_message " + 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 " UNION ALL " + 72 " UNION ALL " +
68 " SELECT DATE(send_time) AS day, sender_id AS user_id FROM im_group_message " + 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 ") t GROUP BY day") 76 ") t GROUP BY day")
71 List<Map<String, Object>> selectActiveUserDailyCount(@Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); 77 List<Map<String, Object>> selectActiveUserDailyCount(@Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime);
72 78
@@ -113,7 +119,8 @@ public interface ImStatisticsManagerMapper { @@ -113,7 +119,8 @@ public interface ImStatisticsManagerMapper {
113 * 区间内私聊消息数 119 * 区间内私聊消息数
114 */ 120 */
115 @Select("SELECT COUNT(*) FROM im_private_message " + 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 Long selectPrivateMessageCount(@Param("beginTime") LocalDateTime beginTime, 124 Long selectPrivateMessageCount(@Param("beginTime") LocalDateTime beginTime,
118 @Param("endTime") LocalDateTime endTime); 125 @Param("endTime") LocalDateTime endTime);
119 126
@@ -121,7 +128,8 @@ public interface ImStatisticsManagerMapper { @@ -121,7 +128,8 @@ public interface ImStatisticsManagerMapper {
121 * 区间内群聊消息数 128 * 区间内群聊消息数
122 */ 129 */
123 @Select("SELECT COUNT(*) FROM im_group_message " + 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 Long selectGroupMessageCount(@Param("beginTime") LocalDateTime beginTime, 133 Long selectGroupMessageCount(@Param("beginTime") LocalDateTime beginTime,
126 @Param("endTime") LocalDateTime endTime); 134 @Param("endTime") LocalDateTime endTime);
127 135
@@ -129,7 +137,8 @@ public interface ImStatisticsManagerMapper { @@ -129,7 +137,8 @@ public interface ImStatisticsManagerMapper {
129 * 区间内每日私聊消息数(按天分组) 137 * 区间内每日私聊消息数(按天分组)
130 */ 138 */
131 @Select("SELECT DATE(send_time) AS date, COUNT(*) AS count FROM im_private_message " + 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 "GROUP BY DATE(send_time)") 142 "GROUP BY DATE(send_time)")
134 List<Map<String, Object>> selectPrivateMessageDailyCount(@Param("beginTime") LocalDateTime beginTime, 143 List<Map<String, Object>> selectPrivateMessageDailyCount(@Param("beginTime") LocalDateTime beginTime,
135 @Param("endTime") LocalDateTime endTime); 144 @Param("endTime") LocalDateTime endTime);
@@ -138,7 +147,8 @@ public interface ImStatisticsManagerMapper { @@ -138,7 +147,8 @@ public interface ImStatisticsManagerMapper {
138 * 区间内每日群聊消息数(按天分组) 147 * 区间内每日群聊消息数(按天分组)
139 */ 148 */
140 @Select("SELECT DATE(send_time) AS date, COUNT(*) AS count FROM im_group_message " + 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 "GROUP BY DATE(send_time)") 152 "GROUP BY DATE(send_time)")
143 List<Map<String, Object>> selectGroupMessageDailyCount(@Param("beginTime") LocalDateTime beginTime, 153 List<Map<String, Object>> selectGroupMessageDailyCount(@Param("beginTime") LocalDateTime beginTime,
144 @Param("endTime") LocalDateTime endTime); 154 @Param("endTime") LocalDateTime endTime);
@@ -150,10 +160,12 @@ public interface ImStatisticsManagerMapper { @@ -150,10 +160,12 @@ public interface ImStatisticsManagerMapper {
150 */ 160 */
151 @Select("SELECT type, COUNT(*) AS count FROM (" + 161 @Select("SELECT type, COUNT(*) AS count FROM (" +
152 " SELECT type FROM im_private_message " + 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 " UNION ALL " + 165 " UNION ALL " +
155 " SELECT type FROM im_group_message " + 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 ") t GROUP BY type") 169 ") t GROUP BY type")
158 List<Map<String, Object>> selectMessageTypeDistribution(@Param("beginTime") LocalDateTime beginTime, 170 List<Map<String, Object>> selectMessageTypeDistribution(@Param("beginTime") LocalDateTime beginTime,
159 @Param("endTime") LocalDateTime endTime); 171 @Param("endTime") LocalDateTime endTime);
@@ -165,10 +177,12 @@ public interface ImStatisticsManagerMapper { @@ -165,10 +177,12 @@ public interface ImStatisticsManagerMapper {
165 */ 177 */
166 @Select("SELECT user_id AS userId, COUNT(*) AS messageCount FROM (" + 178 @Select("SELECT user_id AS userId, COUNT(*) AS messageCount FROM (" +
167 " SELECT sender_id AS user_id FROM im_private_message " + 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 " UNION ALL " + 182 " UNION ALL " +
170 " SELECT sender_id AS user_id FROM im_group_message " + 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 ") t GROUP BY user_id ORDER BY messageCount DESC LIMIT #{limit}") 186 ") t GROUP BY user_id ORDER BY messageCount DESC LIMIT #{limit}")
173 List<Map<String, Object>> selectTopSenders(@Param("beginTime") LocalDateTime beginTime, 187 List<Map<String, Object>> selectTopSenders(@Param("beginTime") LocalDateTime beginTime,
174 @Param("endTime") LocalDateTime endTime, 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,7 +20,7 @@ public interface ErrorCodeConstants {
20 ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); 20 ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群");
21 ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭"); 21 ErrorCode MESSAGE_PRIVATE_READ_DISABLED = new ErrorCode(1_040_300_010, "私聊已读功能已关闭");
22 ErrorCode MESSAGE_GROUP_READ_DISABLED = new ErrorCode(1_040_300_011, "群聊已读功能已关闭"); 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 // ========== 群 (1-040-400-000) ========== 25 // ========== 群 (1-040-400-000) ==========
26 ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); 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,6 +7,7 @@ import lombok.Getter;
7 import lombok.RequiredArgsConstructor; 7 import lombok.RequiredArgsConstructor;
8 8
9 import java.util.Arrays; 9 import java.util.Arrays;
  10 +import java.util.Set;
10 11
11 /** 12 /**
12 * IM 消息类型枚举 13 * IM 消息类型枚举
@@ -339,6 +340,27 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; { @@ -339,6 +340,27 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
339 340
340 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); 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,4 +401,25 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
379 return result; 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 package cn.iocoder.yudao.module.im.mq.consumer.friend; 1 package cn.iocoder.yudao.module.im.mq.consumer.friend;
2 2
  3 +import cn.hutool.core.collection.CollUtil;
3 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; 4 import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
4 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; 5 import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
5 import cn.iocoder.yudao.module.im.service.friend.ImFriendService; 6 import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
@@ -33,22 +34,36 @@ public class AdminUserProfileUpdateConsumer { @@ -33,22 +34,36 @@ public class AdminUserProfileUpdateConsumer {
33 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) 34 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
34 @Async // Spring Event 默认在 Producer 发送的线程,通过 @Async 实现异步;事务提交后触发,避免回滚误推幽灵通知 / Consumer 抢在 commit 前读旧值 35 @Async // Spring Event 默认在 Producer 发送的线程,通过 @Async 实现异步;事务提交后触发,避免回滚误推幽灵通知 / Consumer 抢在 commit 前读旧值
35 public void onMessage(AdminUserProfileUpdateMessage message) { 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,14 +41,14 @@ public interface ImFriendRequestService {
41 void refuseFriendRequest(Long userId, Long requestId, String handleContent); 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 * 按 id 单查申请记录;通用读接口,调用方自行做越权过滤 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,12 +95,13 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
95 // 2. 落库:upsert 语义;同一对 (from, to) 唯一,已有记录覆盖申请理由 / 备注 / 来源 + 重置为未处理 + 清空旧处理痕迹 95 // 2. 落库:upsert 语义;同一对 (from, to) 唯一,已有记录覆盖申请理由 / 备注 / 来源 + 重置为未处理 + 清空旧处理痕迹
96 ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId); 96 ImFriendRequestDO request = friendRequestMapper.selectByFromUserIdAndToUserId(fromUserId, toUserId);
97 if (request != null) { 97 if (request != null) {
  98 + LocalDateTime now = LocalDateTime.now();
98 friendRequestMapper.updateByIdReset(request.getId(), 99 friendRequestMapper.updateByIdReset(request.getId(),
99 - reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource()); 100 + reqVO.getApplyContent(), reqVO.getDisplayName(), reqVO.getAddSource(), now);
100 request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName()) 101 request.setApplyContent(reqVO.getApplyContent()).setDisplayName(reqVO.getDisplayName())
101 .setAddSource(reqVO.getAddSource()) 102 .setAddSource(reqVO.getAddSource())
102 .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult()) 103 .setHandleResult(ImFriendRequestHandleResultEnum.UNHANDLED.getResult())
103 - .setHandleContent(null).setHandleTime(null); 104 + .setHandleContent(null).setHandleTime(null).setUpdateTime(now);
104 } else { 105 } else {
105 request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class) 106 request = BeanUtils.toBean(reqVO, ImFriendRequestDO.class)
106 .setFromUserId(fromUserId).setToUserId(toUserId) 107 .setFromUserId(fromUserId).setToUserId(toUserId)
@@ -195,8 +196,15 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService { @@ -195,8 +196,15 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
195 } 196 }
196 197
197 @Override 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 @Override 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,7 +185,7 @@ public interface ImGroupMemberService {
185 * @param userIds 用户编号集合 185 * @param userIds 用户编号集合
186 * @param role 新角色,见 {@link cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum} 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 * 统计群内指定 role 的活跃成员数量 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,14 +76,9 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
76 76
77 @Override 77 @Override
78 public List<ImGroupMemberDO> getGroupMemberListByOwnerAndAdmin(Long groupId) { 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,11 +234,11 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
239 } 234 }
240 235
241 @Override 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 if (CollUtil.isEmpty(userIds)) { 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 @Override 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,7 +319,7 @@ public class ImGroupServiceImpl implements ImGroupService {
319 Long groupId = reqVO.getGroupId(); 319 Long groupId = reqVO.getGroupId();
320 Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds()); 320 Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds());
321 // 1.1 仅群主可操作 321 // 1.1 仅群主可操作
322 - validateGroupOwner(groupId, userId); 322 + validateGroupOwnerForUpdate(groupId, userId);
323 // 1.2 校验目标都是有效成员且非群主 323 // 1.2 校验目标都是有效成员且非群主
324 Map<Long, ImGroupMemberDO> targetMap = convertMap( 324 Map<Long, ImGroupMemberDO> targetMap = convertMap(
325 groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId); 325 groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId);
@@ -340,7 +340,11 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -340,7 +340,11 @@ public class ImGroupServiceImpl implements ImGroupService {
340 } 340 }
341 341
342 // 2. 批量更新角色 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 // 3. 推送 GROUP_ADMIN_ADD 通知给全员 349 // 3. 推送 GROUP_ADMIN_ADD 通知给全员
346 groupMessageService.sendGroupMessage(userId, 350 groupMessageService.sendGroupMessage(userId,
@@ -353,7 +357,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -353,7 +357,7 @@ public class ImGroupServiceImpl implements ImGroupService {
353 Long groupId = reqVO.getGroupId(); 357 Long groupId = reqVO.getGroupId();
354 Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds()); 358 Set<Long> targetUserIds = new HashSet<>(reqVO.getUserIds());
355 // 1.1 仅群主可操作 359 // 1.1 仅群主可操作
356 - validateGroupOwner(groupId, userId); 360 + validateGroupOwnerForUpdate(groupId, userId);
357 // 1.2 校验目标都是有效成员且非群主 361 // 1.2 校验目标都是有效成员且非群主
358 Map<Long, ImGroupMemberDO> targetMap = convertMap( 362 Map<Long, ImGroupMemberDO> targetMap = convertMap(
359 groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId); 363 groupMemberService.getGroupMembers(groupId, targetUserIds), ImGroupMemberDO::getUserId);
@@ -367,7 +371,11 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -367,7 +371,11 @@ public class ImGroupServiceImpl implements ImGroupService {
367 } 371 }
368 372
369 // 2. 批量更新角色 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 // 3. 推送 GROUP_ADMIN_REMOVE 通知给全员 380 // 3. 推送 GROUP_ADMIN_REMOVE 通知给全员
373 groupMessageService.sendGroupMessage(userId, 381 groupMessageService.sendGroupMessage(userId,
@@ -396,7 +404,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -396,7 +404,7 @@ public class ImGroupServiceImpl implements ImGroupService {
396 Long groupId = transferReqVO.getGroupId(); 404 Long groupId = transferReqVO.getGroupId();
397 Long newOwnerUserId = transferReqVO.getNewOwnerUserId(); 405 Long newOwnerUserId = transferReqVO.getNewOwnerUserId();
398 // 1.1 仅老群主可执行 406 // 1.1 仅老群主可执行
399 - validateGroupOwner(groupId, userId); 407 + validateGroupOwnerForUpdate(groupId, userId);
400 // 1.2 不能转让给自己 408 // 1.2 不能转让给自己
401 if (ObjUtil.equal(userId, newOwnerUserId)) { 409 if (ObjUtil.equal(userId, newOwnerUserId)) {
402 throw exception(GROUP_TRANSFER_OWNER_TO_SELF); 410 throw exception(GROUP_TRANSFER_OWNER_TO_SELF);
@@ -404,11 +412,19 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -404,11 +412,19 @@ public class ImGroupServiceImpl implements ImGroupService {
404 // 1.3 新群主必须是群的有效成员 412 // 1.3 新群主必须是群的有效成员
405 ImGroupMemberDO newOwner = groupMemberService.validateMemberInGroup(groupId, newOwnerUserId); 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 groupMapper.updateById(new ImGroupDO().setId(groupId).setOwnerUserId(newOwnerUserId)); 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 // 3. 推送 GROUP_OWNER_TRANSFER 通知给全员 429 // 3. 推送 GROUP_OWNER_TRANSFER 通知给全员
414 groupMessageService.sendGroupMessage(userId, 430 groupMessageService.sendGroupMessage(userId,
@@ -420,7 +436,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -420,7 +436,7 @@ public class ImGroupServiceImpl implements ImGroupService {
420 @Transactional(rollbackFor = Exception.class) 436 @Transactional(rollbackFor = Exception.class)
421 public void pinGroupMessage(Long userId, Long groupId, Long messageId) { 437 public void pinGroupMessage(Long userId, Long groupId, Long messageId) {
422 // 1.1 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable 438 // 1.1 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable
423 - ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); 439 + ImGroupDO group = validateOwnerOrAdminAndGetGroupForUpdate(groupId, userId);
424 // 1.2 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 440 // 1.2 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回
425 ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId); 441 ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId);
426 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { 442 if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
@@ -457,7 +473,7 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -457,7 +473,7 @@ public class ImGroupServiceImpl implements ImGroupService {
457 @Transactional(rollbackFor = Exception.class) 473 @Transactional(rollbackFor = Exception.class)
458 public void unpinGroupMessage(Long userId, Long groupId, Long messageId) { 474 public void unpinGroupMessage(Long userId, Long groupId, Long messageId) {
459 // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable 475 // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable
460 - ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); 476 + ImGroupDO group = validateOwnerOrAdminAndGetGroupForUpdate(groupId, userId);
461 // 2. 幂等校验 477 // 2. 幂等校验
462 List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); 478 List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds()));
463 if (!pinned.contains(messageId)) { 479 if (!pinned.contains(messageId)) {
@@ -471,7 +487,9 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -471,7 +487,9 @@ public class ImGroupServiceImpl implements ImGroupService {
471 ImGroupMessageSendDTO.ofGroupMessageUnpin(groupId, userId, messageId)); 487 ImGroupMessageSendDTO.ofGroupMessageUnpin(groupId, userId, messageId));
472 } 488 }
473 489
474 - /** 校验登录用户是群主 / 管理员,同时返回 group(复用 validateGroupExists 的查询,避免调用方再查一次) */ 490 + /**
  491 + * 校验登录用户是群主或管理员,同时返回群信息
  492 + */
475 private ImGroupDO validateOwnerOrAdminAndGetGroup(Long groupId, Long userId) { 493 private ImGroupDO validateOwnerOrAdminAndGetGroup(Long groupId, Long userId) {
476 ImGroupDO group = validateGroupExists(groupId); 494 ImGroupDO group = validateGroupExists(groupId);
477 ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); 495 ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);
@@ -481,6 +499,18 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -481,6 +499,18 @@ public class ImGroupServiceImpl implements ImGroupService {
481 return group; 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 @Override 516 @Override
@@ -498,9 +528,11 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -498,9 +528,11 @@ public class ImGroupServiceImpl implements ImGroupService {
498 return group; 528 return group;
499 } 529 }
500 530
501 - // todo @AI:这个是不是可以放到 groupmemberservice 里面?  
502 @Override 531 @Override
503 public void validateMemberCountLimit(Long groupId, int addCount) { 532 public void validateMemberCountLimit(Long groupId, int addCount) {
  533 + // 1. 锁定群记录
  534 + validateGroupExistsForUpdate(groupId);
  535 + // 2. 校验成员数量上限
504 int activeCount = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId).size(); 536 int activeCount = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId).size();
505 int maxMember = imProperties.getGroup().getMaxMember(); 537 int maxMember = imProperties.getGroup().getMaxMember();
506 if (activeCount + addCount > maxMember) { 538 if (activeCount + addCount > maxMember) {
@@ -508,18 +540,28 @@ public class ImGroupServiceImpl implements ImGroupService { @@ -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 if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) { 545 if (ObjUtil.notEqual(group.getOwnerUserId(), userId)) {
518 throw exception(GROUP_NOT_OWNER); 546 throw exception(GROUP_NOT_OWNER);
519 } 547 }
520 return group; 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 private ImGroupDO validateGroupNotDissolved(Long id) { 565 private ImGroupDO validateGroupNotDissolved(Long id) {
524 ImGroupDO group = getSelf().getGroup(id); 566 ImGroupDO group = getSelf().getGroup(id);
525 if (group == null) { 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,12 +91,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
91 senderId, reqVO.getClientMessageId(), existing.getId()); 91 senderId, reqVO.getClientMessageId(), existing.getId());
92 return existing; 92 return existing;
93 } 93 }
94 - // 1.2 校验群存在、发送人仍在群中 94 + // 1.2 消息内容校验
  95 + ImMessageUtils.validateUserMessageContent(reqVO.getType(), reqVO.getContent());
  96 + // 1.3 校验群存在、发送人仍在群中
95 ImGroupDO group = groupService.validateGroupExists(reqVO.getGroupId()); 97 ImGroupDO group = groupService.validateGroupExists(reqVO.getGroupId());
96 ImGroupMemberDO senderMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId); 98 ImGroupMemberDO senderMember = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), senderId);
97 - // 1.3 禁言校验 99 + // 1.4 禁言校验
98 validateMuteStatus(group, senderMember); 100 validateMuteStatus(group, senderMember);
99 - // 1.4 文本消息敏感词过滤 101 + // 1.5 文本消息敏感词过滤
100 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { 102 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
101 sensitiveWordService.validateText(reqVO.getContent()); 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,9 +69,11 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
69 senderId, reqVO.getClientMessageId(), existing.getId()); 69 senderId, reqVO.getClientMessageId(), existing.getId());
70 return existing; 70 return existing;
71 } 71 }
72 - // 1.2 好友校验 72 + // 1.2 消息内容校验
  73 + ImMessageUtils.validateUserMessageContent(reqVO.getType(), reqVO.getContent());
  74 + // 1.3 好友校验
73 friendService.validateFriend(senderId, reqVO.getReceiverId()); 75 friendService.validateFriend(senderId, reqVO.getReceiverId());
74 - // 1.3 文本消息敏感词过滤 76 + // 1.4 文本消息敏感词过滤
75 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) { 77 if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
76 sensitiveWordService.validateText(reqVO.getContent()); 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,6 +38,7 @@ import jakarta.annotation.Resource;
38 import lombok.SneakyThrows; 38 import lombok.SneakyThrows;
39 import lombok.extern.slf4j.Slf4j; 39 import lombok.extern.slf4j.Slf4j;
40 import org.springframework.stereotype.Service; 40 import org.springframework.stereotype.Service;
  41 +import org.springframework.transaction.annotation.Transactional;
41 import org.springframework.validation.annotation.Validated; 42 import org.springframework.validation.annotation.Validated;
42 43
43 import java.time.Duration; 44 import java.time.Duration;
@@ -100,6 +101,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -100,6 +101,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
100 101
101 @Override 102 @Override
102 @SneakyThrows 103 @SneakyThrows
  104 + @Transactional(rollbackFor = Exception.class)
103 public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) { 105 public ImRtcCallDO createCall(Long userId, ImRtcCallCreateReqVO reqVO) {
104 validateEnabled(); 106 validateEnabled();
105 // 1. 校验入参与场景 107 // 1. 校验入参与场景
@@ -176,6 +178,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -176,6 +178,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
176 178
177 @Override 179 @Override
178 @SneakyThrows 180 @SneakyThrows
  181 + @Transactional(rollbackFor = Exception.class)
179 public void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) { 182 public void inviteCall(Long userId, ImRtcCallInviteReqVO reqVO) {
180 validateEnabled(); 183 validateEnabled();
181 // 1.1 校验通话存在且活跃 184 // 1.1 校验通话存在且活跃
@@ -242,6 +245,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -242,6 +245,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
242 } 245 }
243 246
244 @Override 247 @Override
  248 + @Transactional(rollbackFor = Exception.class)
245 public ImRtcCallDO joinCall(Long userId, String room) { 249 public ImRtcCallDO joinCall(Long userId, String room) {
246 validateEnabled(); 250 validateEnabled();
247 // 1.1 校验通话存在且活跃 251 // 1.1 校验通话存在且活跃
@@ -312,6 +316,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -312,6 +316,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
312 } 316 }
313 317
314 @Override 318 @Override
  319 + @Transactional(rollbackFor = Exception.class)
315 public ImRtcCallDO acceptCall(Long userId, String room) { 320 public ImRtcCallDO acceptCall(Long userId, String room) {
316 validateEnabled(); 321 validateEnabled();
317 // 1.1 校验通话存在且活跃 322 // 1.1 校验通话存在且活跃
@@ -341,6 +346,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -341,6 +346,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
341 } 346 }
342 347
343 @Override 348 @Override
  349 + @Transactional(rollbackFor = Exception.class)
344 public void rejectCall(Long userId, String room) { 350 public void rejectCall(Long userId, String room) {
345 validateEnabled(); 351 validateEnabled();
346 // 1.1 校验通话存在且活跃 352 // 1.1 校验通话存在且活跃
@@ -370,6 +376,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -370,6 +376,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
370 } 376 }
371 377
372 @Override 378 @Override
  379 + @Transactional(rollbackFor = Exception.class)
373 public void cancelCall(Long userId, String room) { 380 public void cancelCall(Long userId, String room) {
374 validateEnabled(); 381 validateEnabled();
375 // 1.1 校验通话存在且活跃 382 // 1.1 校验通话存在且活跃
@@ -388,6 +395,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -388,6 +395,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
388 } 395 }
389 396
390 @Override 397 @Override
  398 + @Transactional(rollbackFor = Exception.class)
391 public void leaveCall(Long userId, String room) { 399 public void leaveCall(Long userId, String room) {
392 validateEnabled(); 400 validateEnabled();
393 // 1.1 校验通话存在且活跃 401 // 1.1 校验通话存在且活跃
@@ -397,11 +405,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -397,11 +405,19 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
397 405
398 // 2. 当前状态 → LEFT;条件 UPDATE 防并发反复改 406 // 2. 当前状态 → LEFT;条件 UPDATE 防并发反复改
399 LocalDateTime now = LocalDateTime.now(); 407 LocalDateTime now = LocalDateTime.now();
400 - rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(), 408 + int updated = rtcParticipantMapper.updateByIdAndStatus(participant.getId(), participant.getStatus(),
401 new ImRtcParticipantDO().setId(participant.getId()).setStatus(ImRtcParticipantStatusEnum.LEFT.getStatus()).setLeaveTime(now)); 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 endSessionIfTerminal(call, userId); 421 endSessionIfTerminal(call, userId);
406 } 422 }
407 423
@@ -474,10 +490,17 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -474,10 +490,17 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
474 490
475 @Override 491 @Override
476 public String signCallToken(Long userId, String room) { 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 return signToken(userId, resolveDisplayName(adminUserApi.getUser(userId), userId), room); 499 return signToken(userId, resolveDisplayName(adminUserApi.getUser(userId), userId), room);
478 } 500 }
479 501
480 @Override 502 @Override
  503 + @Transactional(rollbackFor = Exception.class)
481 public void handleLiveKitEvent(LiveKitWebhookEventDTO event) { 504 public void handleLiveKitEvent(LiveKitWebhookEventDTO event) {
482 if (event == null || event.getEvent() == null) { 505 if (event == null || event.getEvent() == null) {
483 return; 506 return;
@@ -580,6 +603,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -580,6 +603,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
580 } 603 }
581 604
582 @Override 605 @Override
  606 + @Transactional(rollbackFor = Exception.class)
583 public int cleanupZombieCalls(int thresholdMinutes) { 607 public int cleanupZombieCalls(int thresholdMinutes) {
584 // 阈值由调用方(Job)保证 > 0;低于 1 分钟会误杀刚发起的合理零人态 608 // 阈值由调用方(Job)保证 > 0;低于 1 分钟会误杀刚发起的合理零人态
585 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); 609 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
@@ -610,6 +634,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -610,6 +634,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
610 } 634 }
611 635
612 @Override 636 @Override
  637 + @Transactional(rollbackFor = Exception.class)
613 public int timeoutInvitingParticipants(int thresholdMinutes) { 638 public int timeoutInvitingParticipants(int thresholdMinutes) {
614 // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态 639 // 阈值由调用方(Job)保证 > 0;低于 1 分钟可能误杀刚发起还在响铃的合理 INVITING 态
615 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes); 640 LocalDateTime threshold = LocalDateTime.now().minusMinutes(thresholdMinutes);
@@ -618,6 +643,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService { @@ -618,6 +643,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
618 } 643 }
619 644
620 @Override 645 @Override
  646 + @Transactional(rollbackFor = Exception.class)
621 public void noAnswerCallCheck(Long userId, String room) { 647 public void noAnswerCallCheck(Long userId, String room) {
622 // 鉴权:仅该 room 参与者可触发;失败静默,不暴露错误 648 // 鉴权:仅该 room 参与者可触发;失败静默,不暴露错误
623 ImRtcParticipantDO operator = rtcParticipantMapper.selectByRoomAndUserId(room, userId); 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,7 +10,7 @@ import java.util.Collections;
10 /** 10 /**
11 * IM WebSocket 推送 Service 接口 11 * IM WebSocket 推送 Service 接口
12 * <p> 12 * <p>
13 - * 统一封装 WebSocket 消息推送,所有方法默认异步执行。 13 + * 统一封装 WebSocket 消息推送,事务内调用时提交后异步执行。
14 * 14 *
15 * @author 芋道源码 15 * @author 芋道源码
16 */ 16 */
@@ -27,9 +27,9 @@ public interface ImWebSocketService { @@ -27,9 +27,9 @@ public interface ImWebSocketService {
27 } 27 }
28 28
29 /** 29 /**
30 - * 异步批量推送私聊消息给多个用户;用于一条 payload 扇出到多个收件人 30 + * 异步批量推送私聊消息给多个用户;用于同一份 DTO 扇出到多个收件人
31 * <p> 31 * <p>
32 - * 相比逐个 sendPrivateMessageAsync,仅注册一个 afterCommit 回调 + 一个 @Async 任务,大群参与者事件(1602 / 1603)下避免 N 次线程池调度 32 + * 相比逐个发送,仅注册一次 afterCommit 回调和异步任务。
33 * 33 *
34 * @param userIds 目标用户编号列表 34 * @param userIds 目标用户编号列表
35 * @param dto 私聊消息 DTO 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,7 +37,7 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
37 37
38 @Override 38 @Override
39 public void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto) { 39 public void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto) {
40 - // 说明:通过 executeAfterCommitOrNow 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交 40 + // 说明:通过 executeAfterTransaction 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交
41 // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理) 41 // 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理)
42 executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto)); 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,6 +73,10 @@ public class ImGroupMessageDTO {
73 * 群回执已读人数 73 * 群回执已读人数
74 */ 74 */
75 private Integer readCount; 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,7 +100,7 @@ public class ImGroupMessageDTO {
96 */ 100 */
97 public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) { 101 public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) {
98 return new ImGroupMessageDTO() 102 return new ImGroupMessageDTO()
99 - .setId(readId).setType(ImMessageTypeEnum.READ.getType()) 103 + .setId(readId).setReadId(readId).setType(ImMessageTypeEnum.READ.getType())
100 .setSenderId(senderId).setGroupId(groupId); 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 package cn.iocoder.yudao.module.im.service.websocket.dto; 1 package cn.iocoder.yudao.module.im.service.websocket.dto;
2 2
  3 +import cn.hutool.core.lang.Assert;
3 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 4 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
4 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 5 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
5 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO; 6 import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
@@ -116,6 +117,7 @@ public class ImPrivateMessageDTO { @@ -116,6 +117,7 @@ public class ImPrivateMessageDTO {
116 */ 117 */
117 public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId, 118 public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId,
118 Long receiverUserId, BaseFriendNotification payload) { 119 Long receiverUserId, BaseFriendNotification payload) {
  120 + validateNotification(type, payload, ImMessageTypeEnum.isFriendNotification(type));
119 return new ImPrivateMessageDTO().setType(type) 121 return new ImPrivateMessageDTO().setType(type)
120 .setSenderId(operatorUserId).setReceiverId(receiverUserId) 122 .setSenderId(operatorUserId).setReceiverId(receiverUserId)
121 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); 123 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
@@ -123,7 +125,7 @@ public class ImPrivateMessageDTO { @@ -123,7 +125,7 @@ public class ImPrivateMessageDTO {
123 125
124 // ==================== 群定向私聊通知 ==================== 126 // ==================== 群定向私聊通知 ====================
125 127
126 - // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】 128 + // TODO DONE @AI:群申请定向通知继续走私聊通道
127 /** 129 /**
128 * 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流) 130 * 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流)
129 * <p> 131 * <p>
@@ -138,6 +140,7 @@ public class ImPrivateMessageDTO { @@ -138,6 +140,7 @@ public class ImPrivateMessageDTO {
138 */ 140 */
139 public static ImPrivateMessageDTO ofGroupNotification(Integer type, Long operatorUserId, 141 public static ImPrivateMessageDTO ofGroupNotification(Integer type, Long operatorUserId,
140 Long receiverUserId, BaseGroupNotification payload) { 142 Long receiverUserId, BaseGroupNotification payload) {
  143 + validateNotification(type, payload, ImMessageTypeEnum.isGroupRequestNotification(type));
141 return new ImPrivateMessageDTO().setType(type) 144 return new ImPrivateMessageDTO().setType(type)
142 .setSenderId(operatorUserId).setReceiverId(receiverUserId) 145 .setSenderId(operatorUserId).setReceiverId(receiverUserId)
143 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); 146 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
@@ -145,7 +148,6 @@ public class ImPrivateMessageDTO { @@ -145,7 +148,6 @@ public class ImPrivateMessageDTO {
145 148
146 // ==================== 实时通话信令 ==================== 149 // ==================== 实时通话信令 ====================
147 150
148 - // TODO @AI:能不能走群聊的通道????只是接收人不同。【统一优化的】  
149 /** 151 /**
150 * 构建通话信令推送 DTO(走私聊通道仅推参与方,不入消息流) 152 * 构建通话信令推送 DTO(走私聊通道仅推参与方,不入消息流)
151 * <p> 153 * <p>
@@ -159,9 +161,16 @@ public class ImPrivateMessageDTO { @@ -159,9 +161,16 @@ public class ImPrivateMessageDTO {
159 */ 161 */
160 public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId, 162 public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId,
161 Long receiverUserId, Object payload) { 163 Long receiverUserId, Object payload) {
  164 + validateNotification(type, payload, ImMessageTypeEnum.isRtcNotification(type));
162 return new ImPrivateMessageDTO().setType(type) 165 return new ImPrivateMessageDTO().setType(type)
163 .setSenderId(senderId).setReceiverId(receiverUserId) 166 .setSenderId(senderId).setReceiverId(receiverUserId)
164 .setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now()); 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 package cn.iocoder.yudao.module.im.util; 1 package cn.iocoder.yudao.module.im.util;
2 2
  3 +import cn.hutool.core.collection.CollUtil;
3 import cn.hutool.core.convert.Convert; 4 import cn.hutool.core.convert.Convert;
4 import cn.hutool.core.util.StrUtil; 5 import cn.hutool.core.util.StrUtil;
5 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 6 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  7 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
6 import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage; 8 import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
7 9
  10 +import java.util.Collection;
8 import java.util.LinkedHashMap; 11 import java.util.LinkedHashMap;
9 import java.util.Map; 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 * IM 消息内容相关工具类 18 * IM 消息内容相关工具类
13 */ 19 */
14 public class ImMessageUtils { 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 * 从 content 解析客户端写入的 quote.messageId 150 * 从 content 解析客户端写入的 quote.messageId
18 * 151 *
19 * @param content 原 content(JSON) 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,8 +29,8 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest {
29 mapper.insert(buildRequest(2L, 1L)); 29 mapper.insert(buildRequest(2L, 1L));
30 mapper.insert(buildRequest(3L, 4L)); 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 // 断言:双向 OR 命中两条,无关用户被排除 35 // 断言:双向 OR 命中两条,无关用户被排除
36 assertEquals(2, list.size()); 36 assertEquals(2, list.size());
@@ -47,8 +47,9 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest { @@ -47,8 +47,9 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest {
47 mapper.insert(r2); 47 mapper.insert(r2);
48 mapper.insert(r3); 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 // 断言:仅含 r1 / r2,按 id 倒序 54 // 断言:仅含 r1 / r2,按 id 倒序
54 assertEquals(2, next.size()); 55 assertEquals(2, next.size());
@@ -64,7 +65,7 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest { @@ -64,7 +65,7 @@ public class ImFriendRequestMapperTest extends BaseDbUnitTest {
64 } 65 }
65 66
66 // 调用:limit = 2 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 // 断言:手写 LIMIT 真生效 70 // 断言:手写 LIMIT 真生效
70 assertEquals(2, list.size()); 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,9 +156,22 @@ public class ImFacePackItemServiceImplTest extends BaseMockitoUnitTest {
156 156
157 @Test 157 @Test
158 public void testDeleteFacePackItemList_success() { 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,6 +19,7 @@ import org.mockito.ArgumentCaptor;
19 import org.mockito.InjectMocks; 19 import org.mockito.InjectMocks;
20 import org.mockito.Mock; 20 import org.mockito.Mock;
21 21
  22 +import java.time.LocalDateTime;
22 import java.util.List; 23 import java.util.List;
23 24
24 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*; 25 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
@@ -176,7 +177,8 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { @@ -176,7 +177,8 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
176 ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO); 177 ImFriendRequestDO result = friendRequestService.applyFriend(1L, reqVO);
177 178
178 // 断言:复用旧记录,未触发 insert 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 verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class)); 182 verify(friendRequestMapper, never()).insert(any(ImFriendRequestDO.class));
181 assertEquals(100L, result.getId()); 183 assertEquals(100L, result.getId());
182 assertEquals("再来一次", result.getApplyContent()); 184 assertEquals("再来一次", result.getApplyContent());
@@ -300,12 +302,29 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest { @@ -300,12 +302,29 @@ public class ImFriendRequestServiceImplTest extends BaseMockitoUnitTest {
300 @Test 302 @Test
301 public void testGetMyFriendRequestList_delegate() { 303 public void testGetMyFriendRequestList_delegate() {
302 // 准备:mapper 返回 mock 数据 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 assertEquals(1, friendRequestService.getMyFriendRequestList(1L, 99L, 20).size()); 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,4 +331,18 @@ public class ImGroupMemberServiceImplTest extends BaseMockitoUnitTest {
331 assertTrue(userIds.isEmpty()); 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,7 +757,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
757 757
758 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 758 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
759 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 759 .status(CommonStatusEnum.ENABLE.getStatus()).build();
760 - when(groupMapper.selectById(10L)).thenReturn(group); 760 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
761 // 目标 3 是普通成员 761 // 目标 3 是普通成员
762 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 762 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
763 ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus()) 763 ImGroupMemberDO.builder().userId(3L).status(CommonStatusEnum.ENABLE.getStatus())
@@ -765,6 +765,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -765,6 +765,8 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
765 // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限 765 // 群里已有 1 个 ADMIN,1 + 1 ≤ 3 不超上限
766 when(groupMemberService.getGroupMemberCountByRole(10L, ImGroupMemberRoleEnum.ADMIN.getRole())) 766 when(groupMemberService.getGroupMemberCountByRole(10L, ImGroupMemberRoleEnum.ADMIN.getRole()))
767 .thenReturn(1L); 767 .thenReturn(1L);
  768 + when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(),
  769 + eq(ImGroupMemberRoleEnum.ADMIN.getRole()))).thenReturn(1);
768 770
769 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO(); 771 ImGroupAdminAddReqVO reqVO = new ImGroupAdminAddReqVO();
770 reqVO.setGroupId(10L); 772 reqVO.setGroupId(10L);
@@ -786,7 +788,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -786,7 +788,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
786 788
787 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 789 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
788 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 790 .status(CommonStatusEnum.ENABLE.getStatus()).build();
789 - when(groupMapper.selectById(10L)).thenReturn(group); 791 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
790 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 792 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
791 ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus()) 793 ImGroupMemberDO.builder().userId(5L).status(CommonStatusEnum.ENABLE.getStatus())
792 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build())); 794 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()));
@@ -813,7 +815,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -813,7 +815,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
813 815
814 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 816 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
815 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 817 .status(CommonStatusEnum.ENABLE.getStatus()).build();
816 - when(groupMapper.selectById(10L)).thenReturn(group); 818 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
817 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 819 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
818 ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus()) 820 ImGroupMemberDO.builder().userId(1L).status(CommonStatusEnum.ENABLE.getStatus())
819 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build())); 821 .role(ImGroupMemberRoleEnum.OWNER.getRole()).build()));
@@ -836,7 +838,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -836,7 +838,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
836 838
837 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 839 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
838 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 840 .status(CommonStatusEnum.ENABLE.getStatus()).build();
839 - when(groupMapper.selectById(10L)).thenReturn(group); 841 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
840 // 目标已是 ADMIN:再加无需操作 842 // 目标已是 ADMIN:再加无需操作
841 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 843 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
842 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) 844 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
@@ -863,10 +865,12 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -863,10 +865,12 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
863 865
864 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 866 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
865 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 867 .status(CommonStatusEnum.ENABLE.getStatus()).build();
866 - when(groupMapper.selectById(10L)).thenReturn(group); 868 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
867 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 869 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
868 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) 870 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
869 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build())); 871 .role(ImGroupMemberRoleEnum.ADMIN.getRole()).build()));
  872 + when(groupMemberService.updateGroupMemberRole(eq(10L), anyCollection(),
  873 + eq(ImGroupMemberRoleEnum.NORMAL.getRole()))).thenReturn(1);
870 874
871 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO(); 875 ImGroupAdminRemoveReqVO reqVO = new ImGroupAdminRemoveReqVO();
872 reqVO.setGroupId(10L); 876 reqVO.setGroupId(10L);
@@ -888,7 +892,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -888,7 +892,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
888 892
889 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 893 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
890 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 894 .status(CommonStatusEnum.ENABLE.getStatus()).build();
891 - when(groupMapper.selectById(10L)).thenReturn(group); 895 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
892 // 目标已是 MEMBER:撤销无需操作 896 // 目标已是 MEMBER:撤销无需操作
893 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of( 897 when(groupMemberService.getGroupMembers(eq(10L), anyCollection())).thenReturn(List.of(
894 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus()) 898 ImGroupMemberDO.builder().userId(2L).status(CommonStatusEnum.ENABLE.getStatus())
@@ -914,10 +918,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -914,10 +918,14 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
914 918
915 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 919 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
916 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 920 .status(CommonStatusEnum.ENABLE.getStatus()).build();
917 - when(groupMapper.selectById(10L)).thenReturn(group); 921 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
918 when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn( 922 when(groupMemberService.validateMemberInGroup(10L, 2L)).thenReturn(
919 ImGroupMemberDO.builder().groupId(10L).userId(2L) 923 ImGroupMemberDO.builder().groupId(10L).userId(2L)
920 .role(ImGroupMemberRoleEnum.NORMAL.getRole()).build()); 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 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); 930 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
923 reqVO.setGroupId(10L); 931 reqVO.setGroupId(10L);
@@ -945,7 +953,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -945,7 +953,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
945 953
946 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L) 954 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(1L)
947 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 955 .status(CommonStatusEnum.ENABLE.getStatus()).build();
948 - when(groupMapper.selectById(10L)).thenReturn(group); 956 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
949 957
950 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); 958 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
951 reqVO.setGroupId(10L); 959 reqVO.setGroupId(10L);
@@ -966,7 +974,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest { @@ -966,7 +974,7 @@ public class ImGroupServiceImplTest extends BaseMockitoUnitTest {
966 974
967 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L) 975 ImGroupDO group = ImGroupDO.builder().id(10L).ownerUserId(99L)
968 .status(CommonStatusEnum.ENABLE.getStatus()).build(); 976 .status(CommonStatusEnum.ENABLE.getStatus()).build();
969 - when(groupMapper.selectById(10L)).thenReturn(group); 977 + when(groupMapper.selectByIdForUpdate(10L)).thenReturn(group);
970 978
971 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO(); 979 ImGroupTransferOwnerReqVO reqVO = new ImGroupTransferOwnerReqVO();
972 reqVO.setGroupId(10L); 980 reqVO.setGroupId(10L);