Commit 6e823f848d40eb9c830d1a9f7d711311a60de038
1 parent
ed9f23ae
✨ feat(im): 增加群消息的置顶
Showing
14 changed files
with
308 additions
and
8 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/ImGroupController.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | |
| 3 | +import cn.hutool.core.collection.CollUtil; | |
| 3 | 4 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 5 | +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; | |
| 4 | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 5 | 7 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO; |
| 6 | 8 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO; |
| 7 | 9 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; |
| 10 | +import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupMessagePinReqVO; | |
| 8 | 11 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupRespVO; |
| 9 | 12 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO; |
| 10 | 13 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; |
| 11 | 14 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; |
| 12 | 15 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; |
| 16 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; | |
| 13 | 17 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 18 | +import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; | |
| 19 | +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; | |
| 20 | +import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService; | |
| 14 | 21 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 22 | +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; | |
| 15 | 23 | import io.swagger.v3.oas.annotations.Operation; |
| 16 | 24 | import io.swagger.v3.oas.annotations.Parameter; |
| 17 | 25 | import io.swagger.v3.oas.annotations.tags.Tag; |
| ... | ... | @@ -20,9 +28,17 @@ import jakarta.validation.Valid; |
| 20 | 28 | import org.springframework.validation.annotation.Validated; |
| 21 | 29 | import org.springframework.web.bind.annotation.*; |
| 22 | 30 | |
| 31 | +import java.util.Collections; | |
| 23 | 32 | import java.util.List; |
| 33 | +import java.util.Map; | |
| 34 | +import java.util.Objects; | |
| 35 | +import java.util.Set; | |
| 36 | +import java.util.stream.Collectors; | |
| 37 | +import java.util.stream.Stream; | |
| 24 | 38 | |
| 25 | 39 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; |
| 40 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; | |
| 41 | +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSetByFlatMap; | |
| 26 | 42 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; |
| 27 | 43 | |
| 28 | 44 | @Tag(name = "管理后台 - 群") |
| ... | ... | @@ -33,6 +49,10 @@ public class ImGroupController { |
| 33 | 49 | |
| 34 | 50 | @Resource |
| 35 | 51 | private ImGroupService groupService; |
| 52 | + @Resource | |
| 53 | + private ImGroupMemberService groupMemberService; | |
| 54 | + @Resource | |
| 55 | + private ImGroupMessageService groupMessageService; | |
| 36 | 56 | |
| 37 | 57 | // ==================== 群的写操作 ==================== |
| 38 | 58 | |
| ... | ... | @@ -40,6 +60,7 @@ public class ImGroupController { |
| 40 | 60 | @Operation(summary = "创建群") |
| 41 | 61 | public CommonResult<ImGroupRespVO> createGroup(@Valid @RequestBody ImGroupCreateReqVO createReqVO) { |
| 42 | 62 | ImGroupDO group = groupService.createGroup(createReqVO, getLoginUserId()); |
| 63 | + // 新建群必无 pinnedMessages,跳过关联回填 | |
| 43 | 64 | return success(BeanUtils.toBean(group, ImGroupRespVO.class)); |
| 44 | 65 | } |
| 45 | 66 | |
| ... | ... | @@ -47,7 +68,7 @@ public class ImGroupController { |
| 47 | 68 | @Operation(summary = "更新群") |
| 48 | 69 | public CommonResult<ImGroupRespVO> updateGroup(@Valid @RequestBody ImGroupUpdateReqVO updateReqVO) { |
| 49 | 70 | ImGroupDO group = groupService.updateGroup(updateReqVO, getLoginUserId()); |
| 50 | - return success(BeanUtils.toBean(group, ImGroupRespVO.class)); | |
| 71 | + return success(buildGroupRespVO(group, getLoginUserId())); | |
| 51 | 72 | } |
| 52 | 73 | |
| 53 | 74 | @DeleteMapping("/dissolve") |
| ... | ... | @@ -65,14 +86,15 @@ public class ImGroupController { |
| 65 | 86 | @Parameter(name = "id", description = "编号", required = true, example = "1024") |
| 66 | 87 | public CommonResult<ImGroupRespVO> getGroup(@RequestParam("id") Long id) { |
| 67 | 88 | ImGroupDO group = groupService.getGroup(id); |
| 68 | - return success(BeanUtils.toBean(group, ImGroupRespVO.class)); | |
| 89 | + return success(buildGroupRespVO(group, getLoginUserId())); | |
| 69 | 90 | } |
| 70 | 91 | |
| 71 | 92 | @GetMapping("/list") |
| 72 | 93 | @Operation(summary = "获得当前登录用户的群列表") |
| 73 | 94 | public CommonResult<List<ImGroupRespVO>> getMyGroupList() { |
| 74 | - List<ImGroupDO> groups = groupService.getMyGroupList(getLoginUserId()); | |
| 75 | - return success(BeanUtils.toBean(groups, ImGroupRespVO.class)); | |
| 95 | + Long loginUserId = getLoginUserId(); | |
| 96 | + List<ImGroupDO> groups = groupService.getMyGroupList(loginUserId); | |
| 97 | + return success(buildGroupRespVOList(groups, loginUserId)); | |
| 76 | 98 | } |
| 77 | 99 | |
| 78 | 100 | // ==================== 群成员的写操作 ==================== |
| ... | ... | @@ -120,4 +142,59 @@ public class ImGroupController { |
| 120 | 142 | return success(true); |
| 121 | 143 | } |
| 122 | 144 | |
| 145 | + // ==================== 群消息置顶 ==================== | |
| 146 | + | |
| 147 | + @PutMapping("/pin-message") | |
| 148 | + @Operation(summary = "置顶群消息(群主 / 管理员)") | |
| 149 | + public CommonResult<Boolean> pinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) { | |
| 150 | + groupService.pinGroupMessage(getLoginUserId(), reqVO.getGroupId(), reqVO.getMessageId()); | |
| 151 | + return success(true); | |
| 152 | + } | |
| 153 | + | |
| 154 | + @PutMapping("/unpin-message") | |
| 155 | + @Operation(summary = "取消置顶群消息(群主 / 管理员)") | |
| 156 | + public CommonResult<Boolean> unpinGroupMessage(@Valid @RequestBody ImGroupMessagePinReqVO reqVO) { | |
| 157 | + groupService.unpinGroupMessage(getLoginUserId(), reqVO.getGroupId(), reqVO.getMessageId()); | |
| 158 | + return success(true); | |
| 159 | + } | |
| 160 | + | |
| 161 | + /** 单群转 VO + 关联回填 pinnedMessages(仅当登录用户是该群有效成员) */ | |
| 162 | + private ImGroupRespVO buildGroupRespVO(ImGroupDO group, Long loginUserId) { | |
| 163 | + if (group == null) { | |
| 164 | + return null; | |
| 165 | + } | |
| 166 | + return buildGroupRespVOList(Collections.singletonList(group), loginUserId).get(0); | |
| 167 | + } | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * 群列表批量转 VO + 关联回填 pinnedMessages | |
| 171 | + * <p> | |
| 172 | + * 仅当登录用户是某群的有效成员时才回填该群的 pinnedMessages,避免非成员 / 已退群用户越权拿到置顶消息内容 | |
| 173 | + */ | |
| 174 | + private List<ImGroupRespVO> buildGroupRespVOList(List<ImGroupDO> groups, Long loginUserId) { | |
| 175 | + if (CollUtil.isEmpty(groups)) { | |
| 176 | + return Collections.emptyList(); | |
| 177 | + } | |
| 178 | + // 仅当前用户是有效成员的群才允许回填置顶消息(按 groupId 一次性 IN 查成员关系) | |
| 179 | + Set<Long> activeGroupIds = convertSet( | |
| 180 | + groupMemberService.getActiveGroupMemberListByUserId(loginUserId), ImGroupMemberDO::getGroupId); | |
| 181 | + // 把 active 群的 pinnedMessageIds 一次性聚合 IN 查询,避免 N+1 | |
| 182 | + Set<Long> allMessageIds = convertSetByFlatMap(groups, group -> activeGroupIds.contains(group.getId()) | |
| 183 | + ? CollUtil.emptyIfNull(group.getPinnedMessageIds()).stream() : Stream.empty()); | |
| 184 | + Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds); | |
| 185 | + return CollectionUtils.convertList(groups, group -> { | |
| 186 | + ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class); | |
| 187 | + if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) { | |
| 188 | + return vo; | |
| 189 | + } | |
| 190 | + // 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过 | |
| 191 | + // TODO @AI:使用 CollUtils convert 进一步简化。 | |
| 192 | + List<ImGroupMessageRespVO> pinned = group.getPinnedMessageIds().stream() | |
| 193 | + .map(messageMap::get).filter(Objects::nonNull) | |
| 194 | + .map(msg -> BeanUtils.toBean(msg, ImGroupMessageRespVO.class)) | |
| 195 | + .collect(Collectors.toList()); | |
| 196 | + return vo.setPinnedMessages(pinned); | |
| 197 | + }); | |
| 198 | + } | |
| 199 | + | |
| 123 | 200 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupMessagePinReqVO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.im.controller.admin.group.vo; | |
| 2 | + | |
| 3 | +import io.swagger.v3.oas.annotations.media.Schema; | |
| 4 | +import jakarta.validation.constraints.NotNull; | |
| 5 | +import lombok.Data; | |
| 6 | + | |
| 7 | +/** | |
| 8 | + * 管理后台 - 群消息置顶 / 取消置顶 Request VO | |
| 9 | + */ | |
| 10 | +@Schema(description = "管理后台 - 群消息置顶 / 取消置顶 Request VO") | |
| 11 | +@Data | |
| 12 | +public class ImGroupMessagePinReqVO { | |
| 13 | + | |
| 14 | + @Schema(description = "群编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13279") | |
| 15 | + @NotNull(message = "群编号不能为空") | |
| 16 | + private Long groupId; | |
| 17 | + | |
| 18 | + @Schema(description = "消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9527") | |
| 19 | + @NotNull(message = "消息编号不能为空") | |
| 20 | + private Long messageId; | |
| 21 | + | |
| 22 | +} | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/group/vo/ImGroupRespVO.java
| 1 | 1 | package cn.iocoder.yudao.module.im.controller.admin.group.vo; |
| 2 | 2 | |
| 3 | +import cn.iocoder.yudao.module.im.controller.admin.message.vo.group.ImGroupMessageRespVO; | |
| 3 | 4 | import io.swagger.v3.oas.annotations.media.Schema; |
| 4 | 5 | import lombok.*; |
| 5 | 6 | |
| 6 | 7 | import java.time.LocalDateTime; |
| 8 | +import java.util.List; | |
| 7 | 9 | |
| 8 | 10 | @Schema(description = "管理后台 - 群 Response VO") |
| 9 | 11 | @Data |
| ... | ... | @@ -39,4 +41,7 @@ public class ImGroupRespVO { |
| 39 | 41 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) |
| 40 | 42 | private LocalDateTime createTime; |
| 41 | 43 | |
| 44 | + @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") | |
| 45 | + private List<ImGroupMessageRespVO> pinnedMessages; | |
| 46 | + | |
| 42 | 47 | } |
| 43 | 48 | \ No newline at end of file | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/group/ImGroupDO.java
| ... | ... | @@ -2,19 +2,22 @@ package cn.iocoder.yudao.module.im.dal.dataobject.group; |
| 2 | 2 | |
| 3 | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | 4 | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; |
| 5 | +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; | |
| 5 | 6 | import com.baomidou.mybatisplus.annotation.KeySequence; |
| 7 | +import com.baomidou.mybatisplus.annotation.TableField; | |
| 6 | 8 | import com.baomidou.mybatisplus.annotation.TableId; |
| 7 | 9 | import com.baomidou.mybatisplus.annotation.TableName; |
| 8 | 10 | import lombok.*; |
| 9 | 11 | |
| 10 | 12 | import java.time.LocalDateTime; |
| 13 | +import java.util.List; | |
| 11 | 14 | |
| 12 | 15 | /** |
| 13 | 16 | * IM 群信息 DO |
| 14 | 17 | * |
| 15 | 18 | * @author 芋道源码 |
| 16 | 19 | */ |
| 17 | -@TableName("im_group") | |
| 20 | +@TableName(value = "im_group",autoResultMap = true) | |
| 18 | 21 | @KeySequence("im_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 |
| 19 | 22 | @Data |
| 20 | 23 | @EqualsAndHashCode(callSuper = true) |
| ... | ... | @@ -70,5 +73,12 @@ public class ImGroupDO extends BaseDO { |
| 70 | 73 | * 解散时间 |
| 71 | 74 | */ |
| 72 | 75 | private LocalDateTime dissolvedTime; |
| 76 | + /** | |
| 77 | + * 群置顶消息编号列表 | |
| 78 | + * <p> | |
| 79 | + * 仅存 messageId,操作人 / 置顶时间从对应 PIN 事件的消息记录里反查 | |
| 80 | + */ | |
| 81 | + @TableField(typeHandler = LongListTypeHandler.class) | |
| 82 | + private List<Long> pinnedMessageIds; | |
| 73 | 83 | |
| 74 | 84 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
| ... | ... | @@ -17,6 +17,7 @@ public interface ErrorCodeConstants { |
| 17 | 17 | ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); |
| 18 | 18 | ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); |
| 19 | 19 | ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用"); |
| 20 | + ErrorCode MESSAGE_NOT_IN_GROUP = new ErrorCode(1_040_300_009, "消息不属于该群"); | |
| 20 | 21 | |
| 21 | 22 | // ========== 群 (1-040-400-000) ========== |
| 22 | 23 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); |
| ... | ... | @@ -25,6 +26,9 @@ public interface ErrorCodeConstants { |
| 25 | 26 | ErrorCode GROUP_NOT_OWNER = new ErrorCode(1_040_400_003, "仅群主可执行该操作"); |
| 26 | 27 | ErrorCode GROUP_NOT_OWNER_OR_ADMIN = new ErrorCode(1_040_400_004, "仅群主或管理员可执行该操作"); |
| 27 | 28 | ErrorCode GROUP_TRANSFER_OWNER_TO_SELF = new ErrorCode(1_040_400_005, "不能将群主转让给自己"); |
| 29 | + ErrorCode GROUP_MESSAGE_PIN_MAX_LIMIT = new ErrorCode(1_040_400_006, "群置顶消息数量不能超过 {} 条"); | |
| 30 | + ErrorCode GROUP_MESSAGE_ALREADY_PINNED = new ErrorCode(1_040_400_007, "该消息已置顶"); | |
| 31 | + ErrorCode GROUP_MESSAGE_NOT_PINNED = new ErrorCode(1_040_400_008, "该消息未置顶"); | |
| 28 | 32 | |
| 29 | 33 | // ========== 群成员 (1-040-500-000) ========== |
| 30 | 34 | ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
| ... | ... | @@ -159,7 +159,16 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> { |
| 159 | 159 | * 对应自己的类:GroupMemberSettingUpdateNotification |
| 160 | 160 | * 场景:用户改自己的群免打扰 / 群备注,仅推该用户其他在线终端做多端同步 |
| 161 | 161 | */ |
| 162 | - GROUP_MEMBER_SETTING_UPDATE(1530, "群成员个人设置变更", false, false); | |
| 162 | + GROUP_MEMBER_SETTING_UPDATE(1530, "群成员个人设置变更", false, false), | |
| 163 | + // TODO @AI:注释风格,还是参考上面的 | |
| 164 | + /** | |
| 165 | + * 自有扩展:群主 / 管理员置顶一条群消息,全员广播;前端把 messageId 加入 group.pinnedMessageIds | |
| 166 | + */ | |
| 167 | + GROUP_MESSAGE_PIN(1531, "群消息置顶", true, false), | |
| 168 | + /** | |
| 169 | + * 自有扩展:群主 / 管理员取消置顶,全员广播;前端把 messageId 从 group.pinnedMessageIds 移除 | |
| 170 | + */ | |
| 171 | + GROUP_MESSAGE_UNPIN(1532, "群消息取消置顶", true, false); | |
| 163 | 172 | |
| 164 | 173 | public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); |
| 165 | 174 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupService.java
| ... | ... | @@ -154,6 +154,26 @@ public interface ImGroupService { |
| 154 | 154 | */ |
| 155 | 155 | void transferGroupOwner(Long userId, @Valid ImGroupTransferOwnerReqVO transferReqVO); |
| 156 | 156 | |
| 157 | + /** | |
| 158 | + * 置顶群消息(仅群主或管理员可执行) | |
| 159 | + * <p> | |
| 160 | + * 上限 {@link cn.iocoder.yudao.module.im.enums.ImCommonConstants#GROUP_PIN_MAX_COUNT};幂等失败时抛业务异常 | |
| 161 | + * | |
| 162 | + * @param userId 当前登录用户编号 | |
| 163 | + * @param groupId 群编号 | |
| 164 | + * @param messageId 被置顶的消息编号 | |
| 165 | + */ | |
| 166 | + void pinGroupMessage(Long userId, Long groupId, Long messageId); | |
| 167 | + | |
| 168 | + /** | |
| 169 | + * 取消置顶群消息(仅群主或管理员可执行) | |
| 170 | + * | |
| 171 | + * @param userId 当前登录用户编号 | |
| 172 | + * @param groupId 群编号 | |
| 173 | + * @param messageId 被取消置顶的消息编号 | |
| 174 | + */ | |
| 175 | + void unpinGroupMessage(Long userId, Long groupId, Long messageId); | |
| 176 | + | |
| 157 | 177 | // ==================== 管理后台 ==================== |
| 158 | 178 | |
| 159 | 179 | /** | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/group/ImGroupServiceImpl.java
| ... | ... | @@ -22,8 +22,11 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManag |
| 22 | 22 | import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; |
| 23 | 23 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 24 | 24 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| 25 | +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; | |
| 25 | 26 | import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; |
| 26 | 27 | import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; |
| 28 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum; | |
| 29 | +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; | |
| 27 | 30 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; |
| 28 | 31 | import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; |
| 29 | 32 | import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; |
| ... | ... | @@ -363,6 +366,68 @@ public class ImGroupServiceImpl implements ImGroupService { |
| 363 | 366 | ImGroupMessageSendDTO.ofGroupOwnerTransfer(groupId, userId, newOwnerUserId)); |
| 364 | 367 | } |
| 365 | 368 | |
| 369 | + @Override | |
| 370 | + @CacheEvict(cacheNames = GROUP, key = "#groupId") | |
| 371 | + @Transactional(rollbackFor = Exception.class) | |
| 372 | + public void pinGroupMessage(Long userId, Long groupId, Long messageId) { | |
| 373 | + // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable | |
| 374 | + ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); | |
| 375 | + // 2. 校验消息属于该群、是普通聊天消息(绕过前端菜单不允许置顶群事件 / 撤回事件)、且未被撤回 | |
| 376 | + ImGroupMessageDO message = groupMessageService.getGroupMessage(messageId); | |
| 377 | + if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) { | |
| 378 | + throw exception(MESSAGE_NOT_IN_GROUP); | |
| 379 | + } | |
| 380 | + if (!ImMessageTypeEnum.validate(message.getType()).isNormal() | |
| 381 | + || ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) { | |
| 382 | + throw exception(MESSAGE_NOT_IN_GROUP); | |
| 383 | + } | |
| 384 | + // 3. 幂等 + 上限校验 | |
| 385 | + // TODO @AI:并发读改写非原子(两个管理员同时 pin 不同消息会丢更新),后续考虑 @Version 乐观锁或带旧值条件的 SQL | |
| 386 | + List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); | |
| 387 | + if (pinned.contains(messageId)) { | |
| 388 | + throw exception(GROUP_MESSAGE_ALREADY_PINNED); | |
| 389 | + } | |
| 390 | + if (pinned.size() >= GROUP_PIN_MAX_COUNT) { | |
| 391 | + throw exception(GROUP_MESSAGE_PIN_MAX_LIMIT, GROUP_PIN_MAX_COUNT); | |
| 392 | + } | |
| 393 | + pinned.add(messageId); | |
| 394 | + groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); | |
| 395 | + | |
| 396 | + // 4. 推送 GROUP_MESSAGE_PIN 通知给全员;payload 直接带消息对象,前端不用回查群详情绕开 @CacheEvict 时序 | |
| 397 | + groupMessageService.sendGroupMessage(userId, | |
| 398 | + ImGroupMessageSendDTO.ofGroupMessagePin(groupId, userId, message)); | |
| 399 | + } | |
| 400 | + | |
| 401 | + @Override | |
| 402 | + @CacheEvict(cacheNames = GROUP, key = "#groupId") | |
| 403 | + @Transactional(rollbackFor = Exception.class) | |
| 404 | + public void unpinGroupMessage(Long userId, Long groupId, Long messageId) { | |
| 405 | + // 1. 校验群主 / 管理员;同时拿到 group 复用,避免再走一次 @Cacheable | |
| 406 | + ImGroupDO group = validateOwnerOrAdminAndGetGroup(groupId, userId); | |
| 407 | + // 2. 幂等校验 | |
| 408 | + // TODO @AI:并发读改写非原子,后续考虑 @Version 乐观锁或带旧值条件的 SQL | |
| 409 | + List<Long> pinned = new ArrayList<>(CollUtil.emptyIfNull(group.getPinnedMessageIds())); | |
| 410 | + if (!pinned.contains(messageId)) { | |
| 411 | + throw exception(GROUP_MESSAGE_NOT_PINNED); | |
| 412 | + } | |
| 413 | + pinned.remove(messageId); | |
| 414 | + groupMapper.updateById(new ImGroupDO().setId(groupId).setPinnedMessageIds(pinned)); | |
| 415 | + | |
| 416 | + // 3. 推送 GROUP_MESSAGE_UNPIN 通知给全员 | |
| 417 | + groupMessageService.sendGroupMessage(userId, | |
| 418 | + ImGroupMessageSendDTO.ofGroupMessageUnpin(groupId, userId, messageId)); | |
| 419 | + } | |
| 420 | + | |
| 421 | + /** 校验登录用户是群主 / 管理员,同时返回 group(复用 validateGroupExists 的查询,避免调用方再查一次) */ | |
| 422 | + private ImGroupDO validateOwnerOrAdminAndGetGroup(Long groupId, Long userId) { | |
| 423 | + ImGroupDO group = validateGroupExists(groupId); | |
| 424 | + ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId); | |
| 425 | + if (!ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole())) { | |
| 426 | + throw exception(GROUP_NOT_OWNER_OR_ADMIN); | |
| 427 | + } | |
| 428 | + return group; | |
| 429 | + } | |
| 430 | + | |
| 366 | 431 | // ==================== 群的读操作 ==================== |
| 367 | 432 | |
| 368 | 433 | @Override | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageService.java
| ... | ... | @@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; |
| 9 | 9 | |
| 10 | 10 | import java.util.Collection; |
| 11 | 11 | import java.util.List; |
| 12 | +import java.util.Map; | |
| 12 | 13 | |
| 13 | 14 | /** |
| 14 | 15 | * IM 群聊消息 Service 接口 |
| ... | ... | @@ -138,5 +139,21 @@ public interface ImGroupMessageService { |
| 138 | 139 | */ |
| 139 | 140 | ImGroupMessageDO getGroupMessage(Long id); |
| 140 | 141 | |
| 142 | + /** | |
| 143 | + * 批量按消息编号查询群聊消息 | |
| 144 | + * | |
| 145 | + * @param ids 消息编号集合 | |
| 146 | + * @return 消息列表 | |
| 147 | + */ | |
| 148 | + List<ImGroupMessageDO> getGroupMessageList(Collection<Long> ids); | |
| 149 | + | |
| 150 | + /** | |
| 151 | + * 批量按消息编号查询群聊消息,返回 messageId → DO 映射 | |
| 152 | + * | |
| 153 | + * @param ids 消息编号集合 | |
| 154 | + * @return 消息 Map(key = 消息编号) | |
| 155 | + */ | |
| 156 | + Map<Long, ImGroupMessageDO> getGroupMessageMap(Collection<Long> ids); | |
| 157 | + | |
| 141 | 158 | } |
| 142 | 159 | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/ImGroupMessageServiceImpl.java
| ... | ... | @@ -588,6 +588,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { |
| 588 | 588 | return groupMessageMapper.selectById(id); |
| 589 | 589 | } |
| 590 | 590 | |
| 591 | + @Override | |
| 592 | + public List<ImGroupMessageDO> getGroupMessageList(Collection<Long> ids) { | |
| 593 | + if (CollUtil.isEmpty(ids)) { | |
| 594 | + return Collections.emptyList(); | |
| 595 | + } | |
| 596 | + return groupMessageMapper.selectByIds(ids); | |
| 597 | + } | |
| 598 | + | |
| 599 | + @Override | |
| 600 | + public Map<Long, ImGroupMessageDO> getGroupMessageMap(Collection<Long> ids) { | |
| 601 | + return convertMap(getGroupMessageList(ids), ImGroupMessageDO::getId); | |
| 602 | + } | |
| 603 | + | |
| 591 | 604 | private ImGroupMessageServiceImpl getSelf() { |
| 592 | 605 | return SpringUtil.getBean(getClass()); |
| 593 | 606 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/message/dto/ImGroupMessageSendDTO.java
| ... | ... | @@ -160,4 +160,22 @@ public class ImGroupMessageSendDTO { |
| 160 | 160 | .setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification); |
| 161 | 161 | } |
| 162 | 162 | |
| 163 | + public static ImGroupMessageSendDTO ofGroupMessagePin(Long groupId, Long operatorUserId, | |
| 164 | + cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO message) { | |
| 165 | + GroupMessagePinNotification notification = new GroupMessagePinNotification(); | |
| 166 | + notification.setOperatorUserId(operatorUserId); | |
| 167 | + notification.setMessageId(message.getId()); | |
| 168 | + notification.setMessage(message); | |
| 169 | + return new ImGroupMessageSendDTO().setGroupId(groupId) | |
| 170 | + .setType(ImMessageTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification); | |
| 171 | + } | |
| 172 | + | |
| 173 | + public static ImGroupMessageSendDTO ofGroupMessageUnpin(Long groupId, Long operatorUserId, Long messageId) { | |
| 174 | + GroupMessageUnpinNotification notification = new GroupMessageUnpinNotification(); | |
| 175 | + notification.setOperatorUserId(operatorUserId); | |
| 176 | + notification.setMessageId(messageId); | |
| 177 | + return new ImGroupMessageSendDTO().setGroupId(groupId) | |
| 178 | + .setType(ImMessageTypeEnum.GROUP_MESSAGE_UNPIN.getType()).setContent(notification); | |
| 179 | + } | |
| 180 | + | |
| 163 | 181 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMessagePinNotification.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.im.service.websocket.dto.notification; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; | |
| 4 | +import lombok.Data; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * 群消息置顶事件通知 | |
| 8 | + * <p> | |
| 9 | + * payload 直接携带被置顶的完整消息对象,前端按 message 直接 push 到 pinnedMessages,避免 GET /im/group/get 命中 @CacheEvict 触发前的旧 cache | |
| 10 | + */ | |
| 11 | +@Data | |
| 12 | +public class GroupMessagePinNotification extends BaseGroupNotification { | |
| 13 | + | |
| 14 | + /** | |
| 15 | + * 被置顶的消息编号 | |
| 16 | + */ | |
| 17 | + private Long messageId; | |
| 18 | + /** | |
| 19 | + * 被置顶的完整消息对象(避免前端回查群详情) | |
| 20 | + */ | |
| 21 | + private ImGroupMessageDO message; | |
| 22 | + | |
| 23 | +} | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/notification/GroupMessageUnpinNotification.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.im.service.websocket.dto.notification; | |
| 2 | + | |
| 3 | +import lombok.Data; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 群消息取消置顶事件通知 | |
| 7 | + */ | |
| 8 | +@Data | |
| 9 | +public class GroupMessageUnpinNotification extends BaseGroupNotification { | |
| 10 | + | |
| 11 | + /** | |
| 12 | + * 被取消置顶的消息编号 | |
| 13 | + */ | |
| 14 | + private Long messageId; | |
| 15 | + | |
| 16 | +} | ... | ... |
yudao-module-im/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( |
| 3 | 3 | "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', |
| 4 | 4 | "sender_id" bigint NOT NULL COMMENT '发送人编号', |
| 5 | 5 | "receiver_id" bigint NOT NULL COMMENT '接收人编号', |
| 6 | - "type" tinyint NOT NULL COMMENT '消息类型', | |
| 6 | + "type" smallint NOT NULL COMMENT '消息类型', | |
| 7 | 7 | "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', |
| 8 | 8 | "status" tinyint NOT NULL COMMENT '消息状态', |
| 9 | 9 | "send_time" timestamp NOT NULL COMMENT '发送时间', |
| ... | ... | @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS "im_group_message" ( |
| 21 | 21 | "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', |
| 22 | 22 | "sender_id" bigint NOT NULL COMMENT '发送人编号', |
| 23 | 23 | "group_id" bigint NOT NULL COMMENT '群编号', |
| 24 | - "type" tinyint NOT NULL COMMENT '消息类型', | |
| 24 | + "type" smallint NOT NULL COMMENT '消息类型', | |
| 25 | 25 | "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', |
| 26 | 26 | "status" tinyint NOT NULL COMMENT '消息状态', |
| 27 | 27 | "send_time" timestamp NOT NULL COMMENT '发送时间', |
| ... | ... | @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS "im_group" ( |
| 48 | 48 | "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', |
| 49 | 49 | "status" tinyint NOT NULL COMMENT '群状态', |
| 50 | 50 | "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', |
| 51 | + "pinned_message_ids" varchar(128) DEFAULT NULL COMMENT '群置顶消息编号列表,逗号分隔', | |
| 51 | 52 | "creator" varchar(64) DEFAULT '', |
| 52 | 53 | "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 53 | 54 | "updater" varchar(64) DEFAULT '', | ... | ... |