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 | 29 | List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size); |
| 30 | 30 | |
| 31 | 31 | /** |
| 32 | - * 校验用户是否能看到指定素材;用作详情接口的归属校验 | |
| 32 | + * 校验用户是否曾经收到过指定素材;防横向越权 | |
| 33 | 33 | * |
| 34 | 34 | * @param userId 当前用户编号 |
| 35 | 35 | * @param materialId 素材编号 |
| 36 | - * @return 是否能看到(曾经收到过 / 全员消息) | |
| 36 | + * @return 是否曾收到(receiver_user_ids 命中 / 全员) | |
| 37 | 37 | */ |
| 38 | 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 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | 4 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | 5 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 6 | +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | |
| 6 | 7 | import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO; |
| 7 | 8 | import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO; |
| 8 | 9 | import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; |
| ... | ... | @@ -59,31 +60,19 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService { |
| 59 | 60 | // 1. 校验素材存在 |
| 60 | 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 | 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 | 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 | 77 | return message.getId(); |
| 89 | 78 | } | ... | ... |
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.im.service.channel; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.ObjUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 6 | 7 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| ... | ... | @@ -15,7 +16,6 @@ import org.springframework.validation.annotation.Validated; |
| 15 | 16 | import java.util.Collection; |
| 16 | 17 | import java.util.Collections; |
| 17 | 18 | import java.util.List; |
| 18 | -import java.util.Objects; | |
| 19 | 19 | |
| 20 | 20 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 21 | 21 | import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_CODE_DUPLICATED; |
| ... | ... | @@ -113,8 +113,7 @@ public class ImChannelServiceImpl implements ImChannelService { |
| 113 | 113 | if (exist == null) { |
| 114 | 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 | 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 | 72 | */ |
| 73 | 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 | 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 | 58 | * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 |
| 54 | 59 | */ |
| ... | ... | @@ -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 | 118 | * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 |
| 100 | 119 | * - 无事务:直接执行(如非 @Transactional 方法中的调用) | ... | ... |