liu
2 天以前 47dd1ecaf0aafa66b9c39fff9c91abc1c611b02c
feat(wxcp): 通知支持图片/视频/Markdown/多图文模板与成员预览

- 新增图片通知、视频通知、Markdown 通知、多图文消息模板
- 图片/附件支持多选,统一走 system 附件中间表
- 新增免登录成员预览页,支持图片放大、视频播放、Markdown 渲染
- 多图文条目落子表,推送时汇总多条消息结果并精确提示第几篇

Co-Authored-By: Claude <noreply@anthropic.com>
已修改13个文件
已添加5个文件
1348 ■■■■■ 文件已修改
docs/sql/README.md 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/wxcp_module.sql 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticeController.java 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticePublicController.java 328 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeArticleRespVO.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeArticleSaveReqVO.java 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeRespVO.java 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeSaveReqVO.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushReqVO.java 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/notice/WxCpNoticeArticleDO.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/notice/WxCpNoticeArticleMapper.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/ErrorCodeConstants.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/notice/WxCpNoticeTypeEnum.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeService.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java 572 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushService.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushServiceImpl.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/README.md
@@ -3,3 +3,5 @@
本项目包含的 SQL è„šæœ¬æ±‡æ€»å¦‚ä¸‹ï¼š
- [mes_pro_task_assignee.sql](mes_pro_task_assignee.sql): å¢žåŠ ç”Ÿäº§ä»»åŠ¡æŠ¥å·¥äººå‘˜å…³è”è¡¨ï¼Œç”¨äºŽå¤šäººå‘˜æŠ¥å·¥åˆ†é…ã€‚
- [wxcp_notice_image_20261027.sql](wxcp_notice_image_20261027.sql): ä¼ä¸šå¾®ä¿¡é€šçŸ¥æ–°å¢žã€Œå›¾ç‰‡é€šçŸ¥ã€æ¨¡æ¿ä¸Žå¤šé™„件支持,仅更新 notice_type æ³¨é‡Šï¼ˆå›¾ç‰‡/附件走附件中间表,无新增字段)。
- [wxcp_notice_templates_extend_20261027.sql](wxcp_notice_templates_extend_20261027.sql): ä¼ä¸šå¾®ä¿¡é€šçŸ¥æ‰©å±•「视频/Markdown/多图文」模板,新增多图文子表 wxcp_notice_article,并更新 notice_type æ³¨é‡Šã€‚
docs/sql/wxcp_module.sql
@@ -8,6 +8,8 @@
-- æ´»åŠ¨å°é¢ record_type = 'wxcp_activity',活动详情图片 record_type = 'wxcp_activity_detail',
-- å›¾ç‰‡é€šçŸ¥ record_type = 'wxcp_activity_image',附件通知 record_type = 'wxcp_activity_file',
-- æŠ¥åé™„ä»¶ record_type = 'wxcp_activity_signup'
-- é€šçŸ¥æ¨¡å—的素材同理:record_type = 'wxcp_notice_image' / 'wxcp_notice_file' /
-- 'wxcp_notice_video' / 'wxcp_notice_article_cover'
-- ============================================================
-- ----------------------------
@@ -80,7 +82,7 @@
  `id`            bigint       NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `title`         varchar(128) NOT NULL                                   COMMENT '通知标题',
  `content`       text                                                    COMMENT '通知正文',
  `notice_type`   tinyint      NOT NULL DEFAULT 1                         COMMENT '通知模板:1纯文本 2图文卡片(填报/链接) 3文件通知 4图文消息',
  `notice_type`   tinyint      NOT NULL DEFAULT 1                         COMMENT '通知模板:1纯文本 2图文卡片(填报/链接) 3文件通知 4图文消息 5图片通知 6视频通知 7Markdown 8多图文消息',
  `summary`       varchar(512)          DEFAULT NULL                      COMMENT '摘要(图文卡片/图文消息)',
  `link_url`      varchar(1024)         DEFAULT NULL                      COMMENT '跳转链接(填报/详情)',
  `link_btn_text` varchar(32)           DEFAULT NULL                      COMMENT '链接按钮文案',
@@ -102,6 +104,27 @@
  KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信通知';
-- ----------------------------
-- é€šçŸ¥ - å¤šå›¾æ–‡æ¶ˆæ¯æ¡ç›®
-- ä»…「多图文消息」模板使用;封面图只存 blob_id,地址走附件中间表
-- ----------------------------
CREATE TABLE IF NOT EXISTS `wxcp_notice_article` (
  `id`            bigint        NOT NULL AUTO_INCREMENT                    COMMENT '编号',
  `notice_id`     bigint        NOT NULL                                   COMMENT '通知编号',
  `title`         varchar(128)  NOT NULL                                   COMMENT '图文标题',
  `description`   varchar(512)           DEFAULT NULL                      COMMENT '图文摘要',
  `link_url`      varchar(1024) NOT NULL                                   COMMENT '点击跳转链接',
  `cover_blob_id` bigint                 DEFAULT NULL                      COMMENT '封面图 Blob ID',
  `sort_no`       int           NOT NULL DEFAULT 0                         COMMENT '排序号',
  `creator`       varchar(64)            DEFAULT ''                        COMMENT '创建者',
  `create_time`   datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '创建时间',
  `updater`       varchar(64)            DEFAULT ''                        COMMENT '更新者',
  `update_time`   datetime      NOT NULL DEFAULT CURRENT_TIMESTAMP         COMMENT '更新时间',
  `deleted`       bit(1)        NOT NULL DEFAULT b'0'                      COMMENT '是否删除',
  PRIMARY KEY (`id`),
  KEY `idx_notice_id` (`notice_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信通知多图文消息条目';
-- ============================================================
-- å‡çº§è„šæœ¬ï¼šå°é¢å›¾ä»Žã€Œä¸šåŠ¡è¡¨å­˜åœ°å€ã€æ”¹ä¸ºã€Œé™„ä»¶ä¸­é—´è¡¨ã€
-- å·²å»ºè¿‡åº“的环境执行一次即可;新库执行上面的 CREATE TABLE å·²æ˜¯æœ€æ–°ç»“构,无需执行
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -89,7 +89,13 @@
    WXCP_ACTIVITY_DETAIL("wxcp_activity_detail", "企业微信活动详情图"),
    WXCP_ACTIVITY_IMAGE("wxcp_activity_image", "企业微信活动图片通知"),
    WXCP_ACTIVITY_FILE("wxcp_activity_file", "企业微信活动附件通知"),
    WXCP_ACTIVITY_SIGNUP("wxcp_activity_signup", "企业微信活动报名");
    WXCP_ACTIVITY_SIGNUP("wxcp_activity_signup", "企业微信活动报名"),
    // ===== ä¼ä¸šå¾®ä¿¡é€šçŸ¥ =====
    WXCP_NOTICE_IMAGE("wxcp_notice_image", "企业微信通知图片"),
    WXCP_NOTICE_FILE("wxcp_notice_file", "企业微信通知附件"),
    WXCP_NOTICE_VIDEO("wxcp_notice_video", "企业微信通知视频"),
    WXCP_NOTICE_ARTICLE_COVER("wxcp_notice_article_cover", "企业微信通知图文封面");
    private final String type;
    private final String description;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticeController.java
@@ -3,6 +3,8 @@
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.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeArticleRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeSaveReqVO;
@@ -21,6 +23,8 @@
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;
@@ -112,6 +116,23 @@
        }
        WxCpNoticeRespVO vo = BeanUtils.toBean(notice, WxCpNoticeRespVO.class);
        fillNames(vo);
        // ç´ æè¯¦æƒ…需要回显,列表页不回显以免多查附件表
        vo.setImageAttachmentList(noticeService.getNoticeImageAttachments(notice.getId()));
        vo.setFileAttachmentList(noticeService.getNoticeFileAttachments(notice.getId()));
        vo.setVideoAttachmentList(noticeService.getNoticeVideoAttachments(notice.getId()));
        vo.setImageBlobIds(vo.getImageAttachmentList().stream()
                .map(SystemStorageBlobRespVO::getId).toList());
        vo.setFileBlobIds(vo.getFileAttachmentList().stream()
                .map(SystemStorageBlobRespVO::getId).toList());
        vo.setVideoBlobIds(vo.getVideoAttachmentList().stream()
                .map(SystemStorageBlobRespVO::getId).toList());
        vo.setArticleList(noticeService.getNoticeArticles(notice.getId()).stream().map(article -> {
            WxCpNoticeArticleRespVO item = BeanUtils.toBean(article, WxCpNoticeArticleRespVO.class);
            List<SystemStorageBlobRespVO> covers =
                    noticeService.getNoticeArticleCoverAttachments(article.getId());
            item.setCoverAttachment(covers.isEmpty() ? null : covers.get(0));
            return item;
        }).toList());
        return vo;
    }
@@ -122,6 +143,8 @@
        vo.setNoticeTypeName(type != null ? type.getName() : null);
        WxCpPushScopeEnum scope = WxCpPushScopeEnum.valueOf(vo.getPushScope());
        vo.setPushScopeName(scope != null ? scope.getName() : null);
        // é¢„览链接只依赖配置,列表页也能直接给出,方便管理员复制发给成员
        vo.setPreviewUrl(noticeService.buildPreviewUrl(vo.getId()));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/WxCpNoticePublicController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,328 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
import cn.iocoder.yudao.module.wxcp.service.notice.WxCpNoticeService;
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.annotation.security.PermitAll;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ - æˆå‘˜é¢„览页(免登录)
 *
 * è¯´æ˜Žï¼šæ”¾åœ¨ controller.admin åŒ…下从而带上 /admin-api å‰ç¼€ã€‚
 * å‰ç«¯ Vite å¼€å‘代理只转发 /admin-api,若放到 /app-api,
 * æˆå‘˜åœ¨ä¼ä¸šå¾®ä¿¡é‡Œæ‰“开链接会打到前端静态服务返回 404。
 *
 * @author è¶…级管理员
 */
@Tag(name = "用户 App - ä¼ä¸šå¾®ä¿¡é€šçŸ¥é¢„览")
@RestController
@RequestMapping("/wxcp/notice")
@Validated
@Slf4j
public class WxCpNoticePublicController {
    /** è¡Œå†…代码 */
    private static final Pattern MD_CODE = Pattern.compile("`([^`]+)`");
    /** åŠ ç²— */
    private static final Pattern MD_STRONG = Pattern.compile("\\*\\*(.+?)\\*\\*");
    /** é“¾æŽ¥ [文案](地址) */
    private static final Pattern MD_LINK = Pattern.compile("\\[([^\\]]+)]\\(([^)]+)\\)");
    @Resource
    private WxCpNoticeService noticeService;
    @GetMapping(value = "/preview-page", produces = MediaType.TEXT_HTML_VALUE)
    @PermitAll
    @Operation(summary = "通知预览页(HTML)", description = "图片/附件通知推送给成员后,成员打开此页查看图片与附件")
    @Parameter(name = "id", description = "通知编号", required = true)
    public String previewPage(@RequestParam("id") Long id) {
        WxCpNoticeDO notice = noticeService.getNotice(id);
        if (notice == null) {
            return renderMessagePage("通知不存在", "通知可能已被删除,请联系通知发起人确认。");
        }
        if (!WxCpNoticeStatusEnum.PUBLISHED.getStatus().equals(notice.getStatus())) {
            return renderMessagePage("通知未开放", "该通知尚未发布或已撤回,请联系通知发起人确认。");
        }
        return renderPreviewPage(notice);
    }
    private String renderPreviewPage(WxCpNoticeDO notice) {
        List<SystemStorageBlobRespVO> images = noticeService.getNoticeImageAttachments(notice.getId());
        List<SystemStorageBlobRespVO> files = noticeService.getNoticeFileAttachments(notice.getId());
        List<SystemStorageBlobRespVO> videos = noticeService.getNoticeVideoAttachments(notice.getId());
        StringBuilder sb = new StringBuilder(2048);
        sb.append("<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">")
                .append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1\">")
                .append("<title>").append(esc(notice.getTitle())).append("</title>")
                .append("<style>")
                .append("*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}")
                .append("body{margin:0;padding:16px;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;")
                .append("background:#f5f6f8;color:#1f2329;font-size:15px;line-height:1.6}")
                .append(".card{background:#fff;border-radius:12px;padding:18px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}")
                .append("h1{font-size:19px;margin:0 0 14px;font-weight:600;word-break:break-all}")
                .append(".desc{white-space:pre-wrap;word-break:break-word;color:#3f444d}")
                .append(".detail-img{display:block;margin:12px 0 0}")
                .append(".detail-img img{display:block;width:100%;height:auto;border-radius:8px;background:#f2f3f5}")
                .append(".file-title{font-weight:600;margin-bottom:8px}")
                .append(".file-list div{padding:8px 0;border-bottom:1px solid #f0f1f3;word-break:break-all}")
                .append(".file-list div:last-child{border-bottom:0}")
                .append(".file-list a{color:#3370ff;text-decoration:none}")
                .append(".file-size{color:#8f959e;font-size:12px;margin-left:6px}")
                .append(".video-wrap video{display:block;width:100%;border-radius:8px;background:#000}")
                .append(".markdown h1,.markdown h2,.markdown h3{margin:14px 0 8px;font-weight:600;line-height:1.4}")
                .append(".markdown h1{font-size:19px}.markdown h2{font-size:17px}.markdown h3{font-size:16px}")
                .append(".markdown p{margin:8px 0}")
                .append(".markdown ul,.markdown ol{margin:8px 0;padding-left:20px}")
                .append(".markdown li{margin:4px 0}")
                .append(".markdown blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #dee0e3;")
                .append("background:#f7f8fa;color:#646a73;border-radius:4px}")
                .append(".markdown code{padding:1px 5px;border-radius:4px;background:#f2f3f5;font-size:13px}")
                .append(".markdown a{color:#3370ff;text-decoration:none}")
                .append(".btn{display:block;margin-top:16px;padding:12px;border-radius:8px;background:#3370ff;color:#fff;")
                .append("font-size:15px;font-weight:500;text-align:center;text-decoration:none}")
                .append(".tip{margin-top:12px;font-size:12px;color:#8f959e;text-align:center}")
                .append("</style></head><body>");
        sb.append("<div class=\"card\"><h1>").append(esc(notice.getTitle())).append("</h1>");
        if (StrUtil.isNotBlank(notice.getContent())) {
            if (Objects.equals(notice.getNoticeType(), WxCpNoticeTypeEnum.MARKDOWN.getType())) {
                // Markdown é€šçŸ¥åœ¨é¢„览页按富文本展示,渲染前已对用户内容做 HTML è½¬ä¹‰
                sb.append("<div class=\"desc markdown\">").append(renderMarkdown(notice.getContent())).append("</div>");
            } else {
                sb.append("<div class=\"desc\">").append(esc(notice.getContent())).append("</div>");
            }
        }
        if (StrUtil.isNotBlank(notice.getLinkUrl())) {
            String link = StrUtil.blankToDefault(notice.getLinkBtnText(), "查看详情");
            sb.append("<a class=\"btn\" href=\"").append(esc(notice.getLinkUrl()))
                    .append("\" target=\"_blank\" rel=\"noopener\">").append(esc(link)).append("</a>");
        }
        sb.append("</div>");
        // å›¾ç‰‡ï¼šæŒ‰åŽŸå›¾æ¯”ä¾‹å®Œæ•´å±•ç¤ºï¼Œç‚¹ä¸€ä¸‹ç”¨æµè§ˆå™¨æ‰“å¼€åŽŸå›¾ï¼Œæ–¹ä¾¿æ”¾å¤§æŸ¥çœ‹
        if (CollUtil.isNotEmpty(images)) {
            sb.append("<div class=\"card\">");
            for (SystemStorageBlobRespVO image : images) {
                String raw = StrUtil.blankToDefault(image.getPreviewURL(), image.getUrl());
                if (StrUtil.isBlank(raw)) {
                    continue;
                }
                String url = toSameOriginUrl(raw);
                sb.append("<a class=\"detail-img\" href=\"").append(esc(url))
                        .append("\" target=\"_blank\" rel=\"noopener\">")
                        .append("<img src=\"").append(esc(url)).append("\" alt=\"通知图片\">")
                        .append("</a>");
            }
            sb.append("</div>");
        }
        // è§†é¢‘:成员点开即可播放
        if (CollUtil.isNotEmpty(videos)) {
            sb.append("<div class=\"card\">");
            for (SystemStorageBlobRespVO video : videos) {
                String raw = StrUtil.blankToDefault(video.getPreviewURL(), video.getUrl());
                if (StrUtil.isBlank(raw)) {
                    continue;
                }
                String url = toSameOriginUrl(raw);
                sb.append("<div class=\"video-wrap\"><video controls preload=\"metadata\" src=\"")
                        .append(esc(url)).append("\"></video></div>");
            }
            sb.append("</div>");
        }
        // é™„件:成员点开即可预览/下载
        if (CollUtil.isNotEmpty(files)) {
            sb.append("<div class=\"card\"><div class=\"file-title\">附件</div><div class=\"file-list\">");
            for (SystemStorageBlobRespVO file : files) {
                String raw = StrUtil.blankToDefault(file.getDownloadURL(),
                        StrUtil.blankToDefault(file.getUrl(), file.getPreviewURL()));
                if (StrUtil.isBlank(raw)) {
                    continue;
                }
                String url = toSameOriginUrl(raw);
                String name = StrUtil.blankToDefault(file.getOriginalFilename(),
                        StrUtil.blankToDefault(file.getName(), "附件"));
                sb.append("<div><a href=\"").append(esc(url)).append("\" target=\"_blank\" rel=\"noopener\">")
                        .append(esc(name)).append("</a>");
                if (file.getByteSize() != null) {
                    sb.append("<span class=\"file-size\">").append(formatSize(file.getByteSize())).append("</span>");
                }
                sb.append("</div>");
            }
            sb.append("</div></div>");
        }
        sb.append("<div class=\"tip\">来自企业微信通知,请在企业微信内查看</div></body></html>");
        return sb.toString();
    }
    private String formatSize(Long bytes) {
        if (bytes == null || bytes <= 0) {
            return "";
        }
        if (bytes < 1024) {
            return bytes + " B";
        }
        if (bytes < 1024 * 1024) {
            return String.format("%.1f KB", bytes / 1024.0);
        }
        return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
    }
    /**
     * æŠŠé™„件地址转成同源相对地址
     *
     * é™„件里存的是带签名的绝对地址,域名取自 yudao.storage.domain(通常是内网地址)。
     * æˆå‘˜æ‰‹æœºé€šè¿‡å…¬ç½‘域名打开本页时直连内网地址会加载不出图片,
     * è¿™é‡Œå‰¥æŽ‰åè®®ä¸ŽåŸŸååªç•™ /admin-api/... è·¯å¾„,交给同源代理转发。
     */
    private String toSameOriginUrl(String url) {
        String trimmed = url.trim();
        if (!StrUtil.startWithAnyIgnoreCase(trimmed, "http://", "https://")) {
            return trimmed;
        }
        try {
            URI uri = URI.create(trimmed);
            String path = uri.getRawPath();
            if (StrUtil.isBlank(path)) {
                return trimmed;
            }
            String query = uri.getRawQuery();
            return StrUtil.isBlank(query) ? path : path + "?" + query;
        } catch (IllegalArgumentException e) {
            // åœ°å€ä¸åˆè§„就原样透出,不能因为一张图片让整页打不开
            log.warn("通知附件地址解析失败,按原样输出:{}", trimmed);
            return trimmed;
        }
    }
    private String renderMessagePage(String title, String desc) {
        return "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">"
                + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
                + "<title>" + esc(title) + "</title><style>"
                + "body{margin:0;padding:60px 24px;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;"
                + "background:#f5f6f8;color:#1f2329;text-align:center}"
                + "h1{font-size:18px;font-weight:600;margin:0 0 12px}"
                + "p{color:#646a73;font-size:14px;line-height:1.7;margin:0}"
                + "</style></head><body><h1>" + esc(title) + "</h1><p>" + esc(desc) + "</p></body></html>";
    }
    /**
     * è½»é‡ Markdown æ¸²æŸ“:只支持企业微信通知常用的标题、加粗、列表、引用、代码、链接、分隔线
     *
     * å®‰å…¨å‰æï¼šæ‰€æœ‰ç”¨æˆ·å†…容先用 {@link #esc(String)} è½¬ä¹‰ï¼Œä¹‹åŽæ‰æ‹¼æŽ¥æ ‡ç­¾ï¼Œ
     * å› æ­¤ä¸ä¼šæ‰§è¡ŒåŽŸå§‹ HTML;链接只允许 http/https/站内路径。
     */
    private String renderMarkdown(String markdown) {
        String normalized = markdown.replace("\r\n", "\n").replace("\r", "\n");
        String[] lines = normalized.split("\n", -1);
        StringBuilder html = new StringBuilder(1024);
        boolean inList = false;
        for (String rawLine : lines) {
            String line = rawLine.trim();
            if (line.isEmpty()) {
                if (inList) {
                    html.append("</ul>");
                    inList = false;
                }
                continue;
            }
            if (line.startsWith("- ") || line.startsWith("* ")) {
                if (!inList) {
                    html.append("<ul>");
                    inList = true;
                }
                html.append("<li>").append(renderInline(line.substring(2))).append("</li>");
                continue;
            }
            if (inList) {
                html.append("</ul>");
                inList = false;
            }
            if (line.startsWith("### ")) {
                html.append("<h3>").append(renderInline(line.substring(4))).append("</h3>");
            } else if (line.startsWith("## ")) {
                html.append("<h2>").append(renderInline(line.substring(3))).append("</h2>");
            } else if (line.startsWith("# ")) {
                html.append("<h1>").append(renderInline(line.substring(2))).append("</h1>");
            } else if (line.startsWith("> ")) {
                html.append("<blockquote>").append(renderInline(line.substring(2))).append("</blockquote>");
            } else if ("---".equals(line) || "***".equals(line)) {
                html.append("<hr>");
            } else {
                html.append("<p>").append(renderInline(line)).append("</p>");
            }
        }
        if (inList) {
            html.append("</ul>");
        }
        return html.toString();
    }
    /**
     * æ¸²æŸ“行内 Markdown;输入已转义,输出只包含受控标签
     */
    private String renderInline(String text) {
        String result = esc(text);
        result = MD_CODE.matcher(result).replaceAll("<code>$1</code>");
        result = MD_STRONG.matcher(result).replaceAll("<strong>$1</strong>");
        Matcher matcher = MD_LINK.matcher(result);
        StringBuilder sb = new StringBuilder();
        while (matcher.find()) {
            String label = matcher.group(1);
            String url = matcher.group(2).trim();
            String replacement = isSafeUrl(url)
                    ? "<a href=\"" + url + "\" target=\"_blank\" rel=\"noopener\">" + label + "</a>"
                    : label;
            matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
        }
        matcher.appendTail(sb);
        return sb.toString();
    }
    /**
     * åªå…è®¸ http/https ä¸Žç«™å†…路径,避免 javascript: ç­‰å±é™©åè®®
     */
    private boolean isSafeUrl(String url) {
        String lower = url.trim().toLowerCase();
        return lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("/");
    }
    /**
     * HTML è½¬ä¹‰ï¼Œé¿å…é€šçŸ¥æ ‡é¢˜/内容里的特殊字符破坏页面或造成 XSS
     */
    private String esc(String raw) {
        if (raw == null) {
            return "";
        }
        return raw.replace("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeArticleRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥å¤šå›¾æ–‡æ¡ç›® Response VO")
@Data
public class WxCpNoticeArticleRespVO {
    @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
    private Long id;
    @Schema(description = "图文标题", example = "关于2026年春季运动会的通知")
    private String title;
    @Schema(description = "图文摘要")
    private String description;
    @Schema(description = "点击跳转链接")
    private String linkUrl;
    @Schema(description = "封面图 Blob ID")
    private Long coverBlobId;
    @Schema(description = "封面图附件(含预览地址,选填)")
    private SystemStorageBlobRespVO coverAttachment;
    @Schema(description = "排序号")
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeArticleSaveReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥å¤šå›¾æ–‡æ¡ç›® åˆ›å»º/修改 Request VO")
@Data
public class WxCpNoticeArticleSaveReqVO {
    @Schema(description = "编号(修改时传,新增不传)", example = "1024")
    private Long id;
    @Schema(description = "图文标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "关于2026年春季运动会的通知")
    @NotEmpty(message = "图文标题不能为空")
    @Size(max = 128, message = "图文标题长度不能超过 128 ä¸ªå­—符")
    private String title;
    @Schema(description = "图文摘要", example = "请各班级于本周五前完成报名")
    @Size(max = 512, message = "图文摘要长度不能超过 512 ä¸ªå­—符")
    private String description;
    @Schema(description = "点击跳转链接", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://example.com/news/1")
    @NotEmpty(message = "图文跳转链接不能为空")
    @Size(max = 1024, message = "跳转链接长度不能超过 1024 ä¸ªå­—符")
    private String linkUrl;
    @Schema(description = "封面图 Blob ID(选填)", example = "1024")
    private Long coverBlobId;
    @Schema(description = "排序号,越小越靠前", example = "1")
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeRespVO.java
@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥ Response VO")
@Data
@@ -18,7 +20,8 @@
    @Schema(description = "通知正文")
    private String content;
    @Schema(description = "通知模板类型", example = "1")
    @Schema(description = "通知模板类型:1纯文本 2图文卡片 3文件通知 4图文消息 5图片通知 6视频通知 7Markdown 8多图文消息",
            example = "1")
    private Integer noticeType;
    @Schema(description = "通知模板类型名称", example = "纯文本通知")
@@ -36,12 +39,36 @@
    @Schema(description = "图文消息封面图地址")
    private String coverUrl;
    @Schema(description = "文件通知的附件 Blob ID")
    @Schema(description = "文件通知的附件 Blob ID(旧版单附件字段)")
    private Long fileBlobId;
    @Schema(description = "文件通知的附件文件名")
    @Schema(description = "文件通知的附件文件名(旧版单附件字段)")
    private String fileName;
    @Schema(description = "图片通知的图片 Blob ID åˆ—表")
    private List<Long> imageBlobIds;
    @Schema(description = "附件通知的附件 Blob ID åˆ—表")
    private List<Long> fileBlobIds;
    @Schema(description = "图片通知的图片列表(含预览地址)")
    private List<SystemStorageBlobRespVO> imageAttachmentList;
    @Schema(description = "附件通知的附件列表(含预览/下载地址)")
    private List<SystemStorageBlobRespVO> fileAttachmentList;
    @Schema(description = "视频通知的视频 Blob ID åˆ—表")
    private List<Long> videoBlobIds;
    @Schema(description = "视频通知的视频列表(含预览地址)")
    private List<SystemStorageBlobRespVO> videoAttachmentList;
    @Schema(description = "多图文消息的图文列表")
    private List<WxCpNoticeArticleRespVO> articleList;
    @Schema(description = "成员预览页地址(图片/附件/视频通知可直接发给成员查看)")
    private String previewUrl;
    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    private Integer status;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/notice/vo/WxCpNoticeSaveReqVO.java
@@ -4,9 +4,12 @@
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡é€šçŸ¥ åˆ›å»º/修改 Request VO")
@Data
@@ -24,7 +27,8 @@
    @Size(max = 10000, message = "通知正文长度不能超过 10000 ä¸ªå­—符")
    private String content;
    @Schema(description = "通知模板类型:1-纯文本 2-图文卡片(填报/链接) 3-文件通知 4-图文消息", example = "1")
    @Schema(description = "通知模板类型:1纯文本 2图文卡片(填报/链接) 3文件通知 4图文消息 5图片通知 6视频通知 7Markdown 8多图文消息",
            example = "1")
    @InEnum(WxCpNoticeTypeEnum.class)
    private Integer noticeType;
@@ -44,13 +48,30 @@
    @Size(max = 1024, message = "封面图地址长度不能超过 1024 ä¸ªå­—符")
    private String coverUrl;
    @Schema(description = "文件通知的附件 Blob ID(先调用 /system/storage-blob/upload ä¸Šä¼ ï¼‰", example = "1024")
    @Schema(description = "文件通知的附件 Blob ID(旧版单附件字段,建议改用 fileBlobIds)", example = "1024")
    private Long fileBlobId;
    @Schema(description = "文件通知的附件文件名", example = "关于开展XX工作的通知.pdf")
    @Schema(description = "文件通知的附件文件名(旧版单附件字段)", example = "关于开展XX工作的通知.pdf")
    @Size(max = 255, message = "文件名长度不能超过 255 ä¸ªå­—符")
    private String fileName;
    @Schema(description = "图片通知的图片 Blob ID åˆ—表(最多 9 å¼ ï¼‰ï¼›null = ä¸æ”¹åŠ¨ï¼Œ[] = æ¸…空", example = "[1024,1025]")
    @Size(max = 9, message = "图片最多上传 9 å¼ ")
    private List<Long> imageBlobIds;
    @Schema(description = "附件通知的附件 Blob ID åˆ—表(最多 5 ä¸ªï¼‰ï¼›null = ä¸æ”¹åŠ¨ï¼Œ[] = æ¸…空", example = "[1024,1025]")
    @Size(max = 5, message = "附件最多上传 5 ä¸ª")
    private List<Long> fileBlobIds;
    @Schema(description = "视频通知的视频 Blob ID åˆ—表(最多 1 ä¸ªï¼‰ï¼›null = ä¸æ”¹åŠ¨ï¼Œ[] = æ¸…空", example = "[1024]")
    @Size(max = 1, message = "视频最多上传 1 ä¸ª")
    private List<Long> videoBlobIds;
    @Schema(description = "多图文消息的图文列表;null = ä¸æ”¹åŠ¨ï¼Œ[] = æ¸…空", example = "[]")
    @Valid
    @Size(max = 8, message = "多图文消息最多 8 ç¯‡")
    private List<WxCpNoticeArticleSaveReqVO> articleList;
    @Schema(description = "推送范围(选填,不传默认全员);推送时仍可在推送弹窗里重新选择", example = "0")
    @InEnum(WxCpPushScopeEnum.class)
    private Integer pushScope;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/vo/WxCpPushReqVO.java
@@ -1,11 +1,15 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.vo;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeArticleSaveReqVO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
@Schema(description = "管理后台 - ä¼ä¸šå¾®ä¿¡æŽ¨é€ Request VO(活动与通知共用)")
@Data
@@ -23,7 +27,8 @@
    @Size(max = 1024, message = "推送目标长度不能超过 1024 ä¸ªå­—符")
    private String pushTarget;
    @Schema(description = "推送时选择的模板类型:1纯文本 2图文卡片 3文件通知 4图文消息;不传沿用通知配置", example = "2")
    @Schema(description = "推送时选择的模板类型:1纯文本 2图文卡片 3文件通知 4图文消息 5图片通知 6视频通知 7Markdown 8多图文消息;不传沿用通知配置",
            example = "2")
    @InEnum(cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum.class)
    private Integer noticeType;
@@ -43,11 +48,28 @@
    @Size(max = 1024, message = "封面图地址长度不能超过 1024 ä¸ªå­—符")
    private String coverUrl;
    @Schema(description = "文件通知的附件 Blob ID")
    @Schema(description = "文件通知的附件 Blob ID(旧版单附件字段,建议改用 fileBlobIds)")
    private Long fileBlobId;
    @Schema(description = "文件通知的附件文件名")
    @Schema(description = "文件通知的附件文件名(旧版单附件字段)")
    @Size(max = 255, message = "文件名长度不能超过 255 ä¸ªå­—符")
    private String fileName;
    @Schema(description = "图片通知的图片 Blob ID åˆ—表;不传沿用通知上已保存的图片", example = "[1024,1025]")
    @Size(max = 9, message = "图片最多上传 9 å¼ ")
    private List<Long> imageBlobIds;
    @Schema(description = "附件通知的附件 Blob ID åˆ—表;不传沿用通知上已保存的附件", example = "[1024,1025]")
    @Size(max = 5, message = "附件最多上传 5 ä¸ª")
    private List<Long> fileBlobIds;
    @Schema(description = "视频通知的视频 Blob ID åˆ—表;不传沿用通知上已保存的视频", example = "[1024]")
    @Size(max = 1, message = "视频最多上传 1 ä¸ª")
    private List<Long> videoBlobIds;
    @Schema(description = "多图文消息的图文列表;不传沿用通知上已保存的图文", example = "[]")
    @Valid
    @Size(max = 8, message = "多图文消息最多 8 ç¯‡")
    private List<WxCpNoticeArticleSaveReqVO> articleList;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/notice/WxCpNoticeArticleDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.wxcp.dal.dataobject.notice;
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.*;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ - å¤šå›¾æ–‡æ¶ˆæ¯æ¡ç›® DO
 *
 * ä»…「多图文消息」模板使用:一条通知可以带多篇图文(企业微信 news æ¶ˆæ¯æœ€å¤š 8 ç¯‡ï¼‰ã€‚
 * å°é¢å›¾ä¸å­˜åœ°å€ï¼Œåªå­˜ blobId,推送时再取可访问地址。
 *
 * @author è¶…级管理员
 */
@TableName("wxcp_notice_article")
@KeySequence("wxcp_notice_article_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WxCpNoticeArticleDO extends BaseDO {
    @TableId
    private Long id;
    /**
     * é€šçŸ¥ç¼–号
     */
    private Long noticeId;
    /**
     * å›¾æ–‡æ ‡é¢˜
     */
    private String title;
    /**
     * å›¾æ–‡æ‘˜è¦
     */
    private String description;
    /**
     * ç‚¹å‡»è·³è½¬é“¾æŽ¥
     */
    private String linkUrl;
    /**
     * å°é¢å›¾ Blob ID(选填)
     */
    private Long coverBlobId;
    /**
     * æŽ’序号
     */
    private Integer sortNo;
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/mysql/notice/WxCpNoticeArticleMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.wxcp.dal.mysql.notice;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeArticleDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ - å¤šå›¾æ–‡æ¡ç›® Mapper
 *
 * @author è¶…级管理员
 */
@Mapper
public interface WxCpNoticeArticleMapper extends BaseMapperX<WxCpNoticeArticleDO> {
    default List<WxCpNoticeArticleDO> selectListByNoticeId(Long noticeId) {
        return selectList(new LambdaQueryWrapperX<WxCpNoticeArticleDO>()
                .eq(WxCpNoticeArticleDO::getNoticeId, noticeId)
                .orderByAsc(WxCpNoticeArticleDO::getSortNo)
                .orderByAsc(WxCpNoticeArticleDO::getId));
    }
    default int deleteByNoticeId(Long noticeId) {
        return delete(new LambdaQueryWrapperX<WxCpNoticeArticleDO>()
                .eq(WxCpNoticeArticleDO::getNoticeId, noticeId));
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/ErrorCodeConstants.java
@@ -57,6 +57,21 @@
    ErrorCode NOTICE_FILE_REQUIRED = new ErrorCode(1_050_003_006, "文件通知必须上传文件,请上传后再推送");
    ErrorCode NOTICE_FILE_NOT_EXISTS = new ErrorCode(1_050_003_007, "通知文件不存在或已被删除,请重新上传");
    ErrorCode NOTICE_FILE_TOO_LARGE = new ErrorCode(1_050_003_008, "通知文件超过企业微信 20MB é™åˆ¶ï¼Œè¯·åŽ‹ç¼©æˆ–æ”¹ç”¨å›¾æ–‡å¡ç‰‡æŽ¨é€ä¸‹è½½é“¾æŽ¥");
    ErrorCode NOTICE_IMAGE_REQUIRED = new ErrorCode(1_050_003_009, "图片通知必须至少上传 1 å¼ å›¾ç‰‡ï¼Œè¯·ä¸Šä¼ åŽå†æŽ¨é€");
    ErrorCode NOTICE_IMAGE_ONLY = new ErrorCode(1_050_003_010, "图片通知只能上传图片文件,请重新选择");
    ErrorCode NOTICE_IMAGE_TOO_LARGE = new ErrorCode(1_050_003_011, "通知图片超过企业微信 10MB é™åˆ¶ï¼Œè¯·åŽ‹ç¼©åŽé‡æ–°ä¸Šä¼ ");
    ErrorCode NOTICE_IMAGE_COUNT_EXCEEDED = new ErrorCode(1_050_003_012, "图片通知最多上传 9 å¼ å›¾ç‰‡");
    ErrorCode NOTICE_FILE_COUNT_EXCEEDED = new ErrorCode(1_050_003_013, "附件通知最多上传 5 ä¸ªé™„ä»¶");
    ErrorCode NOTICE_VIDEO_REQUIRED = new ErrorCode(1_050_003_014, "视频通知必须上传 1 ä¸ªè§†é¢‘,请上传后再推送");
    ErrorCode NOTICE_VIDEO_ONLY = new ErrorCode(1_050_003_015, "视频通知只能上传视频文件(如 mp4),请重新选择");
    ErrorCode NOTICE_VIDEO_TOO_LARGE = new ErrorCode(1_050_003_016, "通知视频超过企业微信 10MB é™åˆ¶ï¼Œè¯·åŽ‹ç¼©åŽé‡æ–°ä¸Šä¼ ");
    ErrorCode NOTICE_MARKDOWN_CONTENT_REQUIRED = new ErrorCode(1_050_003_017, "Markdown é€šçŸ¥çš„æ­£æ–‡ä¸èƒ½ä¸ºç©ºï¼Œè¯·å¡«å†™é€šçŸ¥å†…容");
    ErrorCode NOTICE_MARKDOWN_TOO_LONG = new ErrorCode(1_050_003_018,
            "Markdown é€šçŸ¥æ­£æ–‡è¶…过企业微信 2048 å­—节限制(当前 {} å­—节),请精简后再推送");
    ErrorCode NOTICE_ARTICLE_REQUIRED = new ErrorCode(1_050_003_019, "多图文消息至少需要 1 ç¯‡å›¾æ–‡ï¼Œè¯·æ·»åŠ åŽå†æŽ¨é€");
    ErrorCode NOTICE_ARTICLE_TITLE_REQUIRED = new ErrorCode(1_050_003_020, "第 {} ç¯‡å›¾æ–‡çš„æ ‡é¢˜ä¸èƒ½ä¸ºç©ºï¼Œè¯·å¡«å†™æ ‡é¢˜");
    ErrorCode NOTICE_ARTICLE_URL_REQUIRED = new ErrorCode(1_050_003_021, "第 {} ç¯‡å›¾æ–‡çš„跳转链接不能为空,请填写链接地址");
    ErrorCode NOTICE_ARTICLE_COUNT_EXCEEDED = new ErrorCode(1_050_003_022, "多图文消息最多添加 8 ç¯‡å›¾æ–‡ï¼Œè¯·åˆ é™¤å¤šä½™æ¡ç›®");
    // ========== ä¼ä¸šå¾®ä¿¡é€šè®¯å½• 1-050-004-000 ==========
    ErrorCode WXCP_API_ERROR = new ErrorCode(1_050_004_000, "调用企业微信接口失败:errcode={},errmsg={}");
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/enums/notice/WxCpNoticeTypeEnum.java
@@ -18,6 +18,10 @@
    CARD(2, "图文卡片(填报/链接)"),
    FILE(3, "文件通知"),
    NEWS(4, "图文消息"),
    IMAGE(5, "图片通知"),
    VIDEO(6, "视频通知"),
    MARKDOWN(7, "Markdown é€šçŸ¥"),
    MULTI_NEWS(8, "多图文消息"),
    ;
    public static final Integer[] ARRAYS = Arrays.stream(values())
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeService.java
@@ -1,12 +1,16 @@
package cn.iocoder.yudao.module.wxcp.service.notice;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeSaveReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeArticleDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import jakarta.validation.Valid;
import java.util.List;
/**
 * ä¼ä¸šå¾®ä¿¡é€šçŸ¥ Service æŽ¥å£
@@ -54,6 +58,46 @@
    PageResult<WxCpNoticeDO> getNoticePage(@Valid WxCpNoticePageReqVO pageReqVO);
    /**
     * èŽ·å¾—å›¾ç‰‡é€šçŸ¥çš„å›¾ç‰‡åˆ—è¡¨
     *
     * @param noticeId é€šçŸ¥ç¼–号
     * @return å›¾ç‰‡åˆ—表(含预览地址)
     */
    List<SystemStorageBlobRespVO> getNoticeImageAttachments(Long noticeId);
    /**
     * èŽ·å¾—é™„ä»¶é€šçŸ¥çš„é™„ä»¶åˆ—è¡¨
     *
     * @param noticeId é€šçŸ¥ç¼–号
     * @return é™„件列表(含预览/下载地址)
     */
    List<SystemStorageBlobRespVO> getNoticeFileAttachments(Long noticeId);
    /**
     * èŽ·å¾—è§†é¢‘é€šçŸ¥çš„è§†é¢‘åˆ—è¡¨
     *
     * @param noticeId é€šçŸ¥ç¼–号
     * @return è§†é¢‘列表(含预览地址)
     */
    List<SystemStorageBlobRespVO> getNoticeVideoAttachments(Long noticeId);
    /**
     * èŽ·å¾—å¤šå›¾æ–‡æ¶ˆæ¯çš„å›¾æ–‡åˆ—è¡¨
     *
     * @param noticeId é€šçŸ¥ç¼–号
     * @return å›¾æ–‡åˆ—表
     */
    List<WxCpNoticeArticleDO> getNoticeArticles(Long noticeId);
    /**
     * èŽ·å¾—å¤šå›¾æ–‡æ¡ç›®çš„å°é¢é™„ä»¶
     *
     * @param articleId å›¾æ–‡æ¡ç›®ç¼–号
     * @return å°é¢é™„件列表(最多 1 å¼ ï¼‰
     */
    List<SystemStorageBlobRespVO> getNoticeArticleCoverAttachments(Long articleId);
    /**
     * å‘布通知(草稿 -&gt; å·²å‘布)
     *
     * @param id é€šçŸ¥ç¼–号
@@ -75,4 +119,12 @@
     */
    WxCpPushResultVO pushNotice(@Valid WxCpPushReqVO pushReqVO);
    /**
     * æ‹¼è£…通知预览页地址,成员在企业微信内打开后可查看图片与附件
     *
     * @param id é€šçŸ¥ç¼–号
     * @return é¢„览页地址;未配置外网地址前缀时返回 null
     */
    String buildPreviewUrl(Long id);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java
@@ -1,28 +1,44 @@
package cn.iocoder.yudao.module.wxcp.service.notice;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentListReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageAttachmentSaveReqVO;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageAttachmentService;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeArticleSaveReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticePageReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.notice.vo.WxCpNoticeSaveReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushReqVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.vo.WxCpPushResultVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeArticleDO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
import cn.iocoder.yudao.module.wxcp.dal.mysql.notice.WxCpNoticeArticleMapper;
import cn.iocoder.yudao.module.wxcp.dal.mysql.notice.WxCpNoticeMapper;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
import cn.iocoder.yudao.module.wxcp.framework.config.WxCpProperties;
import cn.iocoder.yudao.module.wxcp.service.push.WxCpMessagePushService;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.cp.bean.article.NewArticle;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.wxcp.enums.ErrorCodeConstants.*;
@@ -43,15 +59,78 @@
     */
    private static final int MAX_PUSH_CONTENT_LENGTH = 600;
    /**
     * å›¾ç‰‡é€šçŸ¥çš„图片关联记录类型,对应 system_storage_attachment.record_type
     */
    public static final String NOTICE_IMAGE_RECORD_TYPE = "wxcp_notice_image";
    /**
     * é™„件通知的附件关联记录类型,对应 system_storage_attachment.record_type
     */
    public static final String NOTICE_FILE_RECORD_TYPE = "wxcp_notice_file";
    /**
     * è§†é¢‘通知的视频关联记录类型,对应 system_storage_attachment.record_type
     */
    public static final String NOTICE_VIDEO_RECORD_TYPE = "wxcp_notice_video";
    /**
     * å¤šå›¾æ–‡å°é¢å›¾çš„关联记录类型,record_id ä¸ºå›¾æ–‡æ¡ç›®ç¼–号
     */
    public static final String NOTICE_ARTICLE_COVER_RECORD_TYPE = "wxcp_notice_article_cover";
    /**
     * é™„件用途,与其它模块保持一致
     */
    private static final String ATTACHMENT_APPLICATION = "file";
    /**
     * ä¼ä¸šå¾®ä¿¡æ–‡ä»¶æ¶ˆæ¯ä¸Šé™ 20MB
     */
    private static final long MAX_FILE_SIZE = 20L * 1024L * 1024L;
    /**
     * ä¼ä¸šå¾®ä¿¡å›¾ç‰‡æ¶ˆæ¯ä¸Šé™ 10MB
     */
    private static final long MAX_IMAGE_SIZE = 10L * 1024L * 1024L;
    /**
     * ä¼ä¸šå¾®ä¿¡è§†é¢‘消息上限 10MB
     */
    private static final long MAX_VIDEO_SIZE = 10L * 1024L * 1024L;
    /**
     * ä¼ä¸šå¾®ä¿¡ Markdown æ¶ˆæ¯ä¸Šé™ 2048 å­—节
     */
    private static final int MAX_MARKDOWN_BYTES = 2048;
    /**
     * ä¸€æ¬¡æŽ¨é€çš„图片数量上限
     */
    private static final int MAX_IMAGE_COUNT = 9;
    /**
     * ä¸€æ¬¡æŽ¨é€çš„附件数量上限
     */
    private static final int MAX_FILE_COUNT = 5;
    /**
     * å¤šå›¾æ–‡æ¶ˆæ¯æœ€å¤šç¯‡æ•°
     */
    private static final int MAX_ARTICLE_COUNT = 8;
    @Resource
    private WxCpNoticeMapper wxcpNoticeMapper;
    @Resource
    private WxCpNoticeArticleMapper wxcpNoticeArticleMapper;
    @Resource
    private WxCpMessagePushService messagePushService;
    @Resource
    private SystemStorageBlobService storageBlobService;
    /** ä¼ä¸šå¾®ä¿¡æ–‡ä»¶æ¶ˆæ¯ä¸Šé™ 20MB */
    private static final long MAX_FILE_SIZE = 20L * 1024L * 1024L;
    @Resource
    private SystemStorageAttachmentService storageAttachmentService;
    @Resource
    private WxCpProperties wxCpProperties;
    @Override
    public Long createNotice(WxCpNoticeSaveReqVO createReqVO) {
@@ -66,6 +145,14 @@
            notice.setPushScope(WxCpPushScopeEnum.ALL.getScope());
        }
        wxcpNoticeMapper.insert(notice);
        // ç´ æç»Ÿä¸€èµ°é™„件中间表;新建时 null ä¸Žç©ºé›†åˆéƒ½è¡¨ç¤ºæ²¡æœ‰ç´ æ
        bindAttachments(NOTICE_IMAGE_RECORD_TYPE, notice.getId(), createReqVO.getImageBlobIds());
        List<Long> fileBlobIds = resolveFileBlobIds(createReqVO);
        bindAttachments(NOTICE_FILE_RECORD_TYPE, notice.getId(), fileBlobIds);
        bindAttachments(NOTICE_VIDEO_RECORD_TYPE, notice.getId(), createReqVO.getVideoBlobIds());
        syncFileSnapshot(notice.getId(), fileBlobIds, createReqVO.getFileName());
        saveArticles(notice.getId(), createReqVO.getArticleList());
        return notice.getId();
    }
@@ -81,12 +168,37 @@
        updateObj.setPushStatus(null);
        updateObj.setPushTime(null);
        wxcpNoticeMapper.updateById(updateObj);
        // ç´ æåˆ—表 null = ä¸æ”¹åŠ¨ï¼Œ[] = æ¸…空;这样只改标题时不会误删已上传的素材
        if (updateReqVO.getImageBlobIds() != null) {
            bindAttachments(NOTICE_IMAGE_RECORD_TYPE, notice.getId(), updateReqVO.getImageBlobIds());
        }
        if (updateReqVO.getVideoBlobIds() != null) {
            bindAttachments(NOTICE_VIDEO_RECORD_TYPE, notice.getId(), updateReqVO.getVideoBlobIds());
        }
        List<Long> fileBlobIds = resolveFileBlobIds(updateReqVO);
        if (fileBlobIds != null) {
            bindAttachments(NOTICE_FILE_RECORD_TYPE, notice.getId(), fileBlobIds);
            syncFileSnapshot(notice.getId(), fileBlobIds, updateReqVO.getFileName());
        }
        if (updateReqVO.getArticleList() != null) {
            saveArticles(notice.getId(), updateReqVO.getArticleList());
        }
    }
    @Override
    public void deleteNotice(Long id) {
        validateNoticeExists(id);
        wxcpNoticeMapper.deleteById(id);
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(NOTICE_IMAGE_RECORD_TYPE, id);
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(NOTICE_FILE_RECORD_TYPE, id);
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(NOTICE_VIDEO_RECORD_TYPE, id);
        // å›¾æ–‡æ¡ç›®åŠå…¶å°é¢é™„件一起清理
        for (WxCpNoticeArticleDO article : wxcpNoticeArticleMapper.selectListByNoticeId(id)) {
            storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(
                    NOTICE_ARTICLE_COVER_RECORD_TYPE, article.getId());
        }
        wxcpNoticeArticleMapper.deleteByNoticeId(id);
    }
    @Override
@@ -97,6 +209,31 @@
    @Override
    public PageResult<WxCpNoticeDO> getNoticePage(WxCpNoticePageReqVO pageReqVO) {
        return wxcpNoticeMapper.selectPage(pageReqVO);
    }
    @Override
    public List<SystemStorageBlobRespVO> getNoticeImageAttachments(Long noticeId) {
        return listAttachments(NOTICE_IMAGE_RECORD_TYPE, noticeId);
    }
    @Override
    public List<SystemStorageBlobRespVO> getNoticeFileAttachments(Long noticeId) {
        return listAttachments(NOTICE_FILE_RECORD_TYPE, noticeId);
    }
    @Override
    public List<SystemStorageBlobRespVO> getNoticeVideoAttachments(Long noticeId) {
        return listAttachments(NOTICE_VIDEO_RECORD_TYPE, noticeId);
    }
    @Override
    public List<WxCpNoticeArticleDO> getNoticeArticles(Long noticeId) {
        return wxcpNoticeArticleMapper.selectListByNoticeId(noticeId);
    }
    @Override
    public List<SystemStorageBlobRespVO> getNoticeArticleCoverAttachments(Long articleId) {
        return listAttachments(NOTICE_ARTICLE_COVER_RECORD_TYPE, articleId);
    }
    @Override
@@ -141,8 +278,20 @@
        String linkUrl = StrUtil.blankToDefault(pushReqVO.getLinkUrl(), notice.getLinkUrl());
        String linkBtnText = StrUtil.blankToDefault(pushReqVO.getLinkBtnText(), notice.getLinkBtnText());
        String coverUrl = StrUtil.blankToDefault(pushReqVO.getCoverUrl(), notice.getCoverUrl());
        Long fileBlobId = pushReqVO.getFileBlobId() != null ? pushReqVO.getFileBlobId() : notice.getFileBlobId();
        String fileName = StrUtil.blankToDefault(pushReqVO.getFileName(), notice.getFileName());
        // æœ¬æ¬¡æŽ¨é€çš„素材:请求里传了就覆盖,没传则沿用通知上已保存的
        List<Long> imageBlobIds = pushReqVO.getImageBlobIds() != null
                ? pushReqVO.getImageBlobIds() : toBlobIds(getNoticeImageAttachments(notice.getId()));
        List<Long> fileBlobIds = pushReqVO.getFileBlobIds() != null
                ? pushReqVO.getFileBlobIds() : toBlobIds(getNoticeFileAttachments(notice.getId()));
        List<Long> videoBlobIds = pushReqVO.getVideoBlobIds() != null
                ? pushReqVO.getVideoBlobIds() : toBlobIds(getNoticeVideoAttachments(notice.getId()));
        List<WxCpNoticeArticleSaveReqVO> articles = pushReqVO.getArticleList() != null
                ? pushReqVO.getArticleList() : toArticleReqList(getNoticeArticles(notice.getId()));
        if (CollUtil.isEmpty(fileBlobIds) && notice.getFileBlobId() != null && notice.getFileBlobId() > 0) {
            // å…¼å®¹æ—§æ•°æ®ï¼šåŽ†å²ä¸Šæ–‡ä»¶é€šçŸ¥åªå­˜äº†ä¸€ä¸ª fileBlobId
            fileBlobIds = List.of(notice.getFileBlobId());
        }
        // æŒ‰æœ¬æ¬¡é€‰æ‹©çš„æ¨¡æ¿ç±»åž‹æ ¡éªŒå¿…填项
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.TEXT.getType())
@@ -154,8 +303,29 @@
                && StrUtil.isBlank(linkUrl)) {
            throw exception(NOTICE_LINK_URL_REQUIRED);
        }
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.FILE.getType()) && fileBlobId == null) {
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.FILE.getType())
                && CollUtil.isEmpty(fileBlobIds)) {
            throw exception(NOTICE_FILE_REQUIRED);
        }
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.IMAGE.getType())
                && CollUtil.isEmpty(imageBlobIds)) {
            throw exception(NOTICE_IMAGE_REQUIRED);
        }
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.VIDEO.getType())
                && CollUtil.isEmpty(videoBlobIds)) {
            throw exception(NOTICE_VIDEO_REQUIRED);
        }
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.MARKDOWN.getType())) {
            validateMarkdownContent(buildMarkdownContent(notice));
        }
        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.MULTI_NEWS.getType())) {
            validateArticles(articles);
        }
        if (imageBlobIds.size() > MAX_IMAGE_COUNT) {
            throw exception(NOTICE_IMAGE_COUNT_EXCEEDED);
        }
        if (fileBlobIds.size() > MAX_FILE_COUNT) {
            throw exception(NOTICE_FILE_COUNT_EXCEEDED);
        }
        WxCpPushResultVO result;
@@ -164,11 +334,19 @@
                    StrUtil.blankToDefault(summary, notice.getContent()),
                    linkUrl, StrUtil.blankToDefault(linkBtnText, "去填报"), scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.FILE.getType())) {
            result = pushFileNotice(fileBlobId, fileName, notice, scope, target);
            result = pushFileNotice(fileBlobIds, notice, scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.NEWS.getType())) {
            result = messagePushService.pushNews(notice.getTitle(),
                    StrUtil.blankToDefault(summary, notice.getContent()),
                    linkUrl, coverUrl, scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.IMAGE.getType())) {
            result = pushImageNotice(imageBlobIds, notice, scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.VIDEO.getType())) {
            result = pushVideoNotice(videoBlobIds.get(0), notice, scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.MARKDOWN.getType())) {
            result = messagePushService.pushMarkdown(buildMarkdownContent(notice), scope, target);
        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.MULTI_NEWS.getType())) {
            result = pushMultiNewsNotice(articles, scope, target);
        } else {
            result = messagePushService.pushText(buildPushContent(notice), scope, target);
        }
@@ -186,47 +364,150 @@
    }
    /**
     * æ–‡ä»¶é€šçŸ¥ï¼šä»Žç³»ç»Ÿé™„件中心取文件,交给推送服务上传企业微信并发送
     * å›¾ç‰‡é€šçŸ¥ï¼šå…ˆå‘一条文字说明(有正文时),再逐张发送图片
     */
    private WxCpPushResultVO pushFileNotice(Long fileBlobId, String fileName, WxCpNoticeDO notice,
                                            Integer scope, String target) {
        if (fileBlobId == null) {
            throw exception(NOTICE_FILE_REQUIRED);
    private WxCpPushResultVO pushImageNotice(List<Long> imageBlobIds, WxCpNoticeDO notice,
                                             Integer scope, String target) {
        List<WxCpPushResultVO> results = new ArrayList<>();
        if (StrUtil.isNotBlank(notice.getContent())) {
            // æ–‡å­—失败不影响后续图片发送,最终由 aggregatePushResults æ±‡æ€»
            results.add(messagePushService.pushText(buildPushContent(notice), scope, target));
        }
        SystemStorageBlobDO blob = storageBlobService.getStorageBlob(fileBlobId);
        for (Long blobId : imageBlobIds) {
            SystemStorageBlobDO blob = storageBlobService.getStorageBlob(blobId);
            if (blob == null) {
                throw exception(NOTICE_FILE_NOT_EXISTS);
            }
            if (StrUtil.isNotBlank(blob.getContentType())
                    && !StrUtil.startWithIgnoreCase(blob.getContentType(), "image/")) {
                throw exception(NOTICE_IMAGE_ONLY);
            }
            if (blob.getByteSize() != null && blob.getByteSize() > MAX_IMAGE_SIZE) {
                throw exception(NOTICE_IMAGE_TOO_LARGE);
            }
            File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (file == null || !file.exists()) {
                throw exception(NOTICE_FILE_NOT_EXISTS);
            }
            results.add(messagePushService.pushImage(file, scope, target));
        }
        // å›¾ç‰‡æ¶ˆæ¯æœ¬èº«ä¸å¯ç‚¹å‡»ï¼Œå†è¡¥ä¸€å¼ å¡ç‰‡æŒ‡å‘预览页,成员可在页内逐张放大查看
        appendPreviewCard(results, notice, "查看全部图片", scope, target);
        return aggregatePushResults(results, "图片");
    }
    /**
     * é™„件通知:先发一条文字说明(有正文时),再逐个发送文件
     */
    private WxCpPushResultVO pushFileNotice(List<Long> fileBlobIds, WxCpNoticeDO notice,
                                            Integer scope, String target) {
        List<WxCpPushResultVO> results = new ArrayList<>();
        if (StrUtil.isNotBlank(notice.getContent())) {
            results.add(messagePushService.pushText(buildPushContent(notice), scope, target));
        }
        for (Long blobId : fileBlobIds) {
            SystemStorageBlobDO blob = storageBlobService.getStorageBlob(blobId);
            if (blob == null) {
                throw exception(NOTICE_FILE_NOT_EXISTS);
            }
            if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
                throw exception(NOTICE_FILE_TOO_LARGE);
            }
            File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (file == null || !file.exists()) {
                throw exception(NOTICE_FILE_NOT_EXISTS);
            }
            String actualFileName = StrUtil.blankToDefault(blob.getOriginalFilename(), blob.getUidFilename());
            results.add(messagePushService.pushFile(file, actualFileName, null, scope, target));
        }
        // æ–‡ä»¶æ¶ˆæ¯åªèƒ½ä¸‹è½½å•个附件,再补一张卡片指向预览页,成员可在页内看到全部附件
        appendPreviewCard(results, notice, "查看全部附件", scope, target);
        return aggregatePushResults(results, "附件");
    }
    /**
     * è§†é¢‘通知:发送视频消息,并补一张卡片指向预览页
     */
    private WxCpPushResultVO pushVideoNotice(Long videoBlobId, WxCpNoticeDO notice,
                                             Integer scope, String target) {
        SystemStorageBlobDO blob = storageBlobService.getStorageBlob(videoBlobId);
        if (blob == null) {
            throw exception(NOTICE_FILE_NOT_EXISTS);
        }
        if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
            throw exception(NOTICE_FILE_TOO_LARGE);
        if (StrUtil.isNotBlank(blob.getContentType())
                && !StrUtil.startWithIgnoreCase(blob.getContentType(), "video/")) {
            throw exception(NOTICE_VIDEO_ONLY);
        }
        if (blob.getByteSize() != null && blob.getByteSize() > MAX_VIDEO_SIZE) {
            throw exception(NOTICE_VIDEO_TOO_LARGE);
        }
        File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
        if (file == null || !file.exists()) {
            throw exception(NOTICE_FILE_NOT_EXISTS);
        }
        String actualFileName = StrUtil.blankToDefault(fileName, blob.getOriginalFilename());
        return messagePushService.pushFile(file, actualFileName, buildPushContent(notice), scope, target);
        List<WxCpPushResultVO> results = new ArrayList<>();
        String description = StrUtil.blankToDefault(notice.getSummary(), notice.getContent());
        if (description.length() > 300) {
            description = description.substring(0, 300) + "……";
        }
        results.add(messagePushService.pushVideo(file, notice.getTitle(), description, scope, target));
        appendPreviewCard(results, notice, "查看通知详情", scope, target);
        return aggregatePushResults(results, "视频");
    }
    /**
     * æŒ‰æ¨¡æ¿ç±»åž‹æ ¡éªŒå¿…填项
     * å¤šå›¾æ–‡æ¶ˆæ¯ï¼šä¸€æ¬¡æ¶ˆæ¯å‘送多篇图文
     */
    private void validateNoticeByType(WxCpNoticeSaveReqVO reqVO) {
        Integer type = reqVO.getNoticeType() != null
                ? reqVO.getNoticeType() : WxCpNoticeTypeEnum.TEXT.getType();
        if (Objects.equals(type, WxCpNoticeTypeEnum.TEXT.getType())
                && StrUtil.isBlank(reqVO.getContent())) {
            throw exception(NOTICE_CONTENT_REQUIRED);
    private WxCpPushResultVO pushMultiNewsNotice(List<WxCpNoticeArticleSaveReqVO> articles,
                                                 Integer scope, String target) {
        List<NewArticle> newsList = new ArrayList<>(articles.size());
        for (WxCpNoticeArticleSaveReqVO article : articles) {
            newsList.add(NewArticle.builder()
                    .title(StrUtil.trim(article.getTitle()))
                    .description(StrUtil.blankToDefault(article.getDescription(), ""))
                    .url(StrUtil.trim(article.getLinkUrl()))
                    .picUrl(resolveArticleCoverUrl(article))
                    .build());
        }
        if ((Objects.equals(type, WxCpNoticeTypeEnum.CARD.getType())
                || Objects.equals(type, WxCpNoticeTypeEnum.NEWS.getType()))
                && StrUtil.isBlank(reqVO.getLinkUrl())) {
            throw exception(NOTICE_LINK_URL_REQUIRED);
        return messagePushService.pushMultiNews(newsList, scope, target);
    }
    /**
     * å¤šå›¾æ–‡å°é¢åœ°å€ï¼šå–图文条目绑定的封面附件(选填)
     */
    private String resolveArticleCoverUrl(WxCpNoticeArticleSaveReqVO article) {
        Long articleId = article.getId();
        if (articleId == null) {
            // æŽ¨é€æ—¶ä¸´æ—¶æ·»åŠ ã€æœªè½åº“çš„å›¾æ–‡æ²¡æœ‰é™„ä»¶å…³ç³»ï¼Œç›´æŽ¥å¿½ç•¥å°é¢
            return null;
        }
        if (Objects.equals(type, WxCpNoticeTypeEnum.FILE.getType())
                && reqVO.getFileBlobId() == null) {
            throw exception(NOTICE_FILE_REQUIRED);
        List<SystemStorageBlobRespVO> covers = listAttachments(NOTICE_ARTICLE_COVER_RECORD_TYPE, articleId);
        if (CollUtil.isEmpty(covers)) {
            return null;
        }
        return StrUtil.blankToDefault(covers.get(0).getPreviewURL(), covers.get(0).getUrl());
    }
    /**
     * å¤šæ¡æ¶ˆæ¯åˆå¹¶ç»“果:任一条失败即视为整体失败,并把失败原因汇总给前端
     */
    private WxCpPushResultVO aggregatePushResults(List<WxCpPushResultVO> results, String label) {
        if (CollUtil.isEmpty(results)) {
            return new WxCpPushResultVO(false, null, label + "推送内容为空", null, null);
        }
        List<WxCpPushResultVO> failures = results.stream()
                .filter(r -> !Boolean.TRUE.equals(r.getSuccess()))
                .toList();
        if (failures.isEmpty()) {
            return new WxCpPushResultVO(true, 0, "推送成功", null, null);
        }
        String message = failures.stream()
                .map(WxCpPushResultVO::getMessage)
                .filter(StrUtil::isNotBlank)
                .distinct()
                .collect(Collectors.joining(";"));
        return new WxCpPushResultVO(false, failures.get(0).getErrCode(),
                StrUtil.blankToDefault(message, label + "推送失败"), null, null);
    }
    /**
@@ -242,6 +523,229 @@
        return content;
    }
    /**
     * Markdown æ­£æ–‡ï¼šæŠŠé€šçŸ¥æ ‡é¢˜ä½œä¸ºä¸€çº§æ ‡é¢˜æ‹¼åœ¨æ­£æ–‡å‰ï¼Œä¾¿äºŽæˆå‘˜ç¬¬ä¸€çœ¼çœ‹åˆ°ä¸»é¢˜
     */
    private String buildMarkdownContent(WxCpNoticeDO notice) {
        String body = StrUtil.blankToDefault(notice.getContent(), "");
        if (StrUtil.isBlank(body)) {
            return "## " + notice.getTitle();
        }
        if (body.startsWith("#")) {
            return body;
        }
        return "## " + notice.getTitle() + "\n" + body;
    }
    /**
     * æ ¡éªŒ Markdown å†…容是否超出企业微信 2048 å­—节限制
     */
    private void validateMarkdownContent(String content) {
        if (StrUtil.isBlank(content)) {
            throw exception(NOTICE_MARKDOWN_CONTENT_REQUIRED);
        }
        int bytes = content.getBytes(StandardCharsets.UTF_8).length;
        if (bytes > MAX_MARKDOWN_BYTES) {
            throw exception(NOTICE_MARKDOWN_TOO_LONG, bytes);
        }
    }
    /**
     * æ ¡éªŒå¤šå›¾æ–‡æ¶ˆæ¯çš„æ¡ç›®
     */
    private void validateArticles(List<WxCpNoticeArticleSaveReqVO> articles) {
        if (CollUtil.isEmpty(articles)) {
            throw exception(NOTICE_ARTICLE_REQUIRED);
        }
        if (articles.size() > MAX_ARTICLE_COUNT) {
            throw exception(NOTICE_ARTICLE_COUNT_EXCEEDED);
        }
        int index = 1;
        for (WxCpNoticeArticleSaveReqVO article : articles) {
            if (article == null || StrUtil.isBlank(article.getTitle())) {
                throw exception(NOTICE_ARTICLE_TITLE_REQUIRED, index);
            }
            if (StrUtil.isBlank(article.getLinkUrl())) {
                throw exception(NOTICE_ARTICLE_URL_REQUIRED, index);
            }
            index++;
        }
    }
    // ==================== é™„件与图文 ====================
    /**
     * ç»‘定图片/附件/视频到通知
     *
     * èµ° system æ¨¡å—的附件中间表,业务表不加文件地址列、不自建 upload æŽ¥å£ã€‚
     * ä¼ ç©ºé›†åˆè¡¨ç¤ºæ¸…空关联(只删中间表记录,不删文件本身)。
     */
    private void bindAttachments(String recordType, Long recordId, List<Long> blobIds) {
        SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
        bindReqVO.setApplication(ATTACHMENT_APPLICATION);
        bindReqVO.setRecordType(recordType);
        bindReqVO.setRecordId(recordId);
        bindReqVO.setBlobItems(toBlobItems(blobIds));
        storageAttachmentService.bindAttachments(bindReqVO);
    }
    private List<SystemStorageAttachmentSaveReqVO.BlobItem> toBlobItems(List<Long> blobIds) {
        if (CollUtil.isEmpty(blobIds)) {
            return Collections.emptyList();
        }
        return blobIds.stream()
                .filter(Objects::nonNull)
                .distinct()
                .map(blobId -> {
                    SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem();
                    item.setBlobId(blobId);
                    return item;
                })
                .toList();
    }
    private List<SystemStorageBlobRespVO> listAttachments(String recordType, Long recordId) {
        SystemStorageAttachmentListReqVO listReqVO = new SystemStorageAttachmentListReqVO();
        listReqVO.setApplication(ATTACHMENT_APPLICATION);
        listReqVO.setRecordType(recordType);
        listReqVO.setRecordId(recordId);
        return storageAttachmentService.listAttachments(listReqVO);
    }
    private List<Long> toBlobIds(List<SystemStorageBlobRespVO> attachments) {
        if (CollUtil.isEmpty(attachments)) {
            return Collections.emptyList();
        }
        // åº•层是 selectByIds,不承诺返回顺序,这里按 blobId å‡åºï¼ˆå³ä¸Šä¼ å…ˆåŽï¼‰ä¿è¯æŽ¨é€é¡ºåºç¨³å®š
        return attachments.stream()
                .map(SystemStorageBlobRespVO::getId)
                .filter(Objects::nonNull)
                .sorted()
                .toList();
    }
    /**
     * ä¿å­˜å¤šå›¾æ–‡æ¡ç›®ï¼šå…ˆæ¸…空再插入,保证提交顺序就是展示顺序
     */
    private void saveArticles(Long noticeId, List<WxCpNoticeArticleSaveReqVO> articles) {
        if (articles == null) {
            return;
        }
        // æ¸…掉旧条目及其封面附件关系
        for (WxCpNoticeArticleDO article : wxcpNoticeArticleMapper.selectListByNoticeId(noticeId)) {
            storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(
                    NOTICE_ARTICLE_COVER_RECORD_TYPE, article.getId());
        }
        wxcpNoticeArticleMapper.deleteByNoticeId(noticeId);
        int index = 1;
        for (WxCpNoticeArticleSaveReqVO item : articles) {
            if (item == null) {
                continue;
            }
            WxCpNoticeArticleDO article = WxCpNoticeArticleDO.builder()
                    .noticeId(noticeId)
                    .title(StrUtil.trim(item.getTitle()))
                    .description(StrUtil.trim(item.getDescription()))
                    .linkUrl(StrUtil.trim(item.getLinkUrl()))
                    .coverBlobId(item.getCoverBlobId())
                    .sortNo(item.getSortNo() != null ? item.getSortNo() : index)
                    .build();
            wxcpNoticeArticleMapper.insert(article);
            if (item.getCoverBlobId() != null) {
                bindAttachments(NOTICE_ARTICLE_COVER_RECORD_TYPE, article.getId(),
                        List.of(item.getCoverBlobId()));
            }
            index++;
        }
    }
    private List<WxCpNoticeArticleSaveReqVO> toArticleReqList(List<WxCpNoticeArticleDO> articles) {
        if (CollUtil.isEmpty(articles)) {
            return Collections.emptyList();
        }
        return articles.stream().map(article -> {
            WxCpNoticeArticleSaveReqVO item = new WxCpNoticeArticleSaveReqVO();
            item.setId(article.getId());
            item.setTitle(article.getTitle());
            item.setDescription(article.getDescription());
            item.setLinkUrl(article.getLinkUrl());
            item.setCoverBlobId(article.getCoverBlobId());
            item.setSortNo(article.getSortNo());
            return item;
        }).toList();
    }
    /**
     * å…¼å®¹æ—§ç‰ˆå•附件字段 fileBlobId:未传 fileBlobIds æ—¶å½“作只有一个附件
     */
    private List<Long> resolveFileBlobIds(WxCpNoticeSaveReqVO reqVO) {
        if (reqVO.getFileBlobIds() != null) {
            return reqVO.getFileBlobIds();
        }
        if (reqVO.getFileBlobId() != null) {
            return List.of(reqVO.getFileBlobId());
        }
        return null;
    }
    /**
     * æŠŠé¦–个附件回写到旧版 fileBlobId/fileName å­—段,便于老前端列表直接展示
     */
    private void syncFileSnapshot(Long noticeId, List<Long> fileBlobIds, String fileName) {
        if (fileBlobIds == null) {
            return;
        }
        if (CollUtil.isEmpty(fileBlobIds)) {
            // é™„件清空时显式置空:updateById ä¼šå¿½ç•¥ null å­—段,必须用 UpdateWrapper
            wxcpNoticeMapper.update(null, new LambdaUpdateWrapper<WxCpNoticeDO>()
                    .eq(WxCpNoticeDO::getId, noticeId)
                    .set(WxCpNoticeDO::getFileBlobId, null)
                    .set(WxCpNoticeDO::getFileName, null));
            return;
        }
        Long firstBlobId = fileBlobIds.get(0);
        String actualFileName = fileName;
        if (StrUtil.isBlank(actualFileName)) {
            SystemStorageBlobDO blob = storageBlobService.getStorageBlob(firstBlobId);
            actualFileName = blob != null ? blob.getOriginalFilename() : null;
        }
        wxcpNoticeMapper.updateById(WxCpNoticeDO.builder()
                .id(noticeId).fileBlobId(firstBlobId).fileName(actualFileName).build());
    }
    /**
     * å›¾ç‰‡/附件/视频推送后追加预览页卡片
     *
     * æœªé…ç½®å¤–网地址前缀时不追加,避免推送一个打不开的链接。
     */
    private void appendPreviewCard(List<WxCpPushResultVO> results, WxCpNoticeDO notice,
                                   String btnText, Integer scope, String target) {
        String previewUrl = buildPreviewUrl(notice.getId());
        if (StrUtil.isBlank(previewUrl)) {
            log.warn("[appendPreviewCard][未配置 wx.cp.signup-url-prefix,跳过预览卡片 noticeId={}]", notice.getId());
            return;
        }
        String description = StrUtil.isNotBlank(notice.getContent())
                ? StrUtil.blankToDefault(notice.getSummary(), notice.getContent())
                : "点击下方按钮查看通知内容";
        if (description.length() > 300) {
            description = description.substring(0, 300) + "……";
        }
        results.add(messagePushService.pushCard(notice.getTitle(), description, previewUrl, btnText, scope, target));
    }
    @Override
    public String buildPreviewUrl(Long id) {
        String prefix = wxCpProperties.getSignupUrlPrefix();
        if (StrUtil.isBlank(prefix)) {
            return null;
        }
        // åŽ»æŽ‰ç»“å°¾æ–œæ ï¼Œé¿å…æ‹¼å‡º //admin-api
        String base = StrUtil.removeSuffix(prefix.trim(), "/");
        return base + "/admin-api/wxcp/notice/preview-page?id=" + id;
    }
    private WxCpNoticeDO validateNoticeExists(Long id) {
        if (id == null) {
            throw exception(NOTICE_NOT_EXISTS);
@@ -253,4 +757,4 @@
        return notice;
    }
}
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushService.java
@@ -72,4 +72,38 @@
     */
    WxCpPushResultVO pushImage(java.io.File file, Integer scope, String target);
    /**
     * æŽ¨é€è§†é¢‘消息:先把视频上传到企业微信换取 mediaId,再发送视频
     *
     * @param file        è§†é¢‘文件
     * @param title       è§†é¢‘标题
     * @param description è§†é¢‘描述,可为空
     * @param scope       æŽ¨é€èŒƒå›´
     * @param target      æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushVideo(java.io.File file, String title, String description,
                               Integer scope, String target);
    /**
     * æŽ¨é€ Markdown æ¶ˆæ¯ï¼Œé€‚合带标题、加粗、列表、引用等排版的正式通知
     *
     * @param content Markdown å†…容(企业微信限制 2048 å­—节)
     * @param scope   æŽ¨é€èŒƒå›´
     * @param target  æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushMarkdown(String content, Integer scope, String target);
    /**
     * æŽ¨é€å¤šå›¾æ–‡æ¶ˆæ¯ï¼ˆä¸€æ¬¡æ¶ˆæ¯å¸¦å¤šç¯‡æ–‡ç« ï¼‰
     *
     * @param articles å›¾æ–‡åˆ—表,企业微信限制 1~8 ç¯‡
     * @param scope    æŽ¨é€èŒƒå›´
     * @param target   æŽ¨é€ç›®æ ‡
     * @return æŽ¨é€ç»“æžœ
     */
    WxCpPushResultVO pushMultiNews(java.util.List<me.chanjar.weixin.cp.bean.article.NewArticle> articles,
                                   Integer scope, String target);
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/push/WxCpMessagePushServiceImpl.java
@@ -131,6 +131,52 @@
        }
    }
    @Override
    public WxCpPushResultVO pushVideo(java.io.File file, String title, String description,
                                      Integer scope, String target) {
        if (file == null || !file.exists()) {
            return new WxCpPushResultVO(false, null, "视频文件不存在,无法推送", null, null);
        }
        try {
            // ä¼ä¸šå¾®ä¿¡è§†é¢‘消息要求先用 video ç±»åž‹ä¸Šä¼ æ¢å– mediaId
            WxMediaUploadResult upload = wxCpService.getMediaService().upload("video", file);
            if (upload == null || StrUtil.isBlank(upload.getMediaId())) {
                return new WxCpPushResultVO(false, null, "视频上传企业微信失败", null, null);
            }
            WxCpMessage message = WxCpMessage.VIDEO()
                    .agentId(agentId())
                    .mediaId(upload.getMediaId())
                    .title(StrUtil.blankToDefault(title, "通知视频"))
                    .description(StrUtil.blankToDefault(description, ""))
                    .build();
            return doSend(message, scope, target, "video");
        } catch (Exception e) {
            log.error("[pushVideo][视频上传或推送异常 file={}]", file.getName(), e);
            return new WxCpPushResultVO(false, null, "视频推送异常:" + e.getMessage(), null, null);
        }
    }
    @Override
    public WxCpPushResultVO pushMarkdown(String content, Integer scope, String target) {
        WxCpMessage message = WxCpMessage.MARKDOWN()
                .agentId(agentId())
                .content(content)
                .build();
        return doSend(message, scope, target, "markdown");
    }
    @Override
    public WxCpPushResultVO pushMultiNews(java.util.List<NewArticle> articles, Integer scope, String target) {
        if (articles == null || articles.isEmpty()) {
            return new WxCpPushResultVO(false, null, "图文内容为空,无法推送", null, null);
        }
        WxCpMessage message = WxCpMessage.NEWS()
                .agentId(agentId())
                .articles(articles)
                .build();
        return doSend(message, scope, target, "multi_news");
    }
    /**
     * ç»Ÿä¸€å¤„理接收者解析、发送与错误包装
     */