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<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();
|
}
|
|
@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<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
|
public WxCpNoticeDO getNotice(Long id) {
|
return wxcpNoticeMapper.selectById(id);
|
}
|
|
@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
|
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<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()
|
.id(notice.getId())
|
.pushScope(scope)
|
.pushTarget(target)
|
.pushStatus(1)
|
.pushTime(LocalDateTime.now())
|
.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);
|
}
|
|
/**
|
* 拼装推送正文:标题加粗样式用不了,改用【】包裹;过长则截断并提示
|
*/
|
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<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);
|
}
|
WxCpNoticeDO notice = wxcpNoticeMapper.selectById(id);
|
if (notice == null) {
|
throw exception(NOTICE_NOT_EXISTS);
|
}
|
return notice;
|
}
|
|
}
|