Commit 032d9558006121a82d1f8748c73af3c92f372f4d

Authored by YunaiV
1 parent 611880a3

✨ feat(im): 新增频道消息的前端实现

yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMaterialController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  5 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialRespVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  7 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
  8 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMessageService;
  9 +import io.swagger.v3.oas.annotations.Operation;
  10 +import io.swagger.v3.oas.annotations.Parameter;
  11 +import io.swagger.v3.oas.annotations.tags.Tag;
  12 +import jakarta.annotation.Resource;
  13 +import org.springframework.validation.annotation.Validated;
  14 +import org.springframework.web.bind.annotation.GetMapping;
  15 +import org.springframework.web.bind.annotation.RequestMapping;
  16 +import org.springframework.web.bind.annotation.RequestParam;
  17 +import org.springframework.web.bind.annotation.RestController;
  18 +
  19 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  20 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  21 +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
  22 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_NOT_EXISTS;
  23 +
  24 +@Tag(name = "用户 APP - IM 频道素材")
  25 +@RestController
  26 +@RequestMapping("/im/channel/material")
  27 +@Validated
  28 +public class ImChannelMaterialController {
  29 +
  30 + @Resource
  31 + private ImChannelMaterialService channelMaterialService;
  32 + @Resource
  33 + private ImChannelMessageService channelMessageService;
  34 +
  35 + // TODO @AI:要不,还是不校验了。会存在一个情况,有人转发图文消息到私聊里。(先不考虑这块的安全性了;简化简化!)
  36 + @GetMapping("/get")
  37 + @Operation(summary = "获取素材详情;用于客户端点击图文卡片渲染详情页")
  38 + @Parameter(name = "id", description = "素材编号", required = true, example = "1024")
  39 + public CommonResult<ImChannelMaterialRespVO> getMaterial(@RequestParam("id") Long id) {
  40 + // 防横向越权:仅允许拉取自己曾经收到过的素材,否则视为不存在
  41 + if (!channelMessageService.isUserReceivedMaterial(getLoginUserId(), id)) {
  42 + throw exception(IM_CHANNEL_MATERIAL_NOT_EXISTS);
  43 + }
  44 + // 获取素材详情
  45 + ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(id);
  46 + return success(BeanUtils.toBean(material, ImChannelMaterialRespVO.class));
  47 + }
  48 +
  49 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/ImChannelMessageController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  5 +import cn.iocoder.yudao.module.im.controller.admin.channel.vo.ImChannelMessagePullRespVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO;
  7 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMessageService;
  8 +import io.swagger.v3.oas.annotations.Operation;
  9 +import io.swagger.v3.oas.annotations.tags.Tag;
  10 +import jakarta.annotation.Resource;
  11 +import org.springframework.validation.annotation.Validated;
  12 +import org.springframework.web.bind.annotation.GetMapping;
  13 +import org.springframework.web.bind.annotation.RequestMapping;
  14 +import org.springframework.web.bind.annotation.RequestParam;
  15 +import org.springframework.web.bind.annotation.RestController;
  16 +
  17 +import java.util.List;
  18 +
  19 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  20 +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
  21 +
  22 +@Tag(name = "用户 APP - IM 频道消息")
  23 +@RestController
  24 +@RequestMapping("/im/channel/message")
  25 +@Validated
  26 +public class ImChannelMessageController {
  27 +
  28 + @Resource
  29 + private ImChannelMessageService channelMessageService;
  30 +
  31 + @GetMapping("/pull")
  32 + @Operation(summary = "拉取频道消息(离线增量);按 minId 游标分页")
  33 + public CommonResult<List<ImChannelMessagePullRespVO>> pull(
  34 + @RequestParam(value = "minId", defaultValue = "0") Long minId,
  35 + @RequestParam(value = "size", defaultValue = "100") Integer size) {
  36 + List<ImChannelMessageDO> list = channelMessageService.getMessageListForPull(getLoginUserId(), minId, size);
  37 + return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class));
  38 + }
  39 +
  40 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/channel/vo/ImChannelMessagePullRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.channel.vo;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +@Schema(description = "用户 APP - IM 频道消息拉取 Response VO")
  9 +@Data
  10 +public class ImChannelMessagePullRespVO {
  11 +
  12 + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "9001")
  13 + private Long id;
  14 +
  15 + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  16 + private Long channelId;
  17 +
  18 + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  19 + private Long materialId;
  20 +
  21 + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125")
  22 + private Integer type;
  23 +
  24 + @Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED)
  25 + private String content;
  26 +
  27 + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
  28 + private LocalDateTime sendTime;
  29 +
  30 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java
@@ -29,11 +29,11 @@ public interface ImChannelMessageService { @@ -29,11 +29,11 @@ public interface ImChannelMessageService {
29 List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size); 29 List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size);
30 30
31 /** 31 /**
32 - * 校验用户是否能看到指定素材;用作详情接口的归属校验 32 + * 校验用户是否曾经收到过指定素材;防横向越权
33 * 33 *
34 * @param userId 当前用户编号 34 * @param userId 当前用户编号
35 * @param materialId 素材编号 35 * @param materialId 素材编号
36 - * @return 是否能看到(曾经收到过 / 全员消息) 36 + * @return 是否曾收到(receiver_user_ids 命中 / 全员)
37 */ 37 */
38 boolean isUserReceivedMaterial(Long userId, Long materialId); 38 boolean isUserReceivedMaterial(Long userId, Long materialId);
39 39
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; @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.channel;
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
4 import cn.iocoder.yudao.framework.common.pojo.PageResult; 4 import cn.iocoder.yudao.framework.common.pojo.PageResult;
5 import cn.iocoder.yudao.framework.common.util.json.JsonUtils; 5 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  6 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
6 import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO; 7 import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO;
7 import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO; 8 import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO;
8 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; 9 import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
@@ -59,31 +60,19 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService { @@ -59,31 +60,19 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
59 // 1. 校验素材存在 60 // 1. 校验素材存在
60 ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(reqVO.getMaterialId()); 61 ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(reqVO.getMaterialId());
61 62
62 - // 2. 组装 payload(不带富文本正文)  
63 - // TODO @AI:链式调用;想通在一行  
64 - MaterialMessage payload = new MaterialMessage()  
65 - .setTitle(material.getTitle())  
66 - .setCoverUrl(material.getCoverUrl())  
67 - .setSummary(material.getSummary())  
68 - .setUrl(material.getUrl());  
69 - String payloadJson = JsonUtils.toJsonString(payload);  
70 -  
71 - // 3. 落库 1 行 message  
72 - // TODO @AI:链式调用;想通在一行  
73 - ImChannelMessageDO message = new ImChannelMessageDO()  
74 - .setChannelId(material.getChannelId())  
75 - .setMaterialId(material.getId())  
76 - .setType(ImMessageTypeEnum.MATERIAL.getType())  
77 - .setContent(payloadJson)  
78 - .setReceiverUserIds(reqVO.getReceiverUserIds())  
79 - .setSendTime(LocalDateTime.now()); 63 + // 2.1 组装 payload(不带富文本正文);字段同名直接 BeanUtils 拷贝
  64 + String payloadJson = JsonUtils.toJsonString(BeanUtils.toBean(material, MaterialMessage.class));
  65 + // 2.2 落库 1 行 message;reqVO 同名字段(materialId / receiverUserIds)自动拷贝,剩余字段补 set
  66 + ImChannelMessageDO message = BeanUtils.toBean(reqVO, ImChannelMessageDO.class).setChannelId(material.getChannelId())
  67 + .setType(ImMessageTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now());
80 channelMessageMapper.insert(message); 68 channelMessageMapper.insert(message);
81 69
82 - // 4. 异步推 WebSocket;全员场景(receiverUserIds 为空)暂不实时广播,依靠客户端上线后 pull 兜底  
83 - // TODO @AI:全院也要发; 70 + // 3. 异步推 WebSocket:指定用户走点对点;全员(receiverUserIds 为空)走广播
  71 + ImChannelMessageDTO dto = ImChannelMessageDTO.ofSend(message);
84 if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) { 72 if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) {
85 - webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(),  
86 - ImChannelMessageDTO.ofSend(message)); 73 + webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), dto);
  74 + } else {
  75 + webSocketService.broadcastChannelMessageAsync(dto);
87 } 76 }
88 return message.getId(); 77 return message.getId();
89 } 78 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java
1 package cn.iocoder.yudao.module.im.service.channel; 1 package cn.iocoder.yudao.module.im.service.channel;
2 2
3 import cn.hutool.core.collection.CollUtil; 3 import cn.hutool.core.collection.CollUtil;
  4 +import cn.hutool.core.util.ObjUtil;
4 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; 5 import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
5 import cn.iocoder.yudao.framework.common.pojo.PageResult; 6 import cn.iocoder.yudao.framework.common.pojo.PageResult;
6 import cn.iocoder.yudao.framework.common.util.object.BeanUtils; 7 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -15,7 +16,6 @@ import org.springframework.validation.annotation.Validated; @@ -15,7 +16,6 @@ import org.springframework.validation.annotation.Validated;
15 import java.util.Collection; 16 import java.util.Collection;
16 import java.util.Collections; 17 import java.util.Collections;
17 import java.util.List; 18 import java.util.List;
18 -import java.util.Objects;  
19 19
20 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; 20 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
21 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_CODE_DUPLICATED; 21 import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_CODE_DUPLICATED;
@@ -113,8 +113,7 @@ public class ImChannelServiceImpl implements ImChannelService { @@ -113,8 +113,7 @@ public class ImChannelServiceImpl implements ImChannelService {
113 if (exist == null) { 113 if (exist == null) {
114 return; 114 return;
115 } 115 }
116 - // TODO @AI:notEquals objutils;  
117 - if (id == null || !Objects.equals(exist.getId(), id)) { 116 + if (id == null || ObjUtil.notEqual(exist.getId(), id)) {
118 throw exception(IM_CHANNEL_CODE_DUPLICATED, code); 117 throw exception(IM_CHANNEL_CODE_DUPLICATED, code);
119 } 118 }
120 } 119 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java
@@ -72,4 +72,11 @@ public interface ImWebSocketService { @@ -72,4 +72,11 @@ public interface ImWebSocketService {
72 */ 72 */
73 void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto); 73 void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto);
74 74
  75 + /**
  76 + * 异步广播频道消息给当前所有在线管理端用户;用于全员推送
  77 + *
  78 + * @param dto 频道消息 DTO
  79 + */
  80 + void broadcastChannelMessageAsync(ImChannelMessageDTO dto);
  81 +
75 } 82 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketServiceImpl.java
@@ -49,6 +49,11 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -49,6 +49,11 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
49 executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto)); 49 executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto));
50 } 50 }
51 51
  52 + @Override
  53 + public void broadcastChannelMessageAsync(ImChannelMessageDTO dto) {
  54 + executeAfterTransaction(() -> getSelf().doBroadcastChannelMessage(dto));
  55 + }
  56 +
52 /** 57 /**
53 * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 58 * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调
54 */ 59 */
@@ -95,6 +100,20 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -95,6 +100,20 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
95 } 100 }
96 101
97 /** 102 /**
  103 + * 异步广播频道 WebSocket 消息给当前所有在线管理端用户;
  104 + * 依赖 WebSocketMessageSender 按 UserType 广播能力,离线用户由客户端上线 pull 兜底
  105 + */
  106 + @Async
  107 + public void doBroadcastChannelMessage(ImChannelMessageDTO dto) {
  108 + try {
  109 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(),
  110 + ImChannelMessageDTO.TYPE, dto);
  111 + } catch (Exception e) {
  112 + log.error("[doBroadcastChannelMessage][dto({}) 广播失败]", dto, e);
  113 + }
  114 + }
  115 +
  116 + /**
98 * 事务感知的任务调度: 117 * 事务感知的任务调度:
99 * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 118 * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘
100 * - 无事务:直接执行(如非 @Transactional 方法中的调用) 119 * - 无事务:直接执行(如非 @Transactional 方法中的调用)