Commit 6e823f848d40eb9c830d1a9f7d711311a60de038

Authored by YunaiV
1 parent ed9f23ae

✨ feat(im): 增加群消息的置顶

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&lt;Integer&gt; { @@ -159,7 +159,16 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
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 &quot;im_private_message&quot; ( @@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS &quot;im_private_message&quot; (
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 &quot;im_group_message&quot; ( @@ -21,7 +21,7 @@ CREATE TABLE IF NOT EXISTS &quot;im_group_message&quot; (
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 &quot;im_group&quot; ( @@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS &quot;im_group&quot; (
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 '',