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 | package cn.iocoder.yudao.module.im.controller.admin.group; | 1 | package cn.iocoder.yudao.module.im.controller.admin.group; |
| 2 | 2 | ||
| 3 | +import cn.hutool.core.collection.CollUtil; | ||
| 3 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; | 4 | import cn.iocoder.yudao.framework.common.pojo.CommonResult; |
| 5 | +import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; | ||
| 4 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | 6 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 5 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO; | 7 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminAddReqVO; |
| 6 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO; | 8 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupAdminRemoveReqVO; |
| 7 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupCreateReqVO; | 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 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupRespVO; | 11 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupRespVO; |
| 9 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO; | 12 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupTransferOwnerReqVO; |
| 10 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; | 13 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.ImGroupUpdateReqVO; |
| 11 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; | 14 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO; |
| 12 | import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberRemoveReqVO; | 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 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; | 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 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; | 21 | import cn.iocoder.yudao.module.im.service.group.ImGroupService; |
| 22 | +import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; | ||
| 15 | import io.swagger.v3.oas.annotations.Operation; | 23 | import io.swagger.v3.oas.annotations.Operation; |
| 16 | import io.swagger.v3.oas.annotations.Parameter; | 24 | import io.swagger.v3.oas.annotations.Parameter; |
| 17 | import io.swagger.v3.oas.annotations.tags.Tag; | 25 | import io.swagger.v3.oas.annotations.tags.Tag; |
| @@ -20,9 +28,17 @@ import jakarta.validation.Valid; | @@ -20,9 +28,17 @@ import jakarta.validation.Valid; | ||
| 20 | import org.springframework.validation.annotation.Validated; | 28 | import org.springframework.validation.annotation.Validated; |
| 21 | import org.springframework.web.bind.annotation.*; | 29 | import org.springframework.web.bind.annotation.*; |
| 22 | 30 | ||
| 31 | +import java.util.Collections; | ||
| 23 | import java.util.List; | 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 | import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; | 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 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; | 42 | import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; |
| 27 | 43 | ||
| 28 | @Tag(name = "管理后台 - 群") | 44 | @Tag(name = "管理后台 - 群") |
| @@ -33,6 +49,10 @@ public class ImGroupController { | @@ -33,6 +49,10 @@ public class ImGroupController { | ||
| 33 | 49 | ||
| 34 | @Resource | 50 | @Resource |
| 35 | private ImGroupService groupService; | 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,6 +60,7 @@ public class ImGroupController { | ||
| 40 | @Operation(summary = "创建群") | 60 | @Operation(summary = "创建群") |
| 41 | public CommonResult<ImGroupRespVO> createGroup(@Valid @RequestBody ImGroupCreateReqVO createReqVO) { | 61 | public CommonResult<ImGroupRespVO> createGroup(@Valid @RequestBody ImGroupCreateReqVO createReqVO) { |
| 42 | ImGroupDO group = groupService.createGroup(createReqVO, getLoginUserId()); | 62 | ImGroupDO group = groupService.createGroup(createReqVO, getLoginUserId()); |
| 63 | + // 新建群必无 pinnedMessages,跳过关联回填 | ||
| 43 | return success(BeanUtils.toBean(group, ImGroupRespVO.class)); | 64 | return success(BeanUtils.toBean(group, ImGroupRespVO.class)); |
| 44 | } | 65 | } |
| 45 | 66 | ||
| @@ -47,7 +68,7 @@ public class ImGroupController { | @@ -47,7 +68,7 @@ public class ImGroupController { | ||
| 47 | @Operation(summary = "更新群") | 68 | @Operation(summary = "更新群") |
| 48 | public CommonResult<ImGroupRespVO> updateGroup(@Valid @RequestBody ImGroupUpdateReqVO updateReqVO) { | 69 | public CommonResult<ImGroupRespVO> updateGroup(@Valid @RequestBody ImGroupUpdateReqVO updateReqVO) { |
| 49 | ImGroupDO group = groupService.updateGroup(updateReqVO, getLoginUserId()); | 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 | @DeleteMapping("/dissolve") | 74 | @DeleteMapping("/dissolve") |
| @@ -65,14 +86,15 @@ public class ImGroupController { | @@ -65,14 +86,15 @@ public class ImGroupController { | ||
| 65 | @Parameter(name = "id", description = "编号", required = true, example = "1024") | 86 | @Parameter(name = "id", description = "编号", required = true, example = "1024") |
| 66 | public CommonResult<ImGroupRespVO> getGroup(@RequestParam("id") Long id) { | 87 | public CommonResult<ImGroupRespVO> getGroup(@RequestParam("id") Long id) { |
| 67 | ImGroupDO group = groupService.getGroup(id); | 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 | @GetMapping("/list") | 92 | @GetMapping("/list") |
| 72 | @Operation(summary = "获得当前登录用户的群列表") | 93 | @Operation(summary = "获得当前登录用户的群列表") |
| 73 | public CommonResult<List<ImGroupRespVO>> getMyGroupList() { | 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,4 +142,59 @@ public class ImGroupController { | ||
| 120 | return success(true); | 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 | package cn.iocoder.yudao.module.im.controller.admin.group.vo; | 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 | import io.swagger.v3.oas.annotations.media.Schema; | 4 | import io.swagger.v3.oas.annotations.media.Schema; |
| 4 | import lombok.*; | 5 | import lombok.*; |
| 5 | 6 | ||
| 6 | import java.time.LocalDateTime; | 7 | import java.time.LocalDateTime; |
| 8 | +import java.util.List; | ||
| 7 | 9 | ||
| 8 | @Schema(description = "管理后台 - 群 Response VO") | 10 | @Schema(description = "管理后台 - 群 Response VO") |
| 9 | @Data | 11 | @Data |
| @@ -39,4 +41,7 @@ public class ImGroupRespVO { | @@ -39,4 +41,7 @@ public class ImGroupRespVO { | ||
| 39 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) | 41 | @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) |
| 40 | private LocalDateTime createTime; | 42 | private LocalDateTime createTime; |
| 41 | 43 | ||
| 44 | + @Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空") | ||
| 45 | + private List<ImGroupMessageRespVO> pinnedMessages; | ||
| 46 | + | ||
| 42 | } | 47 | } |
| 43 | \ No newline at end of file | 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,19 +2,22 @@ package cn.iocoder.yudao.module.im.dal.dataobject.group; | ||
| 2 | 2 | ||
| 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; | 3 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 4 | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; | 4 | import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; |
| 5 | +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; | ||
| 5 | import com.baomidou.mybatisplus.annotation.KeySequence; | 6 | import com.baomidou.mybatisplus.annotation.KeySequence; |
| 7 | +import com.baomidou.mybatisplus.annotation.TableField; | ||
| 6 | import com.baomidou.mybatisplus.annotation.TableId; | 8 | import com.baomidou.mybatisplus.annotation.TableId; |
| 7 | import com.baomidou.mybatisplus.annotation.TableName; | 9 | import com.baomidou.mybatisplus.annotation.TableName; |
| 8 | import lombok.*; | 10 | import lombok.*; |
| 9 | 11 | ||
| 10 | import java.time.LocalDateTime; | 12 | import java.time.LocalDateTime; |
| 13 | +import java.util.List; | ||
| 11 | 14 | ||
| 12 | /** | 15 | /** |
| 13 | * IM 群信息 DO | 16 | * IM 群信息 DO |
| 14 | * | 17 | * |
| 15 | * @author 芋道源码 | 18 | * @author 芋道源码 |
| 16 | */ | 19 | */ |
| 17 | -@TableName("im_group") | 20 | +@TableName(value = "im_group",autoResultMap = true) |
| 18 | @KeySequence("im_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 | 21 | @KeySequence("im_group_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 |
| 19 | @Data | 22 | @Data |
| 20 | @EqualsAndHashCode(callSuper = true) | 23 | @EqualsAndHashCode(callSuper = true) |
| @@ -70,5 +73,12 @@ public class ImGroupDO extends BaseDO { | @@ -70,5 +73,12 @@ public class ImGroupDO extends BaseDO { | ||
| 70 | * 解散时间 | 73 | * 解散时间 |
| 71 | */ | 74 | */ |
| 72 | private LocalDateTime dissolvedTime; | 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,6 +17,7 @@ public interface ErrorCodeConstants { | ||
| 17 | ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); | 17 | ErrorCode MESSAGE_PULL_SIZE_EXCEEDED = new ErrorCode(1_040_300_005, "单次拉取消息数量不能超过 {} 条"); |
| 18 | ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); | 18 | ErrorCode MESSAGE_RECALL_TIMEOUT = new ErrorCode(1_040_300_007, "超过 {} 分钟的消息无法撤回"); |
| 19 | ErrorCode MESSAGE_QUOTE_INVALID = new ErrorCode(1_040_300_008, "引用的消息不可用"); | 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 | // ========== 群 (1-040-400-000) ========== | 22 | // ========== 群 (1-040-400-000) ========== |
| 22 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); | 23 | ErrorCode GROUP_NOT_EXISTS = new ErrorCode(1_040_400_000, "群不存在"); |
| @@ -25,6 +26,9 @@ public interface ErrorCodeConstants { | @@ -25,6 +26,9 @@ public interface ErrorCodeConstants { | ||
| 25 | ErrorCode GROUP_NOT_OWNER = new ErrorCode(1_040_400_003, "仅群主可执行该操作"); | 26 | ErrorCode GROUP_NOT_OWNER = new ErrorCode(1_040_400_003, "仅群主可执行该操作"); |
| 26 | ErrorCode GROUP_NOT_OWNER_OR_ADMIN = new ErrorCode(1_040_400_004, "仅群主或管理员可执行该操作"); | 27 | ErrorCode GROUP_NOT_OWNER_OR_ADMIN = new ErrorCode(1_040_400_004, "仅群主或管理员可执行该操作"); |
| 27 | ErrorCode GROUP_TRANSFER_OWNER_TO_SELF = new ErrorCode(1_040_400_005, "不能将群主转让给自己"); | 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 | // ========== 群成员 (1-040-500-000) ========== | 33 | // ========== 群成员 (1-040-500-000) ========== |
| 30 | ErrorCode GROUP_MEMBER_NOT_IN_GROUP = new ErrorCode(1_040_500_001, "您已不在该群中"); | 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,7 +159,16 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> { | ||
| 159 | * 对应自己的类:GroupMemberSettingUpdateNotification | 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 | public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new); | 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,6 +154,26 @@ public interface ImGroupService { | ||
| 154 | */ | 154 | */ |
| 155 | void transferGroupOwner(Long userId, @Valid ImGroupTransferOwnerReqVO transferReqVO); | 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,8 +22,11 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.group.vo.ImGroupManag | ||
| 22 | import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; | 22 | import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO; |
| 23 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; | 23 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO; |
| 24 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; | 24 | import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO; |
| 25 | +import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO; | ||
| 25 | import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; | 26 | import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper; |
| 26 | import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum; | 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 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; | 30 | import cn.iocoder.yudao.module.im.service.friend.ImFriendService; |
| 28 | import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; | 31 | import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService; |
| 29 | import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; | 32 | import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; |
| @@ -363,6 +366,68 @@ public class ImGroupServiceImpl implements ImGroupService { | @@ -363,6 +366,68 @@ public class ImGroupServiceImpl implements ImGroupService { | ||
| 363 | ImGroupMessageSendDTO.ofGroupOwnerTransfer(groupId, userId, newOwnerUserId)); | 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 | @Override | 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,6 +9,7 @@ import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO; | ||
| 9 | 9 | ||
| 10 | import java.util.Collection; | 10 | import java.util.Collection; |
| 11 | import java.util.List; | 11 | import java.util.List; |
| 12 | +import java.util.Map; | ||
| 12 | 13 | ||
| 13 | /** | 14 | /** |
| 14 | * IM 群聊消息 Service 接口 | 15 | * IM 群聊消息 Service 接口 |
| @@ -138,5 +139,21 @@ public interface ImGroupMessageService { | @@ -138,5 +139,21 @@ public interface ImGroupMessageService { | ||
| 138 | */ | 139 | */ |
| 139 | ImGroupMessageDO getGroupMessage(Long id); | 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,6 +588,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService { | ||
| 588 | return groupMessageMapper.selectById(id); | 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 | private ImGroupMessageServiceImpl getSelf() { | 604 | private ImGroupMessageServiceImpl getSelf() { |
| 592 | return SpringUtil.getBean(getClass()); | 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,4 +160,22 @@ public class ImGroupMessageSendDTO { | ||
| 160 | .setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification); | 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,7 +3,7 @@ CREATE TABLE IF NOT EXISTS "im_private_message" ( | ||
| 3 | "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', | 3 | "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', |
| 4 | "sender_id" bigint NOT NULL COMMENT '发送人编号', | 4 | "sender_id" bigint NOT NULL COMMENT '发送人编号', |
| 5 | "receiver_id" bigint NOT NULL COMMENT '接收人编号', | 5 | "receiver_id" bigint NOT NULL COMMENT '接收人编号', |
| 6 | - "type" tinyint NOT NULL COMMENT '消息类型', | 6 | + "type" smallint NOT NULL COMMENT '消息类型', |
| 7 | "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', | 7 | "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', |
| 8 | "status" tinyint NOT NULL COMMENT '消息状态', | 8 | "status" tinyint NOT NULL COMMENT '消息状态', |
| 9 | "send_time" timestamp NOT NULL COMMENT '发送时间', | 9 | "send_time" timestamp NOT NULL COMMENT '发送时间', |
| @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS "im_group_message" ( | @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS "im_group_message" ( | ||
| 21 | "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', | 21 | "client_message_id" varchar(64) DEFAULT NULL COMMENT '客户端消息编号', |
| 22 | "sender_id" bigint NOT NULL COMMENT '发送人编号', | 22 | "sender_id" bigint NOT NULL COMMENT '发送人编号', |
| 23 | "group_id" bigint NOT NULL COMMENT '群编号', | 23 | "group_id" bigint NOT NULL COMMENT '群编号', |
| 24 | - "type" tinyint NOT NULL COMMENT '消息类型', | 24 | + "type" smallint NOT NULL COMMENT '消息类型', |
| 25 | "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', | 25 | "content" varchar(4096) DEFAULT NULL COMMENT '消息内容', |
| 26 | "status" tinyint NOT NULL COMMENT '消息状态', | 26 | "status" tinyint NOT NULL COMMENT '消息状态', |
| 27 | "send_time" timestamp NOT NULL COMMENT '发送时间', | 27 | "send_time" timestamp NOT NULL COMMENT '发送时间', |
| @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS "im_group" ( | @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS "im_group" ( | ||
| 48 | "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', | 48 | "banned_time" timestamp DEFAULT NULL COMMENT '封禁时间', |
| 49 | "status" tinyint NOT NULL COMMENT '群状态', | 49 | "status" tinyint NOT NULL COMMENT '群状态', |
| 50 | "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', | 50 | "dissolved_time" timestamp DEFAULT NULL COMMENT '解散时间', |
| 51 | + "pinned_message_ids" varchar(128) DEFAULT NULL COMMENT '群置顶消息编号列表,逗号分隔', | ||
| 51 | "creator" varchar(64) DEFAULT '', | 52 | "creator" varchar(64) DEFAULT '', |
| 52 | "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, | 53 | "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, |
| 53 | "updater" varchar(64) DEFAULT '', | 54 | "updater" varchar(64) DEFAULT '', |