From 47dd1ecaf0aafa66b9c39fff9c91abc1c611b02c Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期二, 27 十月 2026 17:59:46 +0800
Subject: [PATCH] feat(wxcp): 通知支持图片/视频/Markdown/多图文模板与成员预览

---
 yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java |  604 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 600 insertions(+), 4 deletions(-)

diff --git a/yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java b/yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java
index 494e29e..bf61273 100644
--- a/yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java
+++ b/yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/notice/WxCpNoticeServiceImpl.java
@@ -1,24 +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 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.*;
@@ -39,14 +59,85 @@
      */
     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;
+    @Resource
+    private SystemStorageAttachmentService storageAttachmentService;
+    @Resource
+    private WxCpProperties wxCpProperties;
 
     @Override
     public Long createNotice(WxCpNoticeSaveReqVO createReqVO) {
         WxCpNoticeDO notice = BeanUtils.toBean(createReqVO, WxCpNoticeDO.class);
+        if (notice.getNoticeType() == null) {
+            notice.setNoticeType(WxCpNoticeTypeEnum.TEXT.getType());
+        }
         notice.setStatus(WxCpNoticeStatusEnum.DRAFT.getStatus());
         notice.setPushStatus(0);
         // 鎺ㄩ�佽寖鍥撮�夊~锛屼笉浼犻粯璁ゅ叏鍛橈紱鎺ㄩ�佹椂杩樿兘鍦ㄥ脊绐楅噷鏀�
@@ -54,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();
     }
 
@@ -69,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
@@ -85,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
@@ -121,9 +270,86 @@
             scope = WxCpPushScopeEnum.ALL.getScope();
         }
 
-        // 閫氱煡鐢ㄧ函鏂囨湰鎺ㄩ�侊細娌℃湁鍙烦杞殑璇︽儏椤碉紝鑰� textcard 绫诲瀷寮哄埗瑕佹眰 url
-        WxCpPushResultVO result = messagePushService.pushText(
-                buildPushContent(notice), scope, target);
+        // 鎺ㄩ�佹椂鍏佽閲嶆柊閫夋嫨妯℃澘绫诲瀷锛涙湭閫夋嫨鍒欐部鐢ㄩ�氱煡涓婁繚瀛樼殑妯℃澘
+        Integer noticeType = pushReqVO.getNoticeType() != null
+                ? pushReqVO.getNoticeType()
+                : (notice.getNoticeType() != null ? notice.getNoticeType() : WxCpNoticeTypeEnum.TEXT.getType());
+        String summary = StrUtil.blankToDefault(pushReqVO.getSummary(), notice.getSummary());
+        String linkUrl = StrUtil.blankToDefault(pushReqVO.getLinkUrl(), notice.getLinkUrl());
+        String linkBtnText = StrUtil.blankToDefault(pushReqVO.getLinkBtnText(), notice.getLinkBtnText());
+        String coverUrl = StrUtil.blankToDefault(pushReqVO.getCoverUrl(), notice.getCoverUrl());
+
+        // 鏈鎺ㄩ�佺殑绱犳潗锛氳姹傞噷浼犱簡灏辫鐩栵紝娌′紶鍒欐部鐢ㄩ�氱煡涓婂凡淇濆瓨鐨�
+        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())
+                && StrUtil.isBlank(notice.getContent())) {
+            throw exception(NOTICE_CONTENT_REQUIRED);
+        }
+        if ((Objects.equals(noticeType, WxCpNoticeTypeEnum.CARD.getType())
+                || Objects.equals(noticeType, WxCpNoticeTypeEnum.NEWS.getType()))
+                && StrUtil.isBlank(linkUrl)) {
+            throw exception(NOTICE_LINK_URL_REQUIRED);
+        }
+        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;
+        if (Objects.equals(noticeType, WxCpNoticeTypeEnum.CARD.getType())) {
+            result = messagePushService.pushCard(notice.getTitle(),
+                    StrUtil.blankToDefault(summary, notice.getContent()),
+                    linkUrl, StrUtil.blankToDefault(linkBtnText, "鍘诲~鎶�"), scope, target);
+        } else if (Objects.equals(noticeType, WxCpNoticeTypeEnum.FILE.getType())) {
+            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);
+        }
 
         if (Boolean.TRUE.equals(result.getSuccess())) {
             wxcpNoticeMapper.updateById(WxCpNoticeDO.builder()
@@ -135,6 +361,153 @@
                     .build());
         }
         return result;
+    }
+
+    /**
+     * 鍥剧墖閫氱煡锛氬厛鍙戜竴鏉℃枃瀛楄鏄庯紙鏈夋鏂囨椂锛夛紝鍐嶉�愬紶鍙戦�佸浘鐗�
+     */
+    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));
+        }
+        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 (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);
+        }
+        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 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());
+        }
+        return messagePushService.pushMultiNews(newsList, scope, target);
+    }
+
+    /**
+     * 澶氬浘鏂囧皝闈㈠湴鍧�锛氬彇鍥炬枃鏉$洰缁戝畾鐨勫皝闈㈤檮浠讹紙閫夊~锛�
+     */
+    private String resolveArticleCoverUrl(WxCpNoticeArticleSaveReqVO article) {
+        Long articleId = article.getId();
+        if (articleId == null) {
+            // 鎺ㄩ�佹椂涓存椂娣诲姞銆佹湭钀藉簱鐨勫浘鏂囨病鏈夐檮浠跺叧绯伙紝鐩存帴蹇界暐灏侀潰
+            return null;
+        }
+        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);
     }
 
     /**
@@ -150,6 +523,229 @@
         return content;
     }
 
+    /**
+     * Markdown 姝f枃锛氭妸閫氱煡鏍囬浣滀负涓�绾ф爣棰樻嫾鍦ㄦ鏂囧墠锛屼究浜庢垚鍛樼涓�鐪肩湅鍒颁富棰�
+     */
+    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);
@@ -161,4 +757,4 @@
         return notice;
     }
 
-}
+}
\ No newline at end of file

--
Gitblit v1.9.3