From 032d9558006121a82d1f8748c73af3c92f372f4d Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 19 May 2026 13:26:33 +0800 Subject: [PATCH] ✨ feat(im): 新增频道消息的前端实现 --- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMessageController.java | 40 ++++++++++++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/vo/ImChannelMessagePullRespVO.java | 30 ++++++++++++++++++++++++++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java | 4 ++-- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java | 33 +++++++++++---------------------- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java | 5 ++--- yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java | 7 +++++++ yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java | 19 +++++++++++++++++++ 8 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMessageController.java create mode 100644 yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/vo/ImChannelMessagePullRespVO.java diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java new file mode 100644 index 0000000..403c035 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.im.controller.admin.channel; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_NOT_EXISTS; + +@Tag(name = "用户 APP - IM 频道素材") +@RestController +@RequestMapping("/im/channel/material") +@Validated +public class ImChannelMaterialController { + + @Resource + private ImChannelMaterialService channelMaterialService; + @Resource + private ImChannelMessageService channelMessageService; + + // TODO @AI:要不,还是不校验了。会存在一个情况,有人转发图文消息到私聊里。(先不考虑这块的安全性了;简化简化!) + @GetMapping("/get") + @Operation(summary = "获取素材详情;用于客户端点击图文卡片渲染详情页") + @Parameter(name = "id", description = "素材编号", required = true, example = "1024") + public CommonResult getMaterial(@RequestParam("id") Long id) { + // 防横向越权:仅允许拉取自己曾经收到过的素材,否则视为不存在 + if (!channelMessageService.isUserReceivedMaterial(getLoginUserId(), id)) { + throw exception(IM_CHANNEL_MATERIAL_NOT_EXISTS); + } + // 获取素材详情 + ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(id); + return success(BeanUtils.toBean(material, ImChannelMaterialRespVO.class)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMessageController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMessageController.java new file mode 100644 index 0000000..26f7f83 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMessageController.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.controller.admin.channel; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.channel.vo.ImChannelMessagePullRespVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 APP - IM 频道消息") +@RestController +@RequestMapping("/im/channel/message") +@Validated +public class ImChannelMessageController { + + @Resource + private ImChannelMessageService channelMessageService; + + @GetMapping("/pull") + @Operation(summary = "拉取频道消息(离线增量);按 minId 游标分页") + public CommonResult> pull( + @RequestParam(value = "minId", defaultValue = "0") Long minId, + @RequestParam(value = "size", defaultValue = "100") Integer size) { + List list = channelMessageService.getMessageListForPull(getLoginUserId(), minId, size); + return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/vo/ImChannelMessagePullRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/vo/ImChannelMessagePullRespVO.java new file mode 100644 index 0000000..154ea9b --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/vo/ImChannelMessagePullRespVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.im.controller.admin.channel.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "用户 APP - IM 频道消息拉取 Response VO") +@Data +public class ImChannelMessagePullRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9001") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long channelId; + + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long materialId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125") + private Integer type; + + @Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java index 20d134d..4d3c848 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java @@ -29,11 +29,11 @@ public interface ImChannelMessageService { List getMessageListForPull(Long userId, Long minId, Integer size); /** - * 校验用户是否能看到指定素材;用作详情接口的归属校验 + * 校验用户是否曾经收到过指定素材;防横向越权 * * @param userId 当前用户编号 * @param materialId 素材编号 - * @return 是否能看到(曾经收到过 / 全员消息) + * @return 是否曾收到(receiver_user_ids 命中 / 全员) */ boolean isUserReceivedMaterial(Long userId, Long materialId); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java index c88e869..c37a3d0 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.channel; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO; import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO; import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; @@ -59,31 +60,19 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService { // 1. 校验素材存在 ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(reqVO.getMaterialId()); - // 2. 组装 payload(不带富文本正文) - // TODO @AI:链式调用;想通在一行 - MaterialMessage payload = new MaterialMessage() - .setTitle(material.getTitle()) - .setCoverUrl(material.getCoverUrl()) - .setSummary(material.getSummary()) - .setUrl(material.getUrl()); - String payloadJson = JsonUtils.toJsonString(payload); - - // 3. 落库 1 行 message - // TODO @AI:链式调用;想通在一行 - ImChannelMessageDO message = new ImChannelMessageDO() - .setChannelId(material.getChannelId()) - .setMaterialId(material.getId()) - .setType(ImMessageTypeEnum.MATERIAL.getType()) - .setContent(payloadJson) - .setReceiverUserIds(reqVO.getReceiverUserIds()) - .setSendTime(LocalDateTime.now()); + // 2.1 组装 payload(不带富文本正文);字段同名直接 BeanUtils 拷贝 + String payloadJson = JsonUtils.toJsonString(BeanUtils.toBean(material, MaterialMessage.class)); + // 2.2 落库 1 行 message;reqVO 同名字段(materialId / receiverUserIds)自动拷贝,剩余字段补 set + ImChannelMessageDO message = BeanUtils.toBean(reqVO, ImChannelMessageDO.class).setChannelId(material.getChannelId()) + .setType(ImMessageTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now()); channelMessageMapper.insert(message); - // 4. 异步推 WebSocket;全员场景(receiverUserIds 为空)暂不实时广播,依靠客户端上线后 pull 兜底 - // TODO @AI:全院也要发; + // 3. 异步推 WebSocket:指定用户走点对点;全员(receiverUserIds 为空)走广播 + ImChannelMessageDTO dto = ImChannelMessageDTO.ofSend(message); if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) { - webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), - ImChannelMessageDTO.ofSend(message)); + webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), dto); + } else { + webSocketService.broadcastChannelMessageAsync(dto); } return message.getId(); } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java index 1c3fe9d..1d04538 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.im.service.channel; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; @@ -15,7 +16,6 @@ import org.springframework.validation.annotation.Validated; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Objects; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_CODE_DUPLICATED; @@ -113,8 +113,7 @@ public class ImChannelServiceImpl implements ImChannelService { if (exist == null) { return; } - // TODO @AI:notEquals objutils; - if (id == null || !Objects.equals(exist.getId(), id)) { + if (id == null || ObjUtil.notEqual(exist.getId(), id)) { throw exception(IM_CHANNEL_CODE_DUPLICATED, code); } } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java index d45eb9d..c298e7d 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java @@ -72,4 +72,11 @@ public interface ImWebSocketService { */ void sendChannelMessageAsync(Collection userIds, ImChannelMessageDTO dto); + /** + * 异步广播频道消息给当前所有在线管理端用户;用于全员推送 + * + * @param dto 频道消息 DTO + */ + void broadcastChannelMessageAsync(ImChannelMessageDTO dto); + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java index 3a9d327..1f68fe3 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java @@ -49,6 +49,11 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto)); } + @Override + public void broadcastChannelMessageAsync(ImChannelMessageDTO dto) { + executeAfterTransaction(() -> getSelf().doBroadcastChannelMessage(dto)); + } + /** * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 */ @@ -95,6 +100,20 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { } /** + * 异步广播频道 WebSocket 消息给当前所有在线管理端用户; + * 依赖 WebSocketMessageSender 按 UserType 广播能力,离线用户由客户端上线 pull 兜底 + */ + @Async + public void doBroadcastChannelMessage(ImChannelMessageDTO dto) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), + ImChannelMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doBroadcastChannelMessage][dto({}) 广播失败]", dto, e); + } + } + + /** * 事务感知的任务调度: * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 * - 无事务:直接执行(如非 @Transactional 方法中的调用) -- libgit2 0.21.4