Commit 611880a3c40266d402702b7489d76b61fa712ccb

Authored by YunaiV
1 parent 6b1a0cfc

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

Showing 32 changed files with 1690 additions and 1 deletions
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelManagerController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  4 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  5 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO;
  7 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelRespVO;
  8 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  10 +import cn.iocoder.yudao.module.im.service.channel.ImChannelService;
  11 +import io.swagger.v3.oas.annotations.Operation;
  12 +import io.swagger.v3.oas.annotations.Parameter;
  13 +import io.swagger.v3.oas.annotations.tags.Tag;
  14 +import jakarta.annotation.Resource;
  15 +import jakarta.validation.Valid;
  16 +import org.springframework.security.access.prepost.PreAuthorize;
  17 +import org.springframework.validation.annotation.Validated;
  18 +import org.springframework.web.bind.annotation.*;
  19 +
  20 +import java.util.List;
  21 +
  22 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  23 +
  24 +@Tag(name = "管理后台 - IM 频道")
  25 +@RestController
  26 +@RequestMapping("/im/manager/channel")
  27 +@Validated
  28 +public class ImChannelManagerController {
  29 +
  30 + @Resource
  31 + private ImChannelService channelService;
  32 +
  33 + @PostMapping("/create")
  34 + @Operation(summary = "新增频道")
  35 + @PreAuthorize("@ss.hasPermission('im:manager:channel:create')")
  36 + public CommonResult<Long> createChannel(@Valid @RequestBody ImChannelSaveReqVO reqVO) {
  37 + return success(channelService.createChannel(reqVO));
  38 + }
  39 +
  40 + @PutMapping("/update")
  41 + @Operation(summary = "修改频道")
  42 + @PreAuthorize("@ss.hasPermission('im:manager:channel:update')")
  43 + public CommonResult<Boolean> updateChannel(@Valid @RequestBody ImChannelSaveReqVO reqVO) {
  44 + channelService.updateChannel(reqVO);
  45 + return success(true);
  46 + }
  47 +
  48 + @DeleteMapping("/delete")
  49 + @Operation(summary = "删除频道")
  50 + @Parameter(name = "id", description = "编号", required = true, example = "1024")
  51 + @PreAuthorize("@ss.hasPermission('im:manager:channel:delete')")
  52 + public CommonResult<Boolean> deleteChannel(@RequestParam("id") Long id) {
  53 + channelService.deleteChannel(id);
  54 + return success(true);
  55 + }
  56 +
  57 + @GetMapping("/page")
  58 + @Operation(summary = "获得频道分页")
  59 + @PreAuthorize("@ss.hasPermission('im:manager:channel:query')")
  60 + public CommonResult<PageResult<ImChannelRespVO>> getChannelPage(@Valid ImChannelPageReqVO pageReqVO) {
  61 + PageResult<ImChannelDO> pageResult = channelService.getChannelPage(pageReqVO);
  62 + return success(BeanUtils.toBean(pageResult, ImChannelRespVO.class));
  63 + }
  64 +
  65 + @GetMapping("/get")
  66 + @Operation(summary = "获得频道详情")
  67 + @Parameter(name = "id", description = "编号", required = true, example = "1024")
  68 + @PreAuthorize("@ss.hasPermission('im:manager:channel:query')")
  69 + public CommonResult<ImChannelRespVO> getChannel(@RequestParam("id") Long id) {
  70 + ImChannelDO channel = channelService.getChannel(id);
  71 + return success(BeanUtils.toBean(channel, ImChannelRespVO.class));
  72 + }
  73 +
  74 + @GetMapping("/simple-list")
  75 + @Operation(summary = "获得启用的频道精简列表;前端表单选择频道时调用")
  76 + public CommonResult<List<ImChannelRespVO>> getEnabledChannelList() {
  77 + // TODO @AI:getChannelListByXXX;更通用点;
  78 + List<ImChannelDO> list = channelService.getEnabledChannelList();
  79 + return success(BeanUtils.toBean(list, ImChannelRespVO.class));
  80 + }
  81 +
  82 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMaterialManagerController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  5 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  6 +import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
  7 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  8 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO;
  9 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialRespVO;
  10 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO;
  11 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  12 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  13 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
  14 +import cn.iocoder.yudao.module.im.service.channel.ImChannelService;
  15 +import io.swagger.v3.oas.annotations.Operation;
  16 +import io.swagger.v3.oas.annotations.Parameter;
  17 +import io.swagger.v3.oas.annotations.tags.Tag;
  18 +import jakarta.annotation.Resource;
  19 +import jakarta.validation.Valid;
  20 +import org.springframework.security.access.prepost.PreAuthorize;
  21 +import org.springframework.validation.annotation.Validated;
  22 +import org.springframework.web.bind.annotation.*;
  23 +
  24 +import java.util.List;
  25 +import java.util.Map;
  26 +
  27 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  28 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
  29 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  30 +
  31 +@Tag(name = "管理后台 - IM 频道素材")
  32 +@RestController
  33 +@RequestMapping("/im/manager/channel-material")
  34 +@Validated
  35 +public class ImChannelMaterialManagerController {
  36 +
  37 + @Resource
  38 + private ImChannelMaterialService channelMaterialService;
  39 + @Resource
  40 + private ImChannelService channelService;
  41 +
  42 + @PostMapping("/create")
  43 + @Operation(summary = "新增素材")
  44 + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:create')")
  45 + public CommonResult<Long> createMaterial(@Valid @RequestBody ImChannelMaterialSaveReqVO reqVO) {
  46 + return success(channelMaterialService.createMaterial(reqVO));
  47 + }
  48 +
  49 + @PutMapping("/update")
  50 + @Operation(summary = "修改素材")
  51 + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:update')")
  52 + public CommonResult<Boolean> updateMaterial(@Valid @RequestBody ImChannelMaterialSaveReqVO reqVO) {
  53 + channelMaterialService.updateMaterial(reqVO);
  54 + return success(true);
  55 + }
  56 +
  57 + @DeleteMapping("/delete")
  58 + @Operation(summary = "删除素材")
  59 + @Parameter(name = "id", description = "编号", required = true, example = "1024")
  60 + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:delete')")
  61 + public CommonResult<Boolean> deleteMaterial(@RequestParam("id") Long id) {
  62 + channelMaterialService.deleteMaterial(id);
  63 + return success(true);
  64 + }
  65 +
  66 + @GetMapping("/page")
  67 + @Operation(summary = "获得素材分页;含频道名回填")
  68 + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:query')")
  69 + public CommonResult<PageResult<ImChannelMaterialRespVO>> getMaterialPage(@Valid ImChannelMaterialPageReqVO pageReqVO) {
  70 + PageResult<ImChannelMaterialDO> pageResult = channelMaterialService.getMaterialPage(pageReqVO);
  71 + if (CollUtil.isEmpty(pageResult.getList())) {
  72 + return success(PageResult.empty(pageResult.getTotal()));
  73 + }
  74 + // 回填频道名
  75 + List<ImChannelDO> channels = channelService.getChannelList(
  76 + convertSet(pageResult.getList(), ImChannelMaterialDO::getChannelId));
  77 + Map<Long, ImChannelDO> channelMap = convertMap(channels, ImChannelDO::getId);
  78 + return success(BeanUtils.toBean(pageResult, ImChannelMaterialRespVO.class, vo ->
  79 + MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName()))));
  80 + }
  81 +
  82 + @GetMapping("/get")
  83 + @Operation(summary = "获得素材详情(含富文本正文)")
  84 + @Parameter(name = "id", description = "编号", required = true, example = "1024")
  85 + @PreAuthorize("@ss.hasPermission('im:manager:channel-material:query')")
  86 + public CommonResult<ImChannelMaterialRespVO> getMaterial(@RequestParam("id") Long id) {
  87 + ImChannelMaterialDO material = channelMaterialService.getMaterial(id);
  88 + return success(BeanUtils.toBean(material, ImChannelMaterialRespVO.class));
  89 + }
  90 +
  91 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/ImChannelMessageManagerController.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.pojo.CommonResult;
  5 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  6 +import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
  7 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  8 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO;
  9 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageRespVO;
  10 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO;
  11 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  12 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  13 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO;
  14 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
  15 +import cn.iocoder.yudao.module.im.service.channel.ImChannelMessageService;
  16 +import cn.iocoder.yudao.module.im.service.channel.ImChannelService;
  17 +import io.swagger.v3.oas.annotations.Operation;
  18 +import io.swagger.v3.oas.annotations.Parameter;
  19 +import io.swagger.v3.oas.annotations.tags.Tag;
  20 +import jakarta.annotation.Resource;
  21 +import jakarta.validation.Valid;
  22 +import org.springframework.security.access.prepost.PreAuthorize;
  23 +import org.springframework.validation.annotation.Validated;
  24 +import org.springframework.web.bind.annotation.*;
  25 +
  26 +import java.util.Map;
  27 +
  28 +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  29 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
  30 +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
  31 +
  32 +@Tag(name = "管理后台 - IM 频道消息")
  33 +@RestController
  34 +@RequestMapping("/im/manager/channel-message")
  35 +@Validated
  36 +public class ImChannelMessageManagerController {
  37 +
  38 + @Resource
  39 + private ImChannelMessageService channelMessageService;
  40 + @Resource
  41 + private ImChannelService channelService;
  42 + @Resource
  43 + private ImChannelMaterialService channelMaterialService;
  44 +
  45 + @PostMapping("/send")
  46 + @Operation(summary = "立即推送频道消息")
  47 + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:send')")
  48 + public CommonResult<Long> sendMessage(@Valid @RequestBody ImChannelMessageSendReqVO reqVO) {
  49 + return success(channelMessageService.sendMessage(reqVO));
  50 + }
  51 +
  52 + @DeleteMapping("/delete")
  53 + @Operation(summary = "删除频道消息")
  54 + @Parameter(name = "id", description = "编号", required = true, example = "1024")
  55 + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:delete')")
  56 + public CommonResult<Boolean> deleteMessage(@RequestParam("id") Long id) {
  57 + channelMessageService.deleteMessage(id);
  58 + return success(true);
  59 + }
  60 +
  61 + @GetMapping("/page")
  62 + @Operation(summary = "获得频道消息分页;回填频道名 / 素材标题")
  63 + @PreAuthorize("@ss.hasPermission('im:manager:channel-message:query')")
  64 + public CommonResult<PageResult<ImChannelMessageRespVO>> getMessagePage(@Valid ImChannelMessagePageReqVO pageReqVO) {
  65 + PageResult<ImChannelMessageDO> pageResult = channelMessageService.getMessagePage(pageReqVO);
  66 + if (CollUtil.isEmpty(pageResult.getList())) {
  67 + return success(PageResult.empty(pageResult.getTotal()));
  68 + }
  69 + // TODO @AI:最好是 channelService 也包出一个 getChannelMap;类似别的模块
  70 + Map<Long, ImChannelDO> channelMap = convertMap(
  71 + channelService.getChannelList(convertSet(pageResult.getList(), ImChannelMessageDO::getChannelId)),
  72 + ImChannelDO::getId);
  73 + // TODO @AI:最好是 channelService 也包出一个 getChannelMap;类似别的模块
  74 + Map<Long, ImChannelMaterialDO> materialMap = convertMap(
  75 + channelMaterialService.getMaterialList(convertSet(pageResult.getList(), ImChannelMessageDO::getMaterialId)),
  76 + ImChannelMaterialDO::getId);
  77 + return success(BeanUtils.toBean(pageResult, ImChannelMessageRespVO.class, vo -> {
  78 + MapUtils.findAndThen(channelMap, vo.getChannelId(), c -> vo.setChannelName(c.getName()));
  79 + MapUtils.findAndThen(materialMap, vo.getMaterialId(), m -> vo.setMaterialTitle(m.getTitle()));
  80 + }));
  81 + }
  82 +
  83 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelPageReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageParam;
  4 +import io.swagger.v3.oas.annotations.media.Schema;
  5 +import lombok.Data;
  6 +import lombok.EqualsAndHashCode;
  7 +
  8 +@Schema(description = "管理后台 - IM 频道分页 Request VO")
  9 +@Data
  10 +@EqualsAndHashCode(callSuper = true)
  11 +public class ImChannelPageReqVO extends PageParam {
  12 +
  13 + @Schema(description = "频道业务码", example = "system_notice")
  14 + private String code;
  15 +
  16 + @Schema(description = "频道名称", example = "系统")
  17 + private String name;
  18 +
  19 + @Schema(description = "状态", example = "0")
  20 + private Integer status; // 参见 CommonStatusEnum 枚举类
  21 +
  22 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +@Schema(description = "管理后台 - IM 频道 Response VO")
  9 +@Data
  10 +public class ImChannelRespVO {
  11 +
  12 + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  13 + private Long id;
  14 +
  15 + @Schema(description = "频道业务码", requiredMode = Schema.RequiredMode.REQUIRED, example = "system_notice")
  16 + private String code;
  17 +
  18 + @Schema(description = "频道名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统公告")
  19 + private String name;
  20 +
  21 + @Schema(description = "频道头像")
  22 + private String avatar;
  23 +
  24 + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  25 + private Integer sort;
  26 +
  27 + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  28 + private Integer status; // 参见 CommonStatusEnum 枚举类
  29 +
  30 + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
  31 + private LocalDateTime createTime;
  32 +
  33 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/channel/ImChannelSaveReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import jakarta.validation.constraints.Pattern;
  7 +import jakarta.validation.constraints.Size;
  8 +import lombok.Data;
  9 +
  10 +@Schema(description = "管理后台 - IM 频道新增 / 修改 Request VO")
  11 +@Data
  12 +public class ImChannelSaveReqVO {
  13 +
  14 + @Schema(description = "编号(修改时必填)", example = "1024")
  15 + private Long id;
  16 +
  17 + @Schema(description = "频道业务码;唯一", requiredMode = Schema.RequiredMode.REQUIRED, example = "system_notice")
  18 + @NotBlank(message = "频道编码不能为空")
  19 + @Size(max = 64, message = "频道编码长度不能超过 64")
  20 + @Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "频道编码只能由小写字母 / 数字 / 下划线组成,且必须以字母开头")
  21 + private String code;
  22 +
  23 + @Schema(description = "频道名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统公告")
  24 + @NotBlank(message = "频道名称不能为空")
  25 + @Size(max = 64, message = "频道名称长度不能超过 64")
  26 + private String name;
  27 +
  28 + @Schema(description = "频道头像", example = "https://cdn.example.com/channel/system_notice.png")
  29 + @Size(max = 512, message = "头像长度不能超过 512")
  30 + private String avatar;
  31 +
  32 + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  33 + @NotNull(message = "排序不能为空")
  34 + private Integer sort;
  35 +
  36 + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
  37 + @NotNull(message = "状态不能为空")
  38 + private Integer status; // 参见 CommonStatusEnum 枚举类(0 启用 / 1 禁用)
  39 +
  40 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialPageReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageParam;
  4 +import io.swagger.v3.oas.annotations.media.Schema;
  5 +import lombok.Data;
  6 +import lombok.EqualsAndHashCode;
  7 +import org.springframework.format.annotation.DateTimeFormat;
  8 +
  9 +import java.time.LocalDateTime;
  10 +
  11 +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
  12 +
  13 +@Schema(description = "管理后台 - IM 频道素材分页 Request VO")
  14 +@Data
  15 +@EqualsAndHashCode(callSuper = true)
  16 +public class ImChannelMaterialPageReqVO extends PageParam {
  17 +
  18 + @Schema(description = "频道编号", example = "1")
  19 + private Long channelId;
  20 +
  21 + @Schema(description = "素材类型", example = "125")
  22 + private Integer type;
  23 +
  24 + @Schema(description = "标题", example = "活动")
  25 + private String title;
  26 +
  27 + @Schema(description = "创建时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
  28 + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
  29 + private LocalDateTime[] createTime;
  30 +
  31 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +
  8 +@Schema(description = "管理后台 - IM 频道素材 Response VO")
  9 +@Data
  10 +public class ImChannelMaterialRespVO {
  11 +
  12 + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  13 + private Long id;
  14 +
  15 + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  16 + private Long channelId;
  17 +
  18 + @Schema(description = "频道名称(关联查询填充)")
  19 + private String channelName;
  20 +
  21 + @Schema(description = "素材类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125")
  22 + private Integer type; // 参见 ImMessageTypeEnum 枚举类
  23 +
  24 + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED)
  25 + private String title;
  26 +
  27 + @Schema(description = "封面图")
  28 + private String coverUrl;
  29 +
  30 + @Schema(description = "摘要")
  31 + private String summary;
  32 +
  33 + @Schema(description = "正文;富文本 HTML")
  34 + private String content;
  35 +
  36 + @Schema(description = "跳转链接")
  37 + private String url;
  38 +
  39 + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
  40 + private LocalDateTime createTime;
  41 +
  42 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/material/ImChannelMaterialSaveReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotBlank;
  5 +import jakarta.validation.constraints.NotNull;
  6 +import jakarta.validation.constraints.Size;
  7 +import lombok.Data;
  8 +
  9 +@Schema(description = "管理后台 - IM 频道素材新增 / 修改 Request VO")
  10 +@Data
  11 +public class ImChannelMaterialSaveReqVO {
  12 +
  13 + @Schema(description = "编号(修改时必填)", example = "1024")
  14 + private Long id;
  15 +
  16 + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  17 + @NotNull(message = "频道编号不能为空")
  18 + private Long channelId;
  19 +
  20 + @Schema(description = "素材类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125")
  21 + @NotNull(message = "素材类型不能为空")
  22 + private Integer type; // 参见 ImMessageTypeEnum 枚举类
  23 +
  24 + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "双十一活动来啦")
  25 + @NotBlank(message = "标题不能为空")
  26 + @Size(max = 128, message = "标题长度不能超过 128")
  27 + private String title;
  28 +
  29 + @Schema(description = "封面图", example = "https://cdn.example.com/cover.png")
  30 + @Size(max = 512, message = "封面图长度不能超过 512")
  31 + private String coverUrl;
  32 +
  33 + @Schema(description = "摘要", example = "全场五折,戳详情看玩法")
  34 + @Size(max = 255, message = "摘要长度不能超过 255")
  35 + private String summary;
  36 +
  37 + @Schema(description = "正文;富文本 HTML")
  38 + private String content;
  39 +
  40 + @Schema(description = "跳转链接;为空表示走客户端内置详情页", example = "https://example.com/activity/123")
  41 + @Size(max = 512, message = "跳转链接长度不能超过 512")
  42 + private String url;
  43 +
  44 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessagePageReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageParam;
  4 +import io.swagger.v3.oas.annotations.media.Schema;
  5 +import lombok.Data;
  6 +import lombok.EqualsAndHashCode;
  7 +import org.springframework.format.annotation.DateTimeFormat;
  8 +
  9 +import java.time.LocalDateTime;
  10 +
  11 +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
  12 +
  13 +@Schema(description = "管理后台 - IM 频道消息分页 Request VO")
  14 +@Data
  15 +@EqualsAndHashCode(callSuper = true)
  16 +public class ImChannelMessagePageReqVO extends PageParam {
  17 +
  18 + @Schema(description = "频道编号", example = "1")
  19 + private Long channelId;
  20 +
  21 + @Schema(description = "素材编号", example = "1024")
  22 + private Long materialId;
  23 +
  24 + @Schema(description = "发送时间", example = "[2026-04-01 00:00:00, 2026-04-30 23:59:59]")
  25 + @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
  26 + private LocalDateTime[] sendTime;
  27 +
  28 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageRespVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import lombok.Data;
  5 +
  6 +import java.time.LocalDateTime;
  7 +import java.util.List;
  8 +
  9 +@Schema(description = "管理后台 - IM 频道消息 Response VO")
  10 +@Data
  11 +public class ImChannelMessageRespVO {
  12 +
  13 + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  14 + private Long id;
  15 +
  16 + @Schema(description = "频道编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
  17 + private Long channelId;
  18 +
  19 + @Schema(description = "频道名称(关联查询填充)")
  20 + private String channelName;
  21 +
  22 + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  23 + private Long materialId;
  24 +
  25 + @Schema(description = "素材标题(关联查询填充)")
  26 + private String materialTitle;
  27 +
  28 + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125")
  29 + private Integer type; // 参见 ImMessageTypeEnum 枚举类
  30 +
  31 + @Schema(description = "消息内容;payload JSON 快照")
  32 + private String content;
  33 +
  34 + @Schema(description = "接收人编号列表;为空表示全员")
  35 + private List<Long> receiverUserIds;
  36 +
  37 + @Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
  38 + private LocalDateTime sendTime;
  39 +
  40 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/controller/admin/manager/channel/vo/message/ImChannelMessageSendReqVO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message;
  2 +
  3 +import io.swagger.v3.oas.annotations.media.Schema;
  4 +import jakarta.validation.constraints.NotNull;
  5 +import lombok.Data;
  6 +
  7 +import java.util.List;
  8 +
  9 +@Schema(description = "管理后台 - IM 频道消息推送 Request VO")
  10 +@Data
  11 +public class ImChannelMessageSendReqVO {
  12 +
  13 + @Schema(description = "素材编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
  14 + @NotNull(message = "素材编号不能为空")
  15 + private Long materialId;
  16 +
  17 + @Schema(description = "接收人编号列表;为空表示全员", example = "[1024, 2048]")
  18 + private List<Long> receiverUserIds;
  19 +
  20 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  4 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  5 +import com.baomidou.mybatisplus.annotation.KeySequence;
  6 +import com.baomidou.mybatisplus.annotation.TableId;
  7 +import com.baomidou.mybatisplus.annotation.TableName;
  8 +import lombok.*;
  9 +
  10 +/**
  11 + * IM 频道 DO
  12 + * <p>
  13 + * 业务语义:
  14 + * - 频道是运营单向推送的主体;C 端用户不能向频道发消息
  15 + * - {@link #code} 是业务码(API / 字典外露),id 是数字主键给前端会话 targetId 用
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@TableName("im_channel")
  20 +@KeySequence("im_channel_seq")
  21 +@Data
  22 +@EqualsAndHashCode(callSuper = true)
  23 +@ToString(callSuper = true)
  24 +@Builder
  25 +@NoArgsConstructor
  26 +@AllArgsConstructor
  27 +public class ImChannelDO extends BaseDO {
  28 +
  29 + /**
  30 + * 编号
  31 + */
  32 + @TableId
  33 + private Long id;
  34 + /**
  35 + * 频道业务码
  36 + */
  37 + private String code;
  38 + /**
  39 + * 频道名称
  40 + */
  41 + private String name;
  42 + /**
  43 + * 频道头像
  44 + */
  45 + private String avatar;
  46 + /**
  47 + * 排序
  48 + */
  49 + private Integer sort;
  50 + /**
  51 + * 状态
  52 + * <p>
  53 + * 枚举 {@link CommonStatusEnum}
  54 + */
  55 + private Integer status;
  56 +
  57 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMaterialDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  5 +import com.baomidou.mybatisplus.annotation.KeySequence;
  6 +import com.baomidou.mybatisplus.annotation.TableId;
  7 +import com.baomidou.mybatisplus.annotation.TableName;
  8 +import lombok.*;
  9 +
  10 +/**
  11 + * IM 频道素材 DO
  12 + * <p>
  13 + * 业务语义:
  14 + * - 运营素材库,可被反复推送
  15 + * - 一条素材 1:N 关联多条 {@link ImChannelMessageDO}
  16 + * - {@link #content} 富文本仅在素材详情接口按需返回,推送 payload 不带,避免压爆 WebSocket 通道
  17 + *
  18 + * @author 芋道源码
  19 + */
  20 +@TableName("im_channel_material")
  21 +@KeySequence("im_channel_material_seq")
  22 +@Data
  23 +@EqualsAndHashCode(callSuper = true)
  24 +@ToString(callSuper = true)
  25 +@Builder
  26 +@NoArgsConstructor
  27 +@AllArgsConstructor
  28 +public class ImChannelMaterialDO extends BaseDO {
  29 +
  30 + /**
  31 + * 编号
  32 + */
  33 + @TableId
  34 + private Long id;
  35 + /**
  36 + * 频道编号
  37 + * <p>
  38 + * 关联 {@link ImChannelDO#getId()}
  39 + */
  40 + private Long channelId;
  41 + /**
  42 + * 素材类型
  43 + * <p>
  44 + * 枚举 {@link ImMessageTypeEnum};当前仅 MATERIAL(125)
  45 + */
  46 + private Integer type;
  47 + /**
  48 + * 标题
  49 + */
  50 + private String title;
  51 + /**
  52 + * 封面图
  53 + */
  54 + private String coverUrl;
  55 + /**
  56 + * 摘要
  57 + */
  58 + private String summary;
  59 + // TODO @AI:素材类型,是不是有个 type;这样一个 content、url 更好区分;然后这个 type ,是不是在 MATERIAL Message 类上,也带上;
  60 + /**
  61 + * 正文;富文本 HTML
  62 + */
  63 + private String content;
  64 + /**
  65 + * 跳转链接
  66 + * <p>
  67 + * 为空时点击在客户端内置详情页拉 {@link #content} 渲染;非空则跳 url
  68 + */
  69 + private String url;
  70 +
  71 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/dataobject/channel/ImChannelMessageDO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.dataobject.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
  4 +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
  5 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  6 +import com.baomidou.mybatisplus.annotation.KeySequence;
  7 +import com.baomidou.mybatisplus.annotation.TableField;
  8 +import com.baomidou.mybatisplus.annotation.TableId;
  9 +import com.baomidou.mybatisplus.annotation.TableName;
  10 +import lombok.*;
  11 +
  12 +import java.time.LocalDateTime;
  13 +import java.util.List;
  14 +
  15 +/**
  16 + * IM 频道消息 DO
  17 + * <p>
  18 + * 业务语义:
  19 + * - 一次推送 1 行;{@link #receiverUserIds} 为空表示全员
  20 + * - {@link #channelId} 冗余 {@link ImChannelMaterialDO#getChannelId()} 便于按频道检索
  21 + * - {@link #content} 存推送时 payload 的 JSON 快照(title / coverUrl / summary / url);不含富文本正文
  22 + *
  23 + * @author 芋道源码
  24 + */
  25 +@TableName(value = "im_channel_message", autoResultMap = true)
  26 +@KeySequence("im_channel_message_seq")
  27 +@Data
  28 +@EqualsAndHashCode(callSuper = true)
  29 +@ToString(callSuper = true)
  30 +@Builder
  31 +@NoArgsConstructor
  32 +@AllArgsConstructor
  33 +public class ImChannelMessageDO extends BaseDO {
  34 +
  35 + /**
  36 + * 编号
  37 + */
  38 + @TableId
  39 + private Long id;
  40 + /**
  41 + * 频道编号
  42 + * <p>
  43 + * 关联 {@link ImChannelDO#getId()};冗余便于按频道检索
  44 + */
  45 + private Long channelId;
  46 + /**
  47 + * 关联素材编号
  48 + * <p>
  49 + * 关联 {@link ImChannelMaterialDO#getId()}
  50 + */
  51 + private Long materialId;
  52 + /**
  53 + * 消息类型
  54 + * <p>
  55 + * 枚举 {@link ImMessageTypeEnum}
  56 + */
  57 + private Integer type;
  58 + /**
  59 + * 消息内容;推送时 payload 的 JSON 快照
  60 + */
  61 + private String content;
  62 + /**
  63 + * 接收人编号列表;为空表示全员
  64 + */
  65 + @TableField(typeHandler = LongListTypeHandler.class)
  66 + private List<Long> receiverUserIds;
  67 + /**
  68 + * 发送时间
  69 + */
  70 + private LocalDateTime sendTime;
  71 +
  72 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  5 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  8 +import org.apache.ibatis.annotations.Mapper;
  9 +
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * IM 频道 Mapper
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +@Mapper
  18 +public interface ImChannelMapper extends BaseMapperX<ImChannelDO> {
  19 +
  20 + default ImChannelDO selectByCode(String code) {
  21 + return selectOne(ImChannelDO::getCode, code);
  22 + }
  23 +
  24 + default List<ImChannelDO> selectListByStatusOrderBySort(Integer status) {
  25 + return selectList(new LambdaQueryWrapperX<ImChannelDO>()
  26 + .eq(ImChannelDO::getStatus, status)
  27 + .orderByAsc(ImChannelDO::getSort));
  28 + }
  29 +
  30 + default PageResult<ImChannelDO> selectPage(ImChannelPageReqVO reqVO) {
  31 + return selectPage(reqVO, new LambdaQueryWrapperX<ImChannelDO>()
  32 + .likeIfPresent(ImChannelDO::getCode, reqVO.getCode())
  33 + .likeIfPresent(ImChannelDO::getName, reqVO.getName())
  34 + .eqIfPresent(ImChannelDO::getStatus, reqVO.getStatus())
  35 + .orderByAsc(ImChannelDO::getSort)
  36 + .orderByDesc(ImChannelDO::getId));
  37 + }
  38 +
  39 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMaterialMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  5 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  8 +import org.apache.ibatis.annotations.Mapper;
  9 +
  10 +/**
  11 + * IM 频道素材 Mapper
  12 + *
  13 + * @author 芋道源码
  14 + */
  15 +@Mapper
  16 +public interface ImChannelMaterialMapper extends BaseMapperX<ImChannelMaterialDO> {
  17 +
  18 + default Long selectCountByChannelId(Long channelId) {
  19 + return selectCount(ImChannelMaterialDO::getChannelId, channelId);
  20 + }
  21 +
  22 + default PageResult<ImChannelMaterialDO> selectPage(ImChannelMaterialPageReqVO reqVO) {
  23 + LambdaQueryWrapperX<ImChannelMaterialDO> wrapper = new LambdaQueryWrapperX<ImChannelMaterialDO>()
  24 + .eqIfPresent(ImChannelMaterialDO::getChannelId, reqVO.getChannelId())
  25 + .eqIfPresent(ImChannelMaterialDO::getType, reqVO.getType())
  26 + .likeIfPresent(ImChannelMaterialDO::getTitle, reqVO.getTitle())
  27 + .betweenIfPresent(ImChannelMaterialDO::getCreateTime, reqVO.getCreateTime())
  28 + .orderByDesc(ImChannelMaterialDO::getId);
  29 + // TODO @AI:不用排除;列表问题不大的;
  30 + // 列表查询排除富文本 content 字段,避免 mediumtext 拖垮分页响应
  31 + wrapper.select(ImChannelMaterialDO::getId, ImChannelMaterialDO::getChannelId,
  32 + ImChannelMaterialDO::getType, ImChannelMaterialDO::getTitle,
  33 + ImChannelMaterialDO::getCoverUrl, ImChannelMaterialDO::getSummary,
  34 + ImChannelMaterialDO::getUrl, ImChannelMaterialDO::getCreator,
  35 + ImChannelMaterialDO::getCreateTime, ImChannelMaterialDO::getUpdater,
  36 + ImChannelMaterialDO::getUpdateTime, ImChannelMaterialDO::getDeleted);
  37 + return selectPage(reqVO, wrapper);
  38 + }
  39 +
  40 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/dal/mysql/channel/ImChannelMessageMapper.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.dal.mysql.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
  5 +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO;
  7 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO;
  8 +import org.apache.ibatis.annotations.Mapper;
  9 +import org.apache.ibatis.annotations.Param;
  10 +import org.apache.ibatis.annotations.Select;
  11 +
  12 +import java.util.List;
  13 +
  14 +/**
  15 + * IM 频道消息 Mapper
  16 + *
  17 + * @author 芋道源码
  18 + */
  19 +@Mapper
  20 +public interface ImChannelMessageMapper extends BaseMapperX<ImChannelMessageDO> {
  21 +
  22 + // TODO @AI:代码风格,和别的 message 一致;
  23 + /**
  24 + * 拉取指定用户应收的频道消息
  25 + * <p>
  26 + * 命中条件:id 大于游标 + (receiver_user_ids 为空表示全员 OR 逗号分隔列表里包含当前 userId)
  27 + *
  28 + * @param userId 当前用户编号
  29 + * @param minId 游标;返回大于此值的消息
  30 + * @param size 返回条数
  31 + * @return 频道消息列表;按 id 升序
  32 + */
  33 + @Select("SELECT * FROM im_channel_message "
  34 + + "WHERE deleted = 0 "
  35 + + "AND id > #{minId} "
  36 + + "AND (receiver_user_ids IS NULL "
  37 + + " OR receiver_user_ids = '' "
  38 + + " OR FIND_IN_SET(#{userId}, receiver_user_ids)) "
  39 + + "ORDER BY id ASC "
  40 + + "LIMIT #{size}")
  41 + List<ImChannelMessageDO> selectListByUserAndMinId(@Param("userId") Long userId,
  42 + @Param("minId") Long minId,
  43 + @Param("size") Integer size);
  44 +
  45 + // TODO @AI:代码风格,和别的 message 一致;
  46 + /**
  47 + * 校验用户是否曾经收到过指定素材(receiver_user_ids 为空表示全员);用于详情接口归属校验
  48 + */
  49 + @Select("SELECT EXISTS(SELECT 1 FROM im_channel_message "
  50 + + "WHERE deleted = 0 AND material_id = #{materialId} "
  51 + + "AND (receiver_user_ids IS NULL "
  52 + + " OR receiver_user_ids = '' "
  53 + + " OR FIND_IN_SET(#{userId}, receiver_user_ids)) "
  54 + + "LIMIT 1)")
  55 + boolean existsByUserAndMaterial(@Param("userId") Long userId,
  56 + @Param("materialId") Long materialId);
  57 +
  58 + default Long selectCountByMaterialId(Long materialId) {
  59 + return selectCount(ImChannelMessageDO::getMaterialId, materialId);
  60 + }
  61 +
  62 + default PageResult<ImChannelMessageDO> selectPage(ImChannelMessagePageReqVO reqVO) {
  63 + return selectPage(reqVO, new LambdaQueryWrapperX<ImChannelMessageDO>()
  64 + .eqIfPresent(ImChannelMessageDO::getChannelId, reqVO.getChannelId())
  65 + .eqIfPresent(ImChannelMessageDO::getMaterialId, reqVO.getMaterialId())
  66 + .betweenIfPresent(ImChannelMessageDO::getSendTime, reqVO.getSendTime())
  67 + .orderByDesc(ImChannelMessageDO::getId));
  68 + }
  69 +
  70 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ErrorCodeConstants.java
@@ -80,6 +80,14 @@ public interface ErrorCodeConstants { @@ -80,6 +80,14 @@ public interface ErrorCodeConstants {
80 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情"); 80 ErrorCode FACE_USER_ITEM_NOT_OWN = new ErrorCode(1_040_800_011, "不能操作他人的表情");
81 ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情"); 81 ErrorCode FACE_USER_ITEM_DUPLICATED = new ErrorCode(1_040_800_013, "该表情已添加到个人表情");
82 82
  83 + // ========== 频道 (1-040-810-000) ==========
  84 + ErrorCode IM_CHANNEL_NOT_EXISTS = new ErrorCode(1_040_810_000, "频道不存在");
  85 + ErrorCode IM_CHANNEL_CODE_DUPLICATED = new ErrorCode(1_040_810_001, "频道编码 '{}' 已存在");
  86 + ErrorCode IM_CHANNEL_HAS_MATERIAL = new ErrorCode(1_040_810_002, "频道下还有素材,无法删除");
  87 + ErrorCode IM_CHANNEL_MATERIAL_NOT_EXISTS = new ErrorCode(1_040_810_010, "素材不存在");
  88 + ErrorCode IM_CHANNEL_MATERIAL_USED = new ErrorCode(1_040_810_011, "素材已被推送过,无法删除");
  89 + ErrorCode IM_CHANNEL_MESSAGE_NOT_EXISTS = new ErrorCode(1_040_810_020, "频道消息不存在");
  90 +
83 // ========== 实时通话 (1-040-900-000) ========== 91 // ========== 实时通话 (1-040-900-000) ==========
84 ErrorCode RTC_NOT_ENABLED = new ErrorCode(1_040_900_000, "通话功能未开启"); 92 ErrorCode RTC_NOT_ENABLED = new ErrorCode(1_040_900_000, "通话功能未开启");
85 ErrorCode RTC_SESSION_NOT_EXISTS = new ErrorCode(1_040_900_001, "通话已结束"); 93 ErrorCode RTC_SESSION_NOT_EXISTS = new ErrorCode(1_040_900_001, "通话已结束");
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/ImConversationTypeEnum.java
@@ -17,7 +17,8 @@ import java.util.Objects; @@ -17,7 +17,8 @@ import java.util.Objects;
17 public enum ImConversationTypeEnum implements ArrayValuable<Integer> { 17 public enum ImConversationTypeEnum implements ArrayValuable<Integer> {
18 18
19 PRIVATE(1, "私聊"), // 私聊 19 PRIVATE(1, "私聊"), // 私聊
20 - GROUP(2, "群聊"); // 群聊 20 + GROUP(2, "群聊"), // 群聊
  21 + CHANNEL(3, "频道"); // 频道 / 公众号
21 22
22 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImConversationTypeEnum::getType).toArray(Integer[]::new); 23 public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImConversationTypeEnum::getType).toArray(Integer[]::new);
23 24
@@ -43,4 +44,8 @@ public enum ImConversationTypeEnum implements ArrayValuable&lt;Integer&gt; { @@ -43,4 +44,8 @@ public enum ImConversationTypeEnum implements ArrayValuable&lt;Integer&gt; {
43 return Objects.equals(GROUP.type, type); 44 return Objects.equals(GROUP.type, type);
44 } 45 }
45 46
  47 + public static boolean isChannel(Integer type) {
  48 + return Objects.equals(CHANNEL.type, type);
  49 + }
  50 +
46 } 51 }
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/enums/message/ImMessageTypeEnum.java
@@ -61,6 +61,15 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; { @@ -61,6 +61,15 @@ public enum ImMessageTypeEnum implements ArrayValuable&lt;Integer&gt; {
61 */ 61 */
62 FACE(115, "表情", true, true), 62 FACE(115, "表情", true, true),
63 63
  64 + // ========== 频道消息扩展段(125+;OpenIM 122 之后未占用,本系统在 125 起步给频道 / 公众号类消息扩展) ==========
  65 + /**
  66 + * 对应 OpenIM:无(125 段位 OpenIM 未占用,作为频道消息扩展起始位)
  67 + * 对应自己的类:MaterialMessage
  68 + * 场景:频道运营推送的素材消息;当前形态为图文卡片(title + coverUrl + summary + url)
  69 + * 详情:url 非空跳 url;url 为空时客户端按 materialId 拉 /get-content 渲染富文本正文
  70 + */
  71 + MATERIAL(125, "素材", true, true),
  72 +
64 // ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ========== 73 // ========== 信号类(2101 / 2200 直接复用 OpenIM 段位编号;2201 自有扩展) ==========
65 /** 74 /**
66 * 对应 OpenIM:RevokeNotification 2101 75 * 对应 OpenIM:RevokeNotification 2101
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  7 +import jakarta.validation.Valid;
  8 +
  9 +import java.util.Collection;
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * IM 频道素材 Service 接口
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public interface ImChannelMaterialService {
  18 +
  19 + // ==================== 用户端 ====================
  20 +
  21 + /**
  22 + * 获取素材正文;用于客户端点击图文卡片时拉富文本
  23 + *
  24 + * @param id 素材编号
  25 + * @return 富文本 HTML
  26 + */
  27 + String getMaterialContent(Long id);
  28 +
  29 + /**
  30 + * 校验素材存在
  31 + *
  32 + * @param id 素材编号
  33 + * @return 素材 DO
  34 + */
  35 + ImChannelMaterialDO validateMaterialExists(Long id);
  36 +
  37 + /**
  38 + * 按编号批量查询素材
  39 + *
  40 + * @param ids 素材编号列表
  41 + * @return 素材列表
  42 + */
  43 + List<ImChannelMaterialDO> getMaterialList(Collection<Long> ids);
  44 +
  45 + /**
  46 + * 统计指定频道下的素材数量
  47 + *
  48 + * @param channelId 频道编号
  49 + * @return 数量
  50 + */
  51 + Long getMaterialCountByChannelId(Long channelId);
  52 +
  53 + // ==================== 管理后台 ====================
  54 +
  55 + /**
  56 + * 分页查询素材
  57 + *
  58 + * @param reqVO 分页查询条件
  59 + * @return 素材分页
  60 + */
  61 + PageResult<ImChannelMaterialDO> getMaterialPage(ImChannelMaterialPageReqVO reqVO);
  62 +
  63 + /**
  64 + * 获取素材详情(含 content 富文本)
  65 + *
  66 + * @param id 素材编号
  67 + * @return 素材 DO
  68 + */
  69 + ImChannelMaterialDO getMaterial(Long id);
  70 +
  71 + /**
  72 + * 新增素材
  73 + *
  74 + * @param reqVO 新增请求
  75 + * @return 新增素材编号
  76 + */
  77 + Long createMaterial(@Valid ImChannelMaterialSaveReqVO reqVO);
  78 +
  79 + /**
  80 + * 修改素材
  81 + *
  82 + * @param reqVO 修改请求
  83 + */
  84 + void updateMaterial(@Valid ImChannelMaterialSaveReqVO reqVO);
  85 +
  86 + /**
  87 + * 删除素材;素材已被推送过时拒绝,避免历史消息无法回查
  88 + *
  89 + * @param id 素材编号
  90 + */
  91 + void deleteMaterial(Long id);
  92 +
  93 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMaterialServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.channel;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  5 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  6 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialPageReqVO;
  7 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.material.ImChannelMaterialSaveReqVO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  9 +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMaterialMapper;
  10 +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMessageMapper;
  11 +import jakarta.annotation.Resource;
  12 +import org.springframework.stereotype.Service;
  13 +import org.springframework.validation.annotation.Validated;
  14 +
  15 +import java.util.Collection;
  16 +import java.util.Collections;
  17 +import java.util.List;
  18 +
  19 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  20 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_NOT_EXISTS;
  21 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MATERIAL_USED;
  22 +
  23 +/**
  24 + * IM 频道素材 Service 实现类
  25 + *
  26 + * @author 芋道源码
  27 + */
  28 +@Service
  29 +@Validated
  30 +public class ImChannelMaterialServiceImpl implements ImChannelMaterialService {
  31 +
  32 + @Resource
  33 + private ImChannelMaterialMapper channelMaterialMapper;
  34 + @Resource
  35 + private ImChannelService channelService;
  36 + @Resource
  37 + private ImChannelMessageMapper channelMessageMapper;
  38 +
  39 + // ==================== 用户端 ====================
  40 +
  41 + @Override
  42 + public String getMaterialContent(Long id) {
  43 + ImChannelMaterialDO material = validateMaterialExists(id);
  44 + return material.getContent();
  45 + }
  46 +
  47 + @Override
  48 + public ImChannelMaterialDO validateMaterialExists(Long id) {
  49 + ImChannelMaterialDO material = channelMaterialMapper.selectById(id);
  50 + if (material == null) {
  51 + throw exception(IM_CHANNEL_MATERIAL_NOT_EXISTS);
  52 + }
  53 + return material;
  54 + }
  55 +
  56 + @Override
  57 + public List<ImChannelMaterialDO> getMaterialList(Collection<Long> ids) {
  58 + if (CollUtil.isEmpty(ids)) {
  59 + return Collections.emptyList();
  60 + }
  61 + return channelMaterialMapper.selectByIds(ids);
  62 + }
  63 +
  64 + @Override
  65 + public Long getMaterialCountByChannelId(Long channelId) {
  66 + return channelMaterialMapper.selectCountByChannelId(channelId);
  67 + }
  68 +
  69 + // ==================== 管理后台 ====================
  70 +
  71 + @Override
  72 + public PageResult<ImChannelMaterialDO> getMaterialPage(ImChannelMaterialPageReqVO reqVO) {
  73 + return channelMaterialMapper.selectPage(reqVO);
  74 + }
  75 +
  76 + @Override
  77 + public ImChannelMaterialDO getMaterial(Long id) {
  78 + return channelMaterialMapper.selectById(id);
  79 + }
  80 +
  81 + // TODO @AI:类似别的模块,写全注释
  82 + @Override
  83 + public Long createMaterial(ImChannelMaterialSaveReqVO reqVO) {
  84 + channelService.validateChannelExists(reqVO.getChannelId());
  85 + ImChannelMaterialDO material = BeanUtils.toBean(reqVO, ImChannelMaterialDO.class);
  86 + channelMaterialMapper.insert(material);
  87 + return material.getId();
  88 + }
  89 +
  90 + // TODO @AI:类似别的模块,写全注释
  91 + @Override
  92 + public void updateMaterial(ImChannelMaterialSaveReqVO reqVO) {
  93 + validateMaterialExists(reqVO.getId());
  94 + channelService.validateChannelExists(reqVO.getChannelId());
  95 + ImChannelMaterialDO updateObj = BeanUtils.toBean(reqVO, ImChannelMaterialDO.class);
  96 + channelMaterialMapper.updateById(updateObj);
  97 + }
  98 +
  99 + @Override
  100 + public void deleteMaterial(Long id) {
  101 + validateMaterialExists(id);
  102 + // 防止删除素材导致历史 channel_message 反查不到内容
  103 + if (channelMessageMapper.selectCountByMaterialId(id) > 0) {
  104 + throw exception(IM_CHANNEL_MATERIAL_USED);
  105 + }
  106 + channelMaterialMapper.deleteById(id);
  107 + }
  108 +
  109 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessagePageReqVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.message.ImChannelMessageSendReqVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO;
  7 +import jakarta.validation.Valid;
  8 +
  9 +import java.util.List;
  10 +
  11 +// TODO @AI:这块消息,我想挪到 message 里;后续,可能整合成一个表存储的;
  12 +/**
  13 + * IM 频道消息 Service 接口
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public interface ImChannelMessageService {
  18 +
  19 + // ==================== 用户端 ====================
  20 +
  21 + /**
  22 + * 拉取当前用户应收的频道消息(离线增量)
  23 + *
  24 + * @param userId 当前用户编号
  25 + * @param minId 游标;返回大于此值的消息
  26 + * @param size 返回条数
  27 + * @return 频道消息列表;按 id 升序
  28 + */
  29 + List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size);
  30 +
  31 + /**
  32 + * 校验用户是否能看到指定素材;用作详情接口的归属校验
  33 + *
  34 + * @param userId 当前用户编号
  35 + * @param materialId 素材编号
  36 + * @return 是否能看到(曾经收到过 / 全员消息)
  37 + */
  38 + boolean isUserReceivedMaterial(Long userId, Long materialId);
  39 +
  40 + // ==================== 管理后台 ====================
  41 +
  42 + /**
  43 + * 立即推送频道消息
  44 + * <p>
  45 + * 流程:读 material → 组装 payload → INSERT 1 行 im_channel_message → WebSocket 扇出
  46 + *
  47 + * @param reqVO 推送请求
  48 + * @return 消息编号
  49 + */
  50 + Long sendMessage(@Valid ImChannelMessageSendReqVO reqVO);
  51 +
  52 + /**
  53 + * 分页查询消息
  54 + *
  55 + * @param reqVO 分页查询条件
  56 + * @return 消息分页
  57 + */
  58 + PageResult<ImChannelMessageDO> getMessagePage(ImChannelMessagePageReqVO reqVO);
  59 +
  60 + /**
  61 + * 删除消息
  62 + *
  63 + * @param id 消息编号
  64 + */
  65 + void deleteMessage(Long id);
  66 +
  67 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelMessageServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.channel;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  5 +import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
  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.ImChannelMessageSendReqVO;
  8 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO;
  10 +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMessageMapper;
  11 +import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
  12 +import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
  13 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
  14 +import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage;
  15 +import jakarta.annotation.Resource;
  16 +import org.springframework.stereotype.Service;
  17 +import org.springframework.transaction.annotation.Transactional;
  18 +import org.springframework.validation.annotation.Validated;
  19 +
  20 +import java.time.LocalDateTime;
  21 +import java.util.List;
  22 +
  23 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  24 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_MESSAGE_NOT_EXISTS;
  25 +
  26 +/**
  27 + * IM 频道消息 Service 实现类
  28 + *
  29 + * @author 芋道源码
  30 + */
  31 +@Service
  32 +@Validated
  33 +public class ImChannelMessageServiceImpl implements ImChannelMessageService {
  34 +
  35 + @Resource
  36 + private ImChannelMessageMapper channelMessageMapper;
  37 + @Resource
  38 + private ImChannelMaterialService channelMaterialService;
  39 + @Resource
  40 + private ImWebSocketService webSocketService;
  41 +
  42 + // ==================== 用户端 ====================
  43 +
  44 + @Override
  45 + public List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size) {
  46 + return channelMessageMapper.selectListByUserAndMinId(userId, minId, size);
  47 + }
  48 +
  49 + @Override
  50 + public boolean isUserReceivedMaterial(Long userId, Long materialId) {
  51 + return channelMessageMapper.existsByUserAndMaterial(userId, materialId);
  52 + }
  53 +
  54 + // ==================== 管理后台 ====================
  55 +
  56 + @Override
  57 + @Transactional(rollbackFor = Exception.class)
  58 + public Long sendMessage(ImChannelMessageSendReqVO reqVO) {
  59 + // 1. 校验素材存在
  60 + ImChannelMaterialDO material = channelMaterialService.validateMaterialExists(reqVO.getMaterialId());
  61 +
  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());
  80 + channelMessageMapper.insert(message);
  81 +
  82 + // 4. 异步推 WebSocket;全员场景(receiverUserIds 为空)暂不实时广播,依靠客户端上线后 pull 兜底
  83 + // TODO @AI:全院也要发;
  84 + if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) {
  85 + webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(),
  86 + ImChannelMessageDTO.ofSend(message));
  87 + }
  88 + return message.getId();
  89 + }
  90 +
  91 + @Override
  92 + public PageResult<ImChannelMessageDO> getMessagePage(ImChannelMessagePageReqVO reqVO) {
  93 + return channelMessageMapper.selectPage(reqVO);
  94 + }
  95 +
  96 + @Override
  97 + public void deleteMessage(Long id) {
  98 + if (channelMessageMapper.selectById(id) == null) {
  99 + throw exception(IM_CHANNEL_MESSAGE_NOT_EXISTS);
  100 + }
  101 + channelMessageMapper.deleteById(id);
  102 + }
  103 +
  104 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelService.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.channel;
  2 +
  3 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  4 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO;
  5 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO;
  6 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  7 +import jakarta.validation.Valid;
  8 +
  9 +import java.util.Collection;
  10 +import java.util.List;
  11 +
  12 +/**
  13 + * IM 频道 Service 接口
  14 + *
  15 + * @author 芋道源码
  16 + */
  17 +public interface ImChannelService {
  18 +
  19 + // ==================== 用户端 ====================
  20 +
  21 + // TODO @AI:getChannelListByStatus 这种形式;
  22 + /**
  23 + * 获取所有启用的频道,按 sort 升序
  24 + *
  25 + * @return 启用的频道列表
  26 + */
  27 + List<ImChannelDO> getEnabledChannelList();
  28 +
  29 + /**
  30 + * 按编号批量查询频道
  31 + *
  32 + * @param ids 频道编号列表
  33 + * @return 频道列表
  34 + */
  35 + List<ImChannelDO> getChannelList(Collection<Long> ids);
  36 +
  37 + /**
  38 + * 校验频道存在
  39 + *
  40 + * @param id 频道编号
  41 + * @return 频道 DO
  42 + */
  43 + @SuppressWarnings("UnusedReturnValue")
  44 + ImChannelDO validateChannelExists(Long id);
  45 +
  46 + // ==================== 管理后台 ====================
  47 +
  48 + /**
  49 + * 分页查询频道
  50 + *
  51 + * @param reqVO 分页查询条件
  52 + * @return 频道分页
  53 + */
  54 + PageResult<ImChannelDO> getChannelPage(ImChannelPageReqVO reqVO);
  55 +
  56 + /**
  57 + * 获取频道详情
  58 + *
  59 + * @param id 频道编号
  60 + * @return 频道 DO
  61 + */
  62 + ImChannelDO getChannel(Long id);
  63 +
  64 + /**
  65 + * 新增频道
  66 + *
  67 + * @param reqVO 新增请求
  68 + * @return 新增频道编号
  69 + */
  70 + Long createChannel(@Valid ImChannelSaveReqVO reqVO);
  71 +
  72 + /**
  73 + * 修改频道
  74 + *
  75 + * @param reqVO 修改请求
  76 + */
  77 + void updateChannel(@Valid ImChannelSaveReqVO reqVO);
  78 +
  79 + /**
  80 + * 删除频道;频道下有素材或消息时拒绝
  81 + *
  82 + * @param id 频道编号
  83 + */
  84 + void deleteChannel(Long id);
  85 +
  86 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/channel/ImChannelServiceImpl.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.channel;
  2 +
  3 +import cn.hutool.core.collection.CollUtil;
  4 +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
  5 +import cn.iocoder.yudao.framework.common.pojo.PageResult;
  6 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  7 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelPageReqVO;
  8 +import cn.iocoder.yudao.module.im.controller.admin.manager.channel.vo.channel.ImChannelSaveReqVO;
  9 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
  10 +import cn.iocoder.yudao.module.im.dal.mysql.channel.ImChannelMapper;
  11 +import jakarta.annotation.Resource;
  12 +import org.springframework.stereotype.Service;
  13 +import org.springframework.validation.annotation.Validated;
  14 +
  15 +import java.util.Collection;
  16 +import java.util.Collections;
  17 +import java.util.List;
  18 +import java.util.Objects;
  19 +
  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;
  22 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_HAS_MATERIAL;
  23 +import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.IM_CHANNEL_NOT_EXISTS;
  24 +
  25 +/**
  26 + * IM 频道 Service 实现类
  27 + *
  28 + * @author 芋道源码
  29 + */
  30 +@Service
  31 +@Validated
  32 +public class ImChannelServiceImpl implements ImChannelService {
  33 +
  34 + @Resource
  35 + private ImChannelMapper channelMapper;
  36 + @Resource
  37 + private ImChannelMaterialService channelMaterialService;
  38 +
  39 + // ==================== 用户端 ====================
  40 +
  41 + @Override
  42 + public List<ImChannelDO> getEnabledChannelList() {
  43 + return channelMapper.selectListByStatusOrderBySort(CommonStatusEnum.ENABLE.getStatus());
  44 + }
  45 +
  46 + @Override
  47 + public List<ImChannelDO> getChannelList(Collection<Long> ids) {
  48 + if (CollUtil.isEmpty(ids)) {
  49 + return Collections.emptyList();
  50 + }
  51 + return channelMapper.selectByIds(ids);
  52 + }
  53 +
  54 + @Override
  55 + public ImChannelDO validateChannelExists(Long id) {
  56 + ImChannelDO channel = channelMapper.selectById(id);
  57 + if (channel == null) {
  58 + throw exception(IM_CHANNEL_NOT_EXISTS);
  59 + }
  60 + return channel;
  61 + }
  62 +
  63 + // ==================== 管理后台 ====================
  64 +
  65 + @Override
  66 + public PageResult<ImChannelDO> getChannelPage(ImChannelPageReqVO reqVO) {
  67 + return channelMapper.selectPage(reqVO);
  68 + }
  69 +
  70 + @Override
  71 + public ImChannelDO getChannel(Long id) {
  72 + return channelMapper.selectById(id);
  73 + }
  74 +
  75 + @Override
  76 + public Long createChannel(ImChannelSaveReqVO reqVO) {
  77 + // 校验 code 唯一
  78 + validateCodeUnique(null, reqVO.getCode());
  79 +
  80 + // 插入
  81 + ImChannelDO channel = BeanUtils.toBean(reqVO, ImChannelDO.class);
  82 + channelMapper.insert(channel);
  83 + return channel.getId();
  84 + }
  85 +
  86 + @Override
  87 + public void updateChannel(ImChannelSaveReqVO reqVO) {
  88 + // 1.1 校验存在
  89 + validateChannelExists(reqVO.getId());
  90 + // 1.2 校验 code 唯一
  91 + validateCodeUnique(reqVO.getId(), reqVO.getCode());
  92 +
  93 + // 2. 更新
  94 + ImChannelDO updateObj = BeanUtils.toBean(reqVO, ImChannelDO.class);
  95 + channelMapper.updateById(updateObj);
  96 + }
  97 +
  98 + @Override
  99 + public void deleteChannel(Long id) {
  100 + // 1.1 校验存在
  101 + validateChannelExists(id);
  102 + // 1.2 防止误删频道导致历史素材 / 消息回查不到归属
  103 + if (channelMaterialService.getMaterialCountByChannelId(id) > 0) {
  104 + throw exception(IM_CHANNEL_HAS_MATERIAL);
  105 + }
  106 +
  107 + // 2. 删除频道
  108 + channelMapper.deleteById(id);
  109 + }
  110 +
  111 + private void validateCodeUnique(Long id, String code) {
  112 + ImChannelDO exist = channelMapper.selectByCode(code);
  113 + if (exist == null) {
  114 + return;
  115 + }
  116 + // TODO @AI:notEquals objutils;
  117 + if (id == null || !Objects.equals(exist.getId(), id)) {
  118 + throw exception(IM_CHANNEL_CODE_DUPLICATED, code);
  119 + }
  120 + }
  121 +
  122 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/ImWebSocketService.java
1 package cn.iocoder.yudao.module.im.service.websocket; 1 package cn.iocoder.yudao.module.im.service.websocket;
2 2
  3 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
3 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; 4 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
4 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 5 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
5 6
@@ -53,4 +54,22 @@ public interface ImWebSocketService { @@ -53,4 +54,22 @@ public interface ImWebSocketService {
53 */ 54 */
54 void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto); 55 void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto);
55 56
  57 + /**
  58 + * 异步推送频道消息给指定用户
  59 + *
  60 + * @param userId 目标用户编号
  61 + * @param dto 频道消息 DTO
  62 + */
  63 + default void sendChannelMessageAsync(Long userId, ImChannelMessageDTO dto) {
  64 + sendChannelMessageAsync(Collections.singleton(userId), dto);
  65 + }
  66 +
  67 + /**
  68 + * 异步批量推送频道消息给多个用户
  69 + *
  70 + * @param userIds 目标用户编号列表
  71 + * @param dto 频道消息 DTO
  72 + */
  73 + void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto);
  74 +
56 } 75 }
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; @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.service.websocket;
3 import cn.hutool.extra.spring.SpringUtil; 3 import cn.hutool.extra.spring.SpringUtil;
4 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; 4 import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
5 import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender; 5 import cn.iocoder.yudao.framework.websocket.core.sender.WebSocketMessageSender;
  6 +import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
6 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO; 7 import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
7 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO; 8 import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
8 import jakarta.annotation.Resource; 9 import jakarta.annotation.Resource;
@@ -43,6 +44,11 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -43,6 +44,11 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
43 executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto)); 44 executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto));
44 } 45 }
45 46
  47 + @Override
  48 + public void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto) {
  49 + executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto));
  50 + }
  51 +
46 /** 52 /**
47 * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调 53 * 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调
48 */ 54 */
@@ -74,6 +80,21 @@ public class ImWebSocketServiceImpl implements ImWebSocketService { @@ -74,6 +80,21 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
74 } 80 }
75 81
76 /** 82 /**
  83 + * 异步发送频道 WebSocket 消息
  84 + */
  85 + @Async
  86 + public void doSendChannelMessage(Collection<Long> userIds, ImChannelMessageDTO dto) {
  87 + for (Long userId : userIds) {
  88 + try {
  89 + webSocketMessageSender.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
  90 + ImChannelMessageDTO.TYPE, dto);
  91 + } catch (Exception e) {
  92 + log.error("[doSendChannelMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
  93 + }
  94 + }
  95 + }
  96 +
  97 + /**
77 * 事务感知的任务调度: 98 * 事务感知的任务调度:
78 * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘 99 * - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘
79 * - 无事务:直接执行(如非 @Transactional 方法中的调用) 100 * - 无事务:直接执行(如非 @Transactional 方法中的调用)
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/ImChannelMessageDTO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto;
  2 +
  3 +import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
  4 +import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMessageDO;
  5 +import lombok.Data;
  6 +import lombok.experimental.Accessors;
  7 +
  8 +import java.time.LocalDateTime;
  9 +
  10 +/**
  11 + * IM 频道消息 WebSocket 推送 DTO
  12 + * <p>
  13 + * 单向:服务端运营推送 → C 端用户;C 端不能向频道发消息。
  14 + * 字段分层:顶层是消息元数据 + 检索维度,content 是 MaterialMessage payload 的 JSON 串。
  15 + *
  16 + * @author 芋道源码
  17 + */
  18 +@Data
  19 +@Accessors(chain = true)
  20 +public class ImChannelMessageDTO {
  21 +
  22 + public static final String TYPE = "im-channel-message";
  23 +
  24 + /**
  25 + * 消息编号
  26 + */
  27 + private Long id;
  28 + /**
  29 + * 频道编号
  30 + */
  31 + private Long channelId;
  32 + /**
  33 + * 关联素材编号
  34 + */
  35 + private Long materialId;
  36 + /**
  37 + * 消息类型
  38 + */
  39 + private Integer type;
  40 + /**
  41 + * 消息内容;payload JSON 串
  42 + */
  43 + private String content;
  44 + /**
  45 + * 发送时间
  46 + */
  47 + private LocalDateTime sendTime;
  48 +
  49 + /**
  50 + * 由频道消息 DO 构建推送 DTO
  51 + */
  52 + public static ImChannelMessageDTO ofSend(ImChannelMessageDO message) {
  53 + return BeanUtils.toBean(message, ImChannelMessageDTO.class);
  54 + }
  55 +
  56 +}
yudao-module-im/src/main/java/cn/iocoder/yudao/module/im/service/websocket/dto/message/MaterialMessage.java 0 → 100644
  1 +package cn.iocoder.yudao.module.im.service.websocket.dto.message;
  2 +
  3 +import lombok.Data;
  4 +import lombok.experimental.Accessors;
  5 +
  6 +/**
  7 + * 频道素材消息 payload
  8 + * <p>
  9 + * 对应 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum#MATERIAL}(type=125)。
  10 + * 客户端按本字段集渲染图文卡片;
  11 + * 富文本正文不在本 payload 中传递,点击详情时另调 /im/channel/material/get-content?id= 按需拉取。
  12 + */
  13 +@Data
  14 +@Accessors(chain = true)
  15 +public class MaterialMessage {
  16 +
  17 + /**
  18 + * 标题
  19 + */
  20 + private String title;
  21 + /**
  22 + * 封面图
  23 + */
  24 + private String coverUrl;
  25 + /**
  26 + * 摘要
  27 + */
  28 + private String summary;
  29 + /**
  30 + * 跳转链接;为空时点击在客户端内置详情页按 materialId 拉 content 渲染;非空则跳 url
  31 + */
  32 + private String url;
  33 +
  34 +}
yudao-module-im/src/test/resources/sql/create_tables.sql
@@ -236,6 +236,57 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_user_item&quot; ( @@ -236,6 +236,57 @@ CREATE TABLE IF NOT EXISTS &quot;im_face_user_item&quot; (
236 PRIMARY KEY ("id") 236 PRIMARY KEY ("id")
237 ) COMMENT 'IM 用户私有表情表'; 237 ) COMMENT 'IM 用户私有表情表';
238 238
  239 +CREATE TABLE IF NOT EXISTS "im_channel" (
  240 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  241 + "code" varchar(64) NOT NULL COMMENT '频道业务码;唯一',
  242 + "name" varchar(64) NOT NULL COMMENT '频道名称',
  243 + "avatar" varchar(512) DEFAULT NULL COMMENT '频道头像',
  244 + "sort" int NOT NULL DEFAULT 0 COMMENT '排序',
  245 + "status" tinyint NOT NULL DEFAULT 0 COMMENT '状态;0 启用 1 停用',
  246 + "creator" varchar(64) DEFAULT '',
  247 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  248 + "updater" varchar(64) DEFAULT '',
  249 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  250 + "deleted" bit NOT NULL DEFAULT FALSE,
  251 + "tenant_id" bigint NOT NULL DEFAULT 0,
  252 + PRIMARY KEY ("id")
  253 +) COMMENT 'IM 频道表';
  254 +
  255 +CREATE TABLE IF NOT EXISTS "im_channel_material" (
  256 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  257 + "channel_id" bigint NOT NULL COMMENT '频道编号',
  258 + "type" smallint NOT NULL COMMENT '素材类型;对应 ImMessageTypeEnum',
  259 + "title" varchar(128) NOT NULL COMMENT '标题',
  260 + "cover_url" varchar(512) DEFAULT NULL COMMENT '封面图',
  261 + "summary" varchar(255) DEFAULT NULL COMMENT '摘要',
  262 + "content" clob DEFAULT NULL COMMENT '正文;富文本 HTML',
  263 + "url" varchar(512) DEFAULT NULL COMMENT '跳转链接;为空时点击在客户端内置详情页拉 content;非空则跳 url',
  264 + "creator" varchar(64) DEFAULT '',
  265 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  266 + "updater" varchar(64) DEFAULT '',
  267 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  268 + "deleted" bit NOT NULL DEFAULT FALSE,
  269 + "tenant_id" bigint NOT NULL DEFAULT 0,
  270 + PRIMARY KEY ("id")
  271 +) COMMENT 'IM 频道素材表';
  272 +
  273 +CREATE TABLE IF NOT EXISTS "im_channel_message" (
  274 + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
  275 + "channel_id" bigint NOT NULL COMMENT '频道编号;冗余 im_channel_material.channel_id 便于检索',
  276 + "material_id" bigint NOT NULL COMMENT '关联素材编号',
  277 + "type" smallint NOT NULL COMMENT '消息类型',
  278 + "content" varchar(8192) DEFAULT NULL COMMENT '消息内容;推送时 payload JSON 快照;不含富文本正文',
  279 + "receiver_user_ids" varchar(1024) DEFAULT NULL COMMENT '接收人编号列表;逗号分隔;为空表示全员',
  280 + "send_time" timestamp NOT NULL COMMENT '发送时间',
  281 + "creator" varchar(64) DEFAULT '',
  282 + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  283 + "updater" varchar(64) DEFAULT '',
  284 + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  285 + "deleted" bit NOT NULL DEFAULT FALSE,
  286 + "tenant_id" bigint NOT NULL DEFAULT 0,
  287 + PRIMARY KEY ("id")
  288 +) COMMENT 'IM 频道消息表';
  289 +
239 CREATE TABLE IF NOT EXISTS "system_users" ( 290 CREATE TABLE IF NOT EXISTS "system_users" (
240 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, 291 "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
241 "username" varchar(30) NOT NULL DEFAULT '', 292 "username" varchar(30) NOT NULL DEFAULT '',