diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java new file mode 100644 index 0000000..4eda7e7 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java @@ -0,0 +1,82 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelService; +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 jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - IM 频道") +@RestController +@RequestMapping("/im/manager/channel") +@Validated +public class ImChannelManagerController { + + @Resource + private ImChannelService channelService; + + @PostMapping("/create") + @Operation(summary = "新增频道") + @PreAuthorize("@ss.hasPermission('im:manager:channel:create')") + public CommonResult createChannel(@Valid @RequestBody ImChannelSaveReqVO reqVO) { + return success(channelService.createChannel(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改频道") + @PreAuthorize("@ss.hasPermission('im:manager:channel:update')") + public CommonResult updateChannel(@Valid @RequestBody ImChannelSaveReqVO reqVO) { + channelService.updateChannel(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除频道") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel:delete')") + public CommonResult deleteChannel(@RequestParam("id") Long id) { + channelService.deleteChannel(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得频道分页") + @PreAuthorize("@ss.hasPermission('im:manager:channel:query')") + public CommonResult> getChannelPage(@Valid ImChannelPageReqVO pageReqVO) { + PageResult pageResult = channelService.getChannelPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, ImChannelRespVO.class)); + } + + @GetMapping("/get") + @Operation(summary = "获得频道详情") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel:query')") + public CommonResult getChannel(@RequestParam("id") Long id) { + ImChannelDO channel = channelService.getChannel(id); + return success(BeanUtils.toBean(channel, ImChannelRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得启用的频道精简列表;前端表单选择频道时调用") + public CommonResult> getEnabledChannelList() { + // TODO @AI:getChannelListByXXX;更通用点; + List list = channelService.getEnabledChannelList(); + return success(BeanUtils.toBean(list, ImChannelRespVO.class)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java new file mode 100644 index 0000000..0b95265 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +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.ImChannelService; +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 jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 频道素材") +@RestController +@RequestMapping("/im/manager/channel-material") +@Validated +public class ImChannelMaterialManagerController { + + @Resource + private ImChannelMaterialService channelMaterialService; + @Resource + private ImChannelService channelService; + + @PostMapping("/create") + @Operation(summary = "新增素材") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:create')") + public CommonResult createMaterial(@Valid @RequestBody ImChannelMaterialSaveReqVO reqVO) { + return success(channelMaterialService.createMaterial(reqVO)); + } + + @PutMapping("/update") + @Operation(summary = "修改素材") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:update')") + public CommonResult updateMaterial(@Valid @RequestBody ImChannelMaterialSaveReqVO reqVO) { + channelMaterialService.updateMaterial(reqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除素材") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:delete')") + public CommonResult deleteMaterial(@RequestParam("id") Long id) { + channelMaterialService.deleteMaterial(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得素材分页;含频道名回填") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:query')") + public CommonResult> getMaterialPage(@Valid ImChannelMaterialPageReqVO pageReqVO) { + PageResult pageResult = channelMaterialService.getMaterialPage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // 回填频道名 + List channels = channelService.getChannelList( + convertSet(pageResult.getList(), ImChannelMaterialDO::getChannelId)); + Map channelMap = convertMap(channels, ImChannelDO::getId); + return success(BeanUtils.toBean(pageResult, ImChannelMaterialRespVO.class, vo -> + MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName())))); + } + + @GetMapping("/get") + @Operation(summary = "获得素材详情(含富文本正文)") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:query')") + public CommonResult getMaterial(@RequestParam("id") Long id) { + ImChannelMaterialDO material = channelMaterialService.getMaterial(id); + return success(BeanUtils.toBean(material, ImChannelMaterialRespVO.class)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMessageManagerController.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMessageManagerController.java new file mode 100644 index 0000000..53da9fa --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMessageManagerController.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +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.ImChannelMessageRespVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService; +import cn.iocoder.yudao.module.im.service.channel.ImChannelMessageService; +import cn.iocoder.yudao.module.im.service.channel.ImChannelService; +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 jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - IM 频道消息") +@RestController +@RequestMapping("/im/manager/channel-message") +@Validated +public class ImChannelMessageManagerController { + + @Resource + private ImChannelMessageService channelMessageService; + @Resource + private ImChannelService channelService; + @Resource + private ImChannelMaterialService channelMaterialService; + + @PostMapping("/send") + @Operation(summary = "立即推送频道消息") + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:send')") + public CommonResult sendMessage(@Valid @RequestBody ImChannelMessageSendReqVO reqVO) { + return success(channelMessageService.sendMessage(reqVO)); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除频道消息") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:delete')") + public CommonResult deleteMessage(@RequestParam("id") Long id) { + channelMessageService.deleteMessage(id); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得频道消息分页;回填频道名 / 素材标题") + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:query')") + public CommonResult> getMessagePage(@Valid ImChannelMessagePageReqVO pageReqVO) { + PageResult pageResult = channelMessageService.getMessagePage(pageReqVO); + if (CollUtil.isEmpty(pageResult.getList())) { + return success(PageResult.empty(pageResult.getTotal())); + } + // TODO @AI:最好是 channelService 也包出一个 getChannelMap;类似别的模块 + Map channelMap = convertMap( + channelService.getChannelList(convertSet(pageResult.getList(), ImChannelMessageDO::getChannelId)), + ImChannelDO::getId); + // TODO @AI:最好是 channelService 也包出一个 getChannelMap;类似别的模块 + Map materialMap = convertMap( + channelMaterialService.getMaterialList(convertSet(pageResult.getList(), ImChannelMessageDO::getMaterialId)), + ImChannelMaterialDO::getId); + return success(BeanUtils.toBean(pageResult, ImChannelMessageRespVO.class, vo -> { + MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName())); + MapUtils.findAndThen(materialMap, vo.getMaterialId(), m -> vo.setMaterialTitle(m.getTitle())); + })); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java new file mode 100644 index 0000000..51ac09a --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Schema(description = "管理后台 - IM 频道分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImChannelPageReqVO extends PageParam { + + @Schema(description = "频道业务码", example = "system_notice") + private String code; + + @Schema(description = "频道名称", example = "系统") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java new file mode 100644 index 0000000..59fed78 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 频道 Response VO") +@Data +public class ImChannelRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "频道业务码", requiredMode = Schema.RequiredMode.REQUIRED, example = "system_notice") + private String code; + + @Schema(description = "频道名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统公告") + private String name; + + @Schema(description = "频道头像") + private String avatar; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; // 参见 CommonStatusEnum 枚举类 + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java new file mode 100644 index 0000000..6e1b042 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 频道新增 / 修改 Request VO") +@Data +public class ImChannelSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "1024") + private Long id; + + @Schema(description = "频道业务码;唯一", requiredMode = Schema.RequiredMode.REQUIRED, example = "system_notice") + @NotBlank(message = "频道编码不能为空") + @Size(max = 64, message = "频道编码长度不能超过 64") + @Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "频道编码只能由小写字母 / 数字 / 下划线组成,且必须以字母开头") + private String code; + + @Schema(description = "频道名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统公告") + @NotBlank(message = "频道名称不能为空") + @Size(max = 64, message = "频道名称长度不能超过 64") + private String name; + + @Schema(description = "频道头像", example = "https://cdn.example.com/channel/system_notice.png") + @Size(max = 512, message = "头像长度不能超过 512") + private String avatar; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "排序不能为空") + private Integer sort; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用) + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java new file mode 100644 index 0000000..92f40f6 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 频道素材分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImChannelMaterialPageReqVO extends PageParam { + + @Schema(description = "频道编号", example = "1") + private Long channelId; + + @Schema(description = "素材类型", example = "125") + private Integer type; + + @Schema(description = "标题", example = "活动") + private String title; + + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] createTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java new file mode 100644 index 0000000..4f0624c --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - IM 频道素材 Response VO") +@Data +public class ImChannelMaterialRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long channelId; + + @Schema(description = "频道名称(关联查询填充)") + private String channelName; + + @Schema(description = "素材类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED) + private String title; + + @Schema(description = "封面图") + private String coverUrl; + + @Schema(description = "摘要") + private String summary; + + @Schema(description = "正文;富文本 HTML") + private String content; + + @Schema(description = "跳转链接") + private String url; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java new file mode 100644 index 0000000..bc9a842 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - IM 频道素材新增 / 修改 Request VO") +@Data +public class ImChannelMaterialSaveReqVO { + + @Schema(description = "编号(修改时必填)", example = "1024") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "频道编号不能为空") + private Long channelId; + + @Schema(description = "素材类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125") + @NotNull(message = "素材类型不能为空") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "双十一活动来啦") + @NotBlank(message = "标题不能为空") + @Size(max = 128, message = "标题长度不能超过 128") + private String title; + + @Schema(description = "封面图", example = "https://cdn.example.com/cover.png") + @Size(max = 512, message = "封面图长度不能超过 512") + private String coverUrl; + + @Schema(description = "摘要", example = "全场五折,戳详情看玩法") + @Size(max = 255, message = "摘要长度不能超过 255") + private String summary; + + @Schema(description = "正文;富文本 HTML") + private String content; + + @Schema(description = "跳转链接;为空表示走客户端内置详情页", example = "https://example.com/activity/123") + @Size(max = 512, message = "跳转链接长度不能超过 512") + private String url; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessagePageReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessagePageReqVO.java new file mode 100644 index 0000000..55a1c3f --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessagePageReqVO.java @@ -0,0 +1,28 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; + +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; + +@Schema(description = "管理后台 - IM 频道消息分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class ImChannelMessagePageReqVO extends PageParam { + + @Schema(description = "频道编号", example = "1") + private Long channelId; + + @Schema(description = "素材编号", example = "1024") + private Long materialId; + + @Schema(description = "发送时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]") + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) + private LocalDateTime[] sendTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageRespVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageRespVO.java new file mode 100644 index 0000000..05143e8 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageRespVO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - IM 频道消息 Response VO") +@Data +public class ImChannelMessageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long channelId; + + @Schema(description = "频道名称(关联查询填充)") + private String channelName; + + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long materialId; + + @Schema(description = "素材标题(关联查询填充)") + private String materialTitle; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125") + private Integer type; // 参见 ImMessageTypeEnum 枚举类 + + @Schema(description = "消息内容;payload JSON 快照") + private String content; + + @Schema(description = "接收人编号列表;为空表示全员") + private List receiverUserIds; + + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageSendReqVO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageSendReqVO.java new file mode 100644 index 0000000..e9bbd0c --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageSendReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - IM 频道消息推送 Request VO") +@Data +public class ImChannelMessageSendReqVO { + + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "素材编号不能为空") + private Long materialId; + + @Schema(description = "接收人编号列表;为空表示全员", example = "[1024, 2048]") + private List receiverUserIds; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java new file mode 100644 index 0000000..f385cfa --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java @@ -0,0 +1,57 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.channel; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 频道 DO + *

+ * 业务语义: + * - 频道是运营单向推送的主体;C 端用户不能向频道发消息 + * - {@link #code} 是业务码(API / 字典外露),id 是数字主键给前端会话 targetId 用 + * + * @author 芋道源码 + */ +@TableName("im_channel") +@KeySequence("im_channel_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImChannelDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 频道业务码 + */ + private String code; + /** + * 频道名称 + */ + private String name; + /** + * 频道头像 + */ + private String avatar; + /** + * 排序 + */ + private Integer sort; + /** + * 状态 + *

+ * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java new file mode 100644 index 0000000..d38dabc --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java @@ -0,0 +1,71 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.channel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * IM 频道素材 DO + *

+ * 业务语义: + * - 运营素材库,可被反复推送 + * - 一条素材 1:N 关联多条 {@link ImChannelMessageDO} + * - {@link #content} 富文本仅在素材详情接口按需返回,推送 payload 不带,避免压爆 WebSocket 通道 + * + * @author 芋道源码 + */ +@TableName("im_channel_material") +@KeySequence("im_channel_material_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImChannelMaterialDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 频道编号 + *

+ * 关联 {@link ImChannelDO#getId()} + */ + private Long channelId; + /** + * 素材类型 + *

+ * 枚举 {@link ImMessageTypeEnum};当前仅 MATERIAL(125) + */ + private Integer type; + /** + * 标题 + */ + private String title; + /** + * 封面图 + */ + private String coverUrl; + /** + * 摘要 + */ + private String summary; + // TODO @AI:素材类型,是不是有个 type;这样一个 content、url 更好区分;然后这个 type ,是不是在 MATERIAL Message 类上,也带上; + /** + * 正文;富文本 HTML + */ + private String content; + /** + * 跳转链接 + *

+ * 为空时点击在客户端内置详情页拉 {@link #content} 渲染;非空则跳 url + */ + private String url; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMessageDO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMessageDO.java new file mode 100644 index 0000000..b04dd2d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMessageDO.java @@ -0,0 +1,72 @@ +package cn.iocoder.yudao.module.im.dal.dataobject.channel; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * IM 频道消息 DO + *

+ * 业务语义: + * - 一次推送 1 行;{@link #receiverUserIds} 为空表示全员 + * - {@link #channelId} 冗余 {@link ImChannelMaterialDO#getChannelId()} 便于按频道检索 + * - {@link #content} 存推送时 payload 的 JSON 快照(title / coverUrl / summary / url);不含富文本正文 + * + * @author 芋道源码 + */ +@TableName(value = "im_channel_message", autoResultMap = true) +@KeySequence("im_channel_message_seq") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ImChannelMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 频道编号 + *

+ * 关联 {@link ImChannelDO#getId()};冗余便于按频道检索 + */ + private Long channelId; + /** + * 关联素材编号 + *

+ * 关联 {@link ImChannelMaterialDO#getId()} + */ + private Long materialId; + /** + * 消息类型 + *

+ * 枚举 {@link ImMessageTypeEnum} + */ + private Integer type; + /** + * 消息内容;推送时 payload 的 JSON 快照 + */ + private String content; + /** + * 接收人编号列表;为空表示全员 + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List receiverUserIds; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java new file mode 100644 index 0000000..faab2b5 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.im.dal.mysql.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * IM 频道 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImChannelMapper extends BaseMapperX { + + default ImChannelDO selectByCode(String code) { + return selectOne(ImChannelDO::getCode, code); + } + + default List selectListByStatusOrderBySort(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(ImChannelDO::getStatus, status) + .orderByAsc(ImChannelDO::getSort)); + } + + default PageResult selectPage(ImChannelPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(ImChannelDO::getCode, reqVO.getCode()) + .likeIfPresent(ImChannelDO::getName, reqVO.getName()) + .eqIfPresent(ImChannelDO::getStatus, reqVO.getStatus()) + .orderByAsc(ImChannelDO::getSort) + .orderByDesc(ImChannelDO::getId)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java new file mode 100644 index 0000000..a95f462 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.im.dal.mysql.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import org.apache.ibatis.annotations.Mapper; + +/** + * IM 频道素材 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImChannelMaterialMapper extends BaseMapperX { + + default Long selectCountByChannelId(Long channelId) { + return selectCount(ImChannelMaterialDO::getChannelId, channelId); + } + + default PageResult selectPage(ImChannelMaterialPageReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX() + .eqIfPresent(ImChannelMaterialDO::getChannelId, reqVO.getChannelId()) + .eqIfPresent(ImChannelMaterialDO::getType, reqVO.getType()) + .likeIfPresent(ImChannelMaterialDO::getTitle, reqVO.getTitle()) + .betweenIfPresent(ImChannelMaterialDO::getCreateTime, reqVO.getCreateTime()) + .orderByDesc(ImChannelMaterialDO::getId); + // TODO @AI:不用排除;列表问题不大的; + // 列表查询排除富文本 content 字段,避免 mediumtext 拖垮分页响应 + wrapper.select(ImChannelMaterialDO::getId, ImChannelMaterialDO::getChannelId, + ImChannelMaterialDO::getType, ImChannelMaterialDO::getTitle, + ImChannelMaterialDO::getCoverUrl, ImChannelMaterialDO::getSummary, + ImChannelMaterialDO::getUrl, ImChannelMaterialDO::getCreator, + ImChannelMaterialDO::getCreateTime, ImChannelMaterialDO::getUpdater, + ImChannelMaterialDO::getUpdateTime, ImChannelMaterialDO::getDeleted); + return selectPage(reqVO, wrapper); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMessageMapper.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMessageMapper.java new file mode 100644 index 0000000..d99b8e4 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMessageMapper.java @@ -0,0 +1,70 @@ +package cn.iocoder.yudao.module.im.dal.mysql.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** + * IM 频道消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface ImChannelMessageMapper extends BaseMapperX { + + // TODO @AI:代码风格,和别的 message 一致; + /** + * 拉取指定用户应收的频道消息 + *

+ * 命中条件:id 大于游标 + (receiver_user_ids 为空表示全员 OR 逗号分隔列表里包含当前 userId) + * + * @param userId 当前用户编号 + * @param minId 游标;返回大于此值的消息 + * @param size 返回条数 + * @return 频道消息列表;按 id 升序 + */ + @Select("SELECT * FROM im_channel_message " + + "WHERE deleted = 0 " + + "AND id > #{minId} " + + "AND (receiver_user_ids IS NULL " + + " OR receiver_user_ids = '' " + + " OR FIND_IN_SET(#{userId}, receiver_user_ids)) " + + "ORDER BY id ASC " + + "LIMIT #{size}") + List selectListByUserAndMinId(@Param("userId") Long userId, + @Param("minId") Long minId, + @Param("size") Integer size); + + // TODO @AI:代码风格,和别的 message 一致; + /** + * 校验用户是否曾经收到过指定素材(receiver_user_ids 为空表示全员);用于详情接口归属校验 + */ + @Select("SELECT EXISTS(SELECT 1 FROM im_channel_message " + + "WHERE deleted = 0 AND material_id = #{materialId} " + + "AND (receiver_user_ids IS NULL " + + " OR receiver_user_ids = '' " + + " OR FIND_IN_SET(#{userId}, receiver_user_ids)) " + + "LIMIT 1)") + boolean existsByUserAndMaterial(@Param("userId") Long userId, + @Param("materialId") Long materialId); + + default Long selectCountByMaterialId(Long materialId) { + return selectCount(ImChannelMessageDO::getMaterialId, materialId); + } + + default PageResult selectPage(ImChannelMessagePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(ImChannelMessageDO::getChannelId, reqVO.getChannelId()) + .eqIfPresent(ImChannelMessageDO::getMaterialId, reqVO.getMaterialId()) + .betweenIfPresent(ImChannelMessageDO::getSendTime, reqVO.getSendTime()) + .orderByDesc(ImChannelMessageDO::getId)); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java index 3bd5c28..7b5a7d0 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java @@ -80,6 +80,14 @@ public interface ErrorCodeConstants { ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); + // ========== 频道 (1-040-810-000) ========== + ErrorCode IM_CHANNEL_NOT_EXISTS = new ErrorCode(1_040_810_000, "频道不存在"); + ErrorCode IM_CHANNEL_CODE_DUPLICATED = new ErrorCode(1_040_810_001, "频道编码 '{}' 已存在"); + ErrorCode IM_CHANNEL_HAS_MATERIAL = new ErrorCode(1_040_810_002, "频道下还有素材,无法删除"); + ErrorCode IM_CHANNEL_MATERIAL_NOT_EXISTS = new ErrorCode(1_040_810_010, "素材不存在"); + ErrorCode IM_CHANNEL_MATERIAL_USED = new ErrorCode(1_040_810_011, "素材已被推送过,无法删除"); + ErrorCode IM_CHANNEL_MESSAGE_NOT_EXISTS = new ErrorCode(1_040_810_020, "频道消息不存在"); + // ========== 实时通话 (1-040-900-000) ========== ErrorCode RTC_NOT_ENABLED = new ErrorCode(1_040_900_000, "通话功能未开启"); ErrorCode RTC_SESSION_NOT_EXISTS = new ErrorCode(1_040_900_001, "通话已结束"); diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java index ed40b37..0f3f446 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java @@ -17,7 +17,8 @@ import java.util.Objects; public enum ImConversationTypeEnum implements ArrayValuable { PRIVATE(1, "私聊"), // 私聊 - GROUP(2, "群聊"); // 群聊 + GROUP(2, "群聊"), // 群聊 + CHANNEL(3, "频道"); // 频道 / 公众号 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImConversationTypeEnum::getType).toArray(Integer[]::new); @@ -43,4 +44,8 @@ public enum ImConversationTypeEnum implements ArrayValuable { return Objects.equals(GROUP.type, type); } + public static boolean isChannel(Integer type) { + return Objects.equals(CHANNEL.type, type); + } + } diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java index fb37fac..caeccf2 100644 --- a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java @@ -61,6 +61,15 @@ public enum ImMessageTypeEnum implements ArrayValuable { */ FACE(115, "表情", true, true), + // ========== 频道消息扩展段(125+;OpenIM 122 之后未占用,本系统在 125 起步给频道 / 公众号类消息扩展) ========== + /** + * 对应 OpenIM:无(125 段位 OpenIM 未占用,作为频道消息扩展起始位) + * 对应自己的类:MaterialMessage + * 场景:频道运营推送的素材消息;当前形态为图文卡片(title + coverUrl + summary + url) + * 详情:url 非空跳 url;url 为空时客户端按 materialId 拉 /get-content 渲染富文本正文 + */ + MATERIAL(125, "素材", true, true), + // ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ========== /** * 对应 OpenIM:RevokeNotification 2101 diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java new file mode 100644 index 0000000..a2d18f4 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * IM 频道素材 Service 接口 + * + * @author 芋道源码 + */ +public interface ImChannelMaterialService { + + // ==================== 用户端 ==================== + + /** + * 获取素材正文;用于客户端点击图文卡片时拉富文本 + * + * @param id 素材编号 + * @return 富文本 HTML + */ + String getMaterialContent(Long id); + + /** + * 校验素材存在 + * + * @param id 素材编号 + * @return 素材 DO + */ + ImChannelMaterialDO validateMaterialExists(Long id); + + /** + * 按编号批量查询素材 + * + * @param ids 素材编号列表 + * @return 素材列表 + */ + List getMaterialList(Collection ids); + + /** + * 统计指定频道下的素材数量 + * + * @param channelId 频道编号 + * @return 数量 + */ + Long getMaterialCountByChannelId(Long channelId); + + // ==================== 管理后台 ==================== + + /** + * 分页查询素材 + * + * @param reqVO 分页查询条件 + * @return 素材分页 + */ + PageResult getMaterialPage(ImChannelMaterialPageReqVO reqVO); + + /** + * 获取素材详情(含 content 富文本) + * + * @param id 素材编号 + * @return 素材 DO + */ + ImChannelMaterialDO getMaterial(Long id); + + /** + * 新增素材 + * + * @param reqVO 新增请求 + * @return 新增素材编号 + */ + Long createMaterial(@Valid ImChannelMaterialSaveReqVO reqVO); + + /** + * 修改素材 + * + * @param reqVO 修改请求 + */ + void updateMaterial(@Valid ImChannelMaterialSaveReqVO reqVO); + + /** + * 删除素材;素材已被推送过时拒绝,避免历史消息无法回查 + * + * @param id 素材编号 + */ + void deleteMaterial(Long id); + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java new file mode 100644 index 0000000..7e72ace --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java @@ -0,0 +1,109 @@ +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.object.BeanUtils; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO; +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMaterialMapper; +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMessageMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_NOT_EXISTS; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_USED; + +/** + * IM 频道素材 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImChannelMaterialServiceImpl implements ImChannelMaterialService { + + @Resource + private ImChannelMaterialMapper channelMaterialMapper; + @Resource + private ImChannelService channelService; + @Resource + private ImChannelMessageMapper channelMessageMapper; + + // ==================== 用户端 ==================== + + @Override + public String getMaterialContent(Long id) { + ImChannelMaterialDO material = validateMaterialExists(id); + return material.getContent(); + } + + @Override + public ImChannelMaterialDO validateMaterialExists(Long id) { + ImChannelMaterialDO material = channelMaterialMapper.selectById(id); + if (material == null) { + throw exception(IM_CHANNEL_MATERIAL_NOT_EXISTS); + } + return material; + } + + @Override + public List getMaterialList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return channelMaterialMapper.selectByIds(ids); + } + + @Override + public Long getMaterialCountByChannelId(Long channelId) { + return channelMaterialMapper.selectCountByChannelId(channelId); + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getMaterialPage(ImChannelMaterialPageReqVO reqVO) { + return channelMaterialMapper.selectPage(reqVO); + } + + @Override + public ImChannelMaterialDO getMaterial(Long id) { + return channelMaterialMapper.selectById(id); + } + + // TODO @AI:类似别的模块,写全注释 + @Override + public Long createMaterial(ImChannelMaterialSaveReqVO reqVO) { + channelService.validateChannelExists(reqVO.getChannelId()); + ImChannelMaterialDO material = BeanUtils.toBean(reqVO, ImChannelMaterialDO.class); + channelMaterialMapper.insert(material); + return material.getId(); + } + + // TODO @AI:类似别的模块,写全注释 + @Override + public void updateMaterial(ImChannelMaterialSaveReqVO reqVO) { + validateMaterialExists(reqVO.getId()); + channelService.validateChannelExists(reqVO.getChannelId()); + ImChannelMaterialDO updateObj = BeanUtils.toBean(reqVO, ImChannelMaterialDO.class); + channelMaterialMapper.updateById(updateObj); + } + + @Override + public void deleteMaterial(Long id) { + validateMaterialExists(id); + // 防止删除素材导致历史 channel_message 反查不到内容 + if (channelMessageMapper.selectCountByMaterialId(id) > 0) { + throw exception(IM_CHANNEL_MATERIAL_USED); + } + channelMaterialMapper.deleteById(id); + } + +} 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 new file mode 100644 index 0000000..20d134d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +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.ImChannelMessageDO; +import jakarta.validation.Valid; + +import java.util.List; + +// TODO @AI:这块消息,我想挪到 message 里;后续,可能整合成一个表存储的; +/** + * IM 频道消息 Service 接口 + * + * @author 芋道源码 + */ +public interface ImChannelMessageService { + + // ==================== 用户端 ==================== + + /** + * 拉取当前用户应收的频道消息(离线增量) + * + * @param userId 当前用户编号 + * @param minId 游标;返回大于此值的消息 + * @param size 返回条数 + * @return 频道消息列表;按 id 升序 + */ + List getMessageListForPull(Long userId, Long minId, Integer size); + + /** + * 校验用户是否能看到指定素材;用作详情接口的归属校验 + * + * @param userId 当前用户编号 + * @param materialId 素材编号 + * @return 是否能看到(曾经收到过 / 全员消息) + */ + boolean isUserReceivedMaterial(Long userId, Long materialId); + + // ==================== 管理后台 ==================== + + /** + * 立即推送频道消息 + *

+ * 流程:读 material → 组装 payload → INSERT 1 行 im_channel_message → WebSocket 扇出 + * + * @param reqVO 推送请求 + * @return 消息编号 + */ + Long sendMessage(@Valid ImChannelMessageSendReqVO reqVO); + + /** + * 分页查询消息 + * + * @param reqVO 分页查询条件 + * @return 消息分页 + */ + PageResult getMessagePage(ImChannelMessagePageReqVO reqVO); + + /** + * 删除消息 + * + * @param id 消息编号 + */ + void deleteMessage(Long id); + +} 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 new file mode 100644 index 0000000..c88e869 --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java @@ -0,0 +1,104 @@ +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.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; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO; +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMessageMapper; +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum; +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; +import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MESSAGE_NOT_EXISTS; + +/** + * IM 频道消息 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImChannelMessageServiceImpl implements ImChannelMessageService { + + @Resource + private ImChannelMessageMapper channelMessageMapper; + @Resource + private ImChannelMaterialService channelMaterialService; + @Resource + private ImWebSocketService webSocketService; + + // ==================== 用户端 ==================== + + @Override + public List getMessageListForPull(Long userId, Long minId, Integer size) { + return channelMessageMapper.selectListByUserAndMinId(userId, minId, size); + } + + @Override + public boolean isUserReceivedMaterial(Long userId, Long materialId) { + return channelMessageMapper.existsByUserAndMaterial(userId, materialId); + } + + // ==================== 管理后台 ==================== + + @Override + @Transactional(rollbackFor = Exception.class) + public Long sendMessage(ImChannelMessageSendReqVO reqVO) { + // 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()); + channelMessageMapper.insert(message); + + // 4. 异步推 WebSocket;全员场景(receiverUserIds 为空)暂不实时广播,依靠客户端上线后 pull 兜底 + // TODO @AI:全院也要发; + if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) { + webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), + ImChannelMessageDTO.ofSend(message)); + } + return message.getId(); + } + + @Override + public PageResult getMessagePage(ImChannelMessagePageReqVO reqVO) { + return channelMessageMapper.selectPage(reqVO); + } + + @Override + public void deleteMessage(Long id) { + if (channelMessageMapper.selectById(id) == null) { + throw exception(IM_CHANNEL_MESSAGE_NOT_EXISTS); + } + channelMessageMapper.deleteById(id); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java new file mode 100644 index 0000000..5b1263f --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java @@ -0,0 +1,86 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * IM 频道 Service 接口 + * + * @author 芋道源码 + */ +public interface ImChannelService { + + // ==================== 用户端 ==================== + + // TODO @AI:getChannelListByStatus 这种形式; + /** + * 获取所有启用的频道,按 sort 升序 + * + * @return 启用的频道列表 + */ + List getEnabledChannelList(); + + /** + * 按编号批量查询频道 + * + * @param ids 频道编号列表 + * @return 频道列表 + */ + List getChannelList(Collection ids); + + /** + * 校验频道存在 + * + * @param id 频道编号 + * @return 频道 DO + */ + @SuppressWarnings("UnusedReturnValue") + ImChannelDO validateChannelExists(Long id); + + // ==================== 管理后台 ==================== + + /** + * 分页查询频道 + * + * @param reqVO 分页查询条件 + * @return 频道分页 + */ + PageResult getChannelPage(ImChannelPageReqVO reqVO); + + /** + * 获取频道详情 + * + * @param id 频道编号 + * @return 频道 DO + */ + ImChannelDO getChannel(Long id); + + /** + * 新增频道 + * + * @param reqVO 新增请求 + * @return 新增频道编号 + */ + Long createChannel(@Valid ImChannelSaveReqVO reqVO); + + /** + * 修改频道 + * + * @param reqVO 修改请求 + */ + void updateChannel(@Valid ImChannelSaveReqVO reqVO); + + /** + * 删除频道;频道下有素材或消息时拒绝 + * + * @param id 频道编号 + */ + void deleteChannel(Long id); + +} 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 new file mode 100644 index 0000000..1c3fe9d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java @@ -0,0 +1,122 @@ +package cn.iocoder.yudao.module.im.service.channel; + +import cn.hutool.core.collection.CollUtil; +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; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO; +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO; +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +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; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_HAS_MATERIAL; +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_NOT_EXISTS; + +/** + * IM 频道 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class ImChannelServiceImpl implements ImChannelService { + + @Resource + private ImChannelMapper channelMapper; + @Resource + private ImChannelMaterialService channelMaterialService; + + // ==================== 用户端 ==================== + + @Override + public List getEnabledChannelList() { + return channelMapper.selectListByStatusOrderBySort(CommonStatusEnum.ENABLE.getStatus()); + } + + @Override + public List getChannelList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return channelMapper.selectByIds(ids); + } + + @Override + public ImChannelDO validateChannelExists(Long id) { + ImChannelDO channel = channelMapper.selectById(id); + if (channel == null) { + throw exception(IM_CHANNEL_NOT_EXISTS); + } + return channel; + } + + // ==================== 管理后台 ==================== + + @Override + public PageResult getChannelPage(ImChannelPageReqVO reqVO) { + return channelMapper.selectPage(reqVO); + } + + @Override + public ImChannelDO getChannel(Long id) { + return channelMapper.selectById(id); + } + + @Override + public Long createChannel(ImChannelSaveReqVO reqVO) { + // 校验 code 唯一 + validateCodeUnique(null, reqVO.getCode()); + + // 插入 + ImChannelDO channel = BeanUtils.toBean(reqVO, ImChannelDO.class); + channelMapper.insert(channel); + return channel.getId(); + } + + @Override + public void updateChannel(ImChannelSaveReqVO reqVO) { + // 1.1 校验存在 + validateChannelExists(reqVO.getId()); + // 1.2 校验 code 唯一 + validateCodeUnique(reqVO.getId(), reqVO.getCode()); + + // 2. 更新 + ImChannelDO updateObj = BeanUtils.toBean(reqVO, ImChannelDO.class); + channelMapper.updateById(updateObj); + } + + @Override + public void deleteChannel(Long id) { + // 1.1 校验存在 + validateChannelExists(id); + // 1.2 防止误删频道导致历史素材 / 消息回查不到归属 + if (channelMaterialService.getMaterialCountByChannelId(id) > 0) { + throw exception(IM_CHANNEL_HAS_MATERIAL); + } + + // 2. 删除频道 + channelMapper.deleteById(id); + } + + private void validateCodeUnique(Long id, String code) { + ImChannelDO exist = channelMapper.selectByCode(code); + if (exist == null) { + return; + } + // TODO @AI:notEquals objutils; + if (id == null || !Objects.equals(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 de84a3e..d45eb9d 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 @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.im.service.websocket; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; @@ -53,4 +54,22 @@ public interface ImWebSocketService { */ void sendGroupMessageAsync(Collection userIds, ImGroupMessageDTO dto); + /** + * 异步推送频道消息给指定用户 + * + * @param userId 目标用户编号 + * @param dto 频道消息 DTO + */ + default void sendChannelMessageAsync(Long userId, ImChannelMessageDTO dto) { + sendChannelMessageAsync(Collections.singleton(userId), dto); + } + + /** + * 异步批量推送频道消息给多个用户 + * + * @param userIds 目标用户编号列表 + * @param dto 频道消息 DTO + */ + void sendChannelMessageAsync(Collection userIds, 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 ef06997..3a9d327 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 @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.websocket; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO; import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; import jakarta.annotation.Resource; @@ -43,6 +44,11 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto)); } + @Override + public void sendChannelMessageAsync(Collection userIds, ImChannelMessageDTO dto) { + executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto)); + } + /** * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 */ @@ -74,6 +80,21 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { } /** + * 异步发送频道 WebSocket 消息 + */ + @Async + public void doSendChannelMessage(Collection userIds, ImChannelMessageDTO dto) { + for (Long userId : userIds) { + try { + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId, + ImChannelMessageDTO.TYPE, dto); + } catch (Exception e) { + log.error("[doSendChannelMessage][userId({}) dto({}) 发送失败]", userId, dto, e); + } + } + } + + /** * 事务感知的任务调度: * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 * - 无事务:直接执行(如非 @Transactional 方法中的调用) diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java new file mode 100644 index 0000000..15bf71d --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java @@ -0,0 +1,56 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto; + +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.time.LocalDateTime; + +/** + * IM 频道消息 WebSocket 推送 DTO + *

+ * 单向:服务端运营推送 → C 端用户;C 端不能向频道发消息。 + * 字段分层:顶层是消息元数据 + 检索维度,content 是 MaterialMessage payload 的 JSON 串。 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class ImChannelMessageDTO { + + public static final String TYPE = "im-channel-message"; + + /** + * 消息编号 + */ + private Long id; + /** + * 频道编号 + */ + private Long channelId; + /** + * 关联素材编号 + */ + private Long materialId; + /** + * 消息类型 + */ + private Integer type; + /** + * 消息内容;payload JSON 串 + */ + private String content; + /** + * 发送时间 + */ + private LocalDateTime sendTime; + + /** + * 由频道消息 DO 构建推送 DTO + */ + public static ImChannelMessageDTO ofSend(ImChannelMessageDO message) { + return BeanUtils.toBean(message, ImChannelMessageDTO.class); + } + +} diff --git a/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java new file mode 100644 index 0000000..a7c835c --- /dev/null +++ b/yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java @@ -0,0 +1,34 @@ +package cn.iocoder.yudao.module.im.service.websocket.dto.message; + +import lombok.Data; +import lombok.experimental.Accessors; + +/** + * 频道素材消息 payload + *

+ * 对应 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum#MATERIAL}(type=125)。 + * 客户端按本字段集渲染图文卡片; + * 富文本正文不在本 payload 中传递,点击详情时另调 /im/channel/material/get-content?id= 按需拉取。 + */ +@Data +@Accessors(chain = true) +public class MaterialMessage { + + /** + * 标题 + */ + private String title; + /** + * 封面图 + */ + private String coverUrl; + /** + * 摘要 + */ + private String summary; + /** + * 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url + */ + private String url; + +} diff --git a/yudao-module-im/src/test/resources/sql/create_tables.sql b/yudao-module-im/src/test/resources/sql/create_tables.sql index f43a56a..2822bcf 100644 --- a/yudao-module-im/src/test/resources/sql/create_tables.sql +++ b/yudao-module-im/src/test/resources/sql/create_tables.sql @@ -236,6 +236,57 @@ CREATE TABLE IF NOT EXISTS "im_face_user_item" ( PRIMARY KEY ("id") ) COMMENT 'IM 用户私有表情表'; +CREATE TABLE IF NOT EXISTS "im_channel" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "code" varchar(64) NOT NULL COMMENT '频道业务码;唯一', + "name" varchar(64) NOT NULL COMMENT '频道名称', + "avatar" varchar(512) DEFAULT NULL COMMENT '频道头像', + "sort" int NOT NULL DEFAULT 0 COMMENT '排序', + "status" tinyint NOT NULL DEFAULT 0 COMMENT '状态;0 启用 1 停用', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 频道表'; + +CREATE TABLE IF NOT EXISTS "im_channel_material" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "channel_id" bigint NOT NULL COMMENT '频道编号', + "type" smallint NOT NULL COMMENT '素材类型;对应 ImMessageTypeEnum', + "title" varchar(128) NOT NULL COMMENT '标题', + "cover_url" varchar(512) DEFAULT NULL COMMENT '封面图', + "summary" varchar(255) DEFAULT NULL COMMENT '摘要', + "content" clob DEFAULT NULL COMMENT '正文;富文本 HTML', + "url" varchar(512) DEFAULT NULL COMMENT '跳转链接;为空时点击在客户端内置详情页拉 content;非空则跳 url', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 频道素材表'; + +CREATE TABLE IF NOT EXISTS "im_channel_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号', + "channel_id" bigint NOT NULL COMMENT '频道编号;冗余 im_channel_material.channel_id 便于检索', + "material_id" bigint NOT NULL COMMENT '关联素材编号', + "type" smallint NOT NULL COMMENT '消息类型', + "content" varchar(8192) DEFAULT NULL COMMENT '消息内容;推送时 payload JSON 快照;不含富文本正文', + "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '接收人编号列表;逗号分隔;为空表示全员', + "send_time" timestamp NOT NULL COMMENT '发送时间', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +) COMMENT 'IM 频道消息表'; + CREATE TABLE IF NOT EXISTS "system_users" ( "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, "username" varchar(30) NOT NULL DEFAULT '',