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.*; /** * 企业微信通知 Service 实现类 * * @author 超级管理员 */ @Service @Validated @Slf4j public class WxCpNoticeServiceImpl implements WxCpNoticeService { /** * 企业微信文本消息上限约 2048 字节,中文按 3 字节算。 * 这里按字符数留出足够余量,超出部分截断并在末尾提示,避免整条推送失败。 */ 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); // 推送范围选填,不传默认全员;推送时还能在弹窗里改 if (notice.getPushScope() == null) { notice.setPushScope(WxCpPushScopeEnum.ALL.getScope()); } wxcpNoticeMapper.insert(notice); // 素材统一走附件中间表;新建时 null 与空集合都表示没有素材 bindAttachments(NOTICE_IMAGE_RECORD_TYPE, notice.getId(), createReqVO.getImageBlobIds()); List 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(); } @Override public void updateNotice(WxCpNoticeSaveReqVO updateReqVO) { WxCpNoticeDO notice = validateNoticeExists(updateReqVO.getId()); if (Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.REVOKED.getStatus())) { throw exception(NOTICE_CANNOT_UPDATE); } WxCpNoticeDO updateObj = BeanUtils.toBean(updateReqVO, WxCpNoticeDO.class); // 状态与推送信息由系统维护,不允许前端覆盖 updateObj.setStatus(null); 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 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 public WxCpNoticeDO getNotice(Long id) { return wxcpNoticeMapper.selectById(id); } @Override public PageResult getNoticePage(WxCpNoticePageReqVO pageReqVO) { return wxcpNoticeMapper.selectPage(pageReqVO); } @Override public List getNoticeImageAttachments(Long noticeId) { return listAttachments(NOTICE_IMAGE_RECORD_TYPE, noticeId); } @Override public List getNoticeFileAttachments(Long noticeId) { return listAttachments(NOTICE_FILE_RECORD_TYPE, noticeId); } @Override public List getNoticeVideoAttachments(Long noticeId) { return listAttachments(NOTICE_VIDEO_RECORD_TYPE, noticeId); } @Override public List getNoticeArticles(Long noticeId) { return wxcpNoticeArticleMapper.selectListByNoticeId(noticeId); } @Override public List getNoticeArticleCoverAttachments(Long articleId) { return listAttachments(NOTICE_ARTICLE_COVER_RECORD_TYPE, articleId); } @Override public void publishNotice(Long id) { WxCpNoticeDO notice = validateNoticeExists(id); if (Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.PUBLISHED.getStatus())) { return; // 已发布,幂等返回 } if (Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.REVOKED.getStatus())) { throw exception(NOTICE_STATUS_REVOKED); } wxcpNoticeMapper.updateById(WxCpNoticeDO.builder() .id(id).status(WxCpNoticeStatusEnum.PUBLISHED.getStatus()).build()); } @Override public void revokeNotice(Long id) { validateNoticeExists(id); wxcpNoticeMapper.updateById(WxCpNoticeDO.builder() .id(id).status(WxCpNoticeStatusEnum.REVOKED.getStatus()).build()); } @Override public WxCpPushResultVO pushNotice(WxCpPushReqVO pushReqVO) { WxCpNoticeDO notice = validateNoticeExists(pushReqVO.getId()); if (!Objects.equals(notice.getStatus(), WxCpNoticeStatusEnum.PUBLISHED.getStatus())) { throw exception(NOTICE_STATUS_NOT_PUBLISHED); } Integer scope = pushReqVO.getPushScope() != null ? pushReqVO.getPushScope() : notice.getPushScope(); String target = StrUtil.isNotBlank(pushReqVO.getPushTarget()) ? pushReqVO.getPushTarget() : notice.getPushTarget(); if (scope == null) { scope = WxCpPushScopeEnum.ALL.getScope(); } // 推送时允许重新选择模板类型;未选择则沿用通知上保存的模板 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 imageBlobIds = pushReqVO.getImageBlobIds() != null ? pushReqVO.getImageBlobIds() : toBlobIds(getNoticeImageAttachments(notice.getId())); List fileBlobIds = pushReqVO.getFileBlobIds() != null ? pushReqVO.getFileBlobIds() : toBlobIds(getNoticeFileAttachments(notice.getId())); List videoBlobIds = pushReqVO.getVideoBlobIds() != null ? pushReqVO.getVideoBlobIds() : toBlobIds(getNoticeVideoAttachments(notice.getId())); List 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() .id(notice.getId()) .pushScope(scope) .pushTarget(target) .pushStatus(1) .pushTime(LocalDateTime.now()) .build()); } return result; } /** * 图片通知:先发一条文字说明(有正文时),再逐张发送图片 */ private WxCpPushResultVO pushImageNotice(List imageBlobIds, WxCpNoticeDO notice, Integer scope, String target) { List 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 fileBlobIds, WxCpNoticeDO notice, Integer scope, String target) { List 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 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 articles, Integer scope, String target) { List 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 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 results, String label) { if (CollUtil.isEmpty(results)) { return new WxCpPushResultVO(false, null, label + "推送内容为空", null, null); } List 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); } /** * 拼装推送正文:标题加粗样式用不了,改用【】包裹;过长则截断并提示 */ private String buildPushContent(WxCpNoticeDO notice) { String body = StrUtil.blankToDefault(notice.getContent(), ""); String content = "【" + notice.getTitle() + "】\n" + body; if (content.length() > MAX_PUSH_CONTENT_LENGTH) { content = content.substring(0, MAX_PUSH_CONTENT_LENGTH) + "\n……(内容过长已截断,请到系统内查看完整通知)"; } 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 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 blobIds) { SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO(); bindReqVO.setApplication(ATTACHMENT_APPLICATION); bindReqVO.setRecordType(recordType); bindReqVO.setRecordId(recordId); bindReqVO.setBlobItems(toBlobItems(blobIds)); storageAttachmentService.bindAttachments(bindReqVO); } private List toBlobItems(List 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 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 toBlobIds(List 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 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 toArticleReqList(List 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 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 fileBlobIds, String fileName) { if (fileBlobIds == null) { return; } if (CollUtil.isEmpty(fileBlobIds)) { // 附件清空时显式置空:updateById 会忽略 null 字段,必须用 UpdateWrapper wxcpNoticeMapper.update(null, new LambdaUpdateWrapper() .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 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); } WxCpNoticeDO notice = wxcpNoticeMapper.selectById(id); if (notice == null) { throw exception(NOTICE_NOT_EXISTS); } return notice; } }