Commit 032d9558006121a82d1f8748c73af3c92f372f4d
1 parent
611880a3
✨ feat(im): 新增频道消息的前端实现
Showing
8 changed files
with
160 additions
and
27 deletions
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 方法中的调用) |