| | |
| | | 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.*; |
| | |
| | | */ |
| | | 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) { |
| | |
| | | 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(); |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | @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 |
| | |
| | | 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()) |
| | |
| | | && 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; |
| | |
| | | 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); |
| | | } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 文件通知:从系统附件中心取文件,交给推送服务上传企业微信并发送 |
| | | * 图片通知:先发一条文字说明(有正文时),再逐张发送图片 |
| | | */ |
| | | 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); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | |
| | | return notice; |
| | | } |
| | | |
| | | } |
| | | } |