package cn.iocoder.yudao.module.wxcp.service.activity;
|
|
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.activity.vo.*;
|
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
|
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityFieldDO;
|
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupDO;
|
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivitySignupValueDO;
|
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivityFieldMapper;
|
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivityMapper;
|
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivitySignupMapper;
|
import cn.iocoder.yudao.module.wxcp.dal.mysql.activity.WxCpActivitySignupValueMapper;
|
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
|
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum;
|
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpPushScopeEnum;
|
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpSignupStatusEnum;
|
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.framework.config.WxCpProperties;
|
import cn.iocoder.yudao.module.wxcp.framework.qrcode.WxCpQrCodeUtil;
|
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 org.springframework.stereotype.Service;
|
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.validation.annotation.Validated;
|
|
import java.io.File;
|
import java.time.LocalDateTime;
|
import java.time.format.DateTimeFormatter;
|
import java.util.ArrayList;
|
import java.util.Collections;
|
import java.util.Comparator;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.Set;
|
import java.util.TreeMap;
|
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 WxCpActivityServiceImpl implements WxCpActivityService {
|
|
private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
|
/**
|
* 企业微信文本消息约 2048 字节,中文按 3 字节算,这里按字符数留余量
|
*/
|
private static final int MAX_PUSH_CONTENT_LENGTH = 600;
|
|
/**
|
* 报名附件的关联记录类型,对应 system_storage_attachment.record_type
|
*/
|
public static final String SIGNUP_RECORD_TYPE = "wxcp_activity_signup";
|
|
/**
|
* 活动封面的关联记录类型,对应 system_storage_attachment.record_type
|
*/
|
public static final String ACTIVITY_RECORD_TYPE = "wxcp_activity";
|
|
/**
|
* 活动详情图片的关联记录类型,对应 system_storage_attachment.record_type
|
*
|
* 与封面分开存:封面只允许 1 张、报名页顶部裁剪展示;
|
* 详情图可以多张、报名页按原图比例完整展示(二维码/海报这类不能裁的图放这里)。
|
*/
|
public static final String ACTIVITY_DETAIL_RECORD_TYPE = "wxcp_activity_detail";
|
|
/**
|
* 图片通知模板的图片关联记录类型
|
*/
|
public static final String ACTIVITY_IMAGE_RECORD_TYPE = "wxcp_activity_image";
|
|
/**
|
* 附件通知模板的附件关联记录类型
|
*/
|
public static final String ACTIVITY_FILE_RECORD_TYPE = "wxcp_activity_file";
|
|
/**
|
* 附件用途,与其它模块保持一致
|
*/
|
private static final String ATTACHMENT_APPLICATION = "file";
|
|
/**
|
* 企业微信文件消息上限 20MB
|
*/
|
private static final long MAX_FILE_SIZE = 20L * 1024L * 1024L;
|
|
/**
|
* 默认评价最高星级
|
*/
|
private static final int DEFAULT_RATING_MAX = 5;
|
|
@Resource
|
private WxCpActivityMapper wxcpActivityMapper;
|
@Resource
|
private WxCpActivitySignupMapper wxcpSignupMapper;
|
@Resource
|
private WxCpActivityFieldMapper activityFieldMapper;
|
@Resource
|
private WxCpActivitySignupValueMapper signupValueMapper;
|
|
@Resource
|
private SystemStorageAttachmentService storageAttachmentService;
|
@Resource
|
private SystemStorageBlobService storageBlobService;
|
|
@Resource
|
private WxCpMessagePushService messagePushService;
|
@Resource
|
private WxCpProperties wxCpProperties;
|
|
// ==================== 活动管理 ====================
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public Long createActivity(WxCpActivitySaveReqVO createReqVO) {
|
WxCpActivityDO activity = BeanUtils.toBean(createReqVO, WxCpActivityDO.class);
|
// 新建的一律是草稿,避免未配置完就被扫码报名
|
activity.setStatus(WxCpActivityStatusEnum.DRAFT.getStatus());
|
activity.setSignupCount(0);
|
activity.setPushStatus(0);
|
// 模板类型:默认「数据采集/评价」,与改造前的报名活动行为保持一致
|
if (activity.getActivityType() == null) {
|
activity.setActivityType(WxCpActivityTypeEnum.FORM.getType());
|
}
|
if (activity.getRatingMax() == null || activity.getRatingMax() <= 0) {
|
activity.setRatingMax(DEFAULT_RATING_MAX);
|
}
|
if (activity.getRatingEnabled() == null) {
|
activity.setRatingEnabled(Boolean.FALSE);
|
}
|
// 报名上限只对数据采集模板有意义;其它模板不传时按不限处理,避免接口强校验
|
if (activity.getSignupLimit() == null) {
|
activity.setSignupLimit(0);
|
}
|
if (StrUtil.isBlank(activity.getButtonText())) {
|
activity.setButtonText(defaultButtonText(activity.getActivityType()));
|
}
|
// 推送范围选填,不传默认全员;推送时还能在弹窗里改
|
if (activity.getPushScope() == null) {
|
activity.setPushScope(WxCpPushScopeEnum.ALL.getScope());
|
}
|
wxcpActivityMapper.insert(activity);
|
// 封面走 system 的附件中间表,业务表不落文件地址字段
|
if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
|
bindActivityAttachments(activity.getId(), createReqVO.getBlobIds());
|
}
|
// 详情图片同样走附件中间表,与封面分开存,互不影响
|
if (CollUtil.isNotEmpty(createReqVO.getDetailBlobIds())) {
|
bindActivityDetailAttachments(activity.getId(), createReqVO.getDetailBlobIds());
|
}
|
// 图片通知 / 附件通知的素材也走附件中间表,各自独立 recordType
|
if (CollUtil.isNotEmpty(createReqVO.getImageBlobIds())) {
|
bindAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activity.getId(), createReqVO.getImageBlobIds());
|
}
|
if (CollUtil.isNotEmpty(createReqVO.getFileBlobIds())) {
|
bindAttachments(ACTIVITY_FILE_RECORD_TYPE, activity.getId(), createReqVO.getFileBlobIds());
|
}
|
// 数据采集模板的自定义字段
|
if (CollUtil.isNotEmpty(createReqVO.getFieldList())) {
|
saveActivityFields(activity.getId(), createReqVO.getFieldList());
|
}
|
return activity.getId();
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void updateActivity(WxCpActivitySaveReqVO updateReqVO) {
|
WxCpActivityDO activity = validateActivityExists(updateReqVO.getId());
|
// 已结束或已取消的活动不再允许修改
|
if (Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.FINISHED.getStatus())
|
|| Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.CANCELED.getStatus())) {
|
throw exception(ACTIVITY_CANNOT_UPDATE);
|
}
|
WxCpActivityDO updateObj = BeanUtils.toBean(updateReqVO, WxCpActivityDO.class);
|
// 以下字段由系统维护,不允许前端覆盖
|
updateObj.setStatus(null);
|
updateObj.setSignupCount(null);
|
updateObj.setPushStatus(null);
|
updateObj.setPushTime(null);
|
wxcpActivityMapper.updateById(updateObj);
|
// blobIds 为 null 表示请求里没带这个字段,视为不改动封面;传空数组才是明确要清空封面
|
if (updateReqVO.getBlobIds() != null) {
|
bindActivityAttachments(activity.getId(), updateReqVO.getBlobIds());
|
}
|
// 详情图片同理:null = 不改动,空数组 = 清空
|
if (updateReqVO.getDetailBlobIds() != null) {
|
bindActivityDetailAttachments(activity.getId(), updateReqVO.getDetailBlobIds());
|
}
|
if (updateReqVO.getImageBlobIds() != null) {
|
bindAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activity.getId(), updateReqVO.getImageBlobIds());
|
}
|
if (updateReqVO.getFileBlobIds() != null) {
|
bindAttachments(ACTIVITY_FILE_RECORD_TYPE, activity.getId(), updateReqVO.getFileBlobIds());
|
}
|
// 字段列表:null = 不改动,空数组 = 清空
|
if (updateReqVO.getFieldList() != null) {
|
activityFieldMapper.deleteByActivityId(activity.getId());
|
if (CollUtil.isNotEmpty(updateReqVO.getFieldList())) {
|
saveActivityFields(activity.getId(), updateReqVO.getFieldList());
|
}
|
}
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void deleteActivity(Long id) {
|
validateActivityExists(id);
|
wxcpActivityMapper.deleteById(id);
|
// 活动删了,所有附件关系与采集字段一并清掉,避免残留成孤儿数据
|
storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_RECORD_TYPE, id);
|
storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_DETAIL_RECORD_TYPE, id);
|
storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_IMAGE_RECORD_TYPE, id);
|
storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_FILE_RECORD_TYPE, id);
|
activityFieldMapper.deleteByActivityId(id);
|
signupValueMapper.deleteByActivityId(id);
|
}
|
|
@Override
|
public WxCpActivityDO getActivity(Long id) {
|
return wxcpActivityMapper.selectById(id);
|
}
|
|
@Override
|
public PageResult<WxCpActivityDO> getActivityPage(WxCpActivityPageReqVO pageReqVO) {
|
return wxcpActivityMapper.selectPage(pageReqVO);
|
}
|
|
@Override
|
public List<SystemStorageBlobRespVO> getActivityAttachments(Long activityId) {
|
return listAttachments(ACTIVITY_RECORD_TYPE, activityId);
|
}
|
|
@Override
|
public List<SystemStorageBlobRespVO> getActivityDetailAttachments(Long activityId) {
|
List<SystemStorageBlobRespVO> attachments = listAttachments(ACTIVITY_DETAIL_RECORD_TYPE, activityId);
|
// 多张图必须保证展示顺序稳定:底层是 selectByIds,不承诺返回顺序,
|
// 这里按 blobId 升序(即上传先后)排一遍,避免同一批图每次刷新换位置。
|
return attachments.stream()
|
.sorted(Comparator.comparing(SystemStorageBlobRespVO::getId,
|
Comparator.nullsLast(Comparator.naturalOrder())))
|
.toList();
|
}
|
|
@Override
|
public List<SystemStorageBlobRespVO> getActivityImageAttachments(Long activityId) {
|
return listAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activityId);
|
}
|
|
@Override
|
public List<SystemStorageBlobRespVO> getActivityFileAttachments(Long activityId) {
|
return listAttachments(ACTIVITY_FILE_RECORD_TYPE, activityId);
|
}
|
|
@Override
|
public List<WxCpActivityFieldDO> getActivityFields(Long activityId) {
|
return activityFieldMapper.selectListByActivityId(activityId);
|
}
|
|
@Override
|
public List<WxCpActivitySignupValueDO> getSignupValues(Long signupId) {
|
return signupValueMapper.selectListBySignupId(signupId);
|
}
|
|
/**
|
* 保存活动自定义采集字段
|
*
|
* 先整体清空再插入,保证前端提交的顺序与内容就是最终结果;
|
* 已产生的报名值做字段名/类型快照,不会因为这里重建字段而丢失含义。
|
*/
|
private void saveActivityFields(Long activityId, List<WxCpActivityFieldSaveReqVO> fieldList) {
|
int index = 1;
|
for (WxCpActivityFieldSaveReqVO field : fieldList) {
|
if (field == null || StrUtil.isBlank(field.getFieldLabel())) {
|
throw exception(ACTIVITY_FIELD_LABEL_REQUIRED, index);
|
}
|
String fieldType = StrUtil.blankToDefault(field.getFieldType(), WxCpActivityFieldDO.TYPE_TEXT);
|
boolean needOptions = WxCpActivityFieldDO.TYPE_SELECT.equals(fieldType)
|
|| WxCpActivityFieldDO.TYPE_RADIO.equals(fieldType)
|
|| WxCpActivityFieldDO.TYPE_CHECKBOX.equals(fieldType);
|
if (needOptions && StrUtil.isBlank(field.getFieldOptions())) {
|
throw exception(ACTIVITY_FIELD_OPTIONS_REQUIRED, field.getFieldLabel());
|
}
|
activityFieldMapper.insert(WxCpActivityFieldDO.builder()
|
.activityId(activityId)
|
.fieldLabel(StrUtil.trim(field.getFieldLabel()))
|
.fieldType(fieldType)
|
.fieldOptions(StrUtil.trim(field.getFieldOptions()))
|
.required(Boolean.TRUE.equals(field.getRequired()))
|
.placeholder(StrUtil.trim(field.getPlaceholder()))
|
.sortNo(field.getSortNo() != null ? field.getSortNo() : index)
|
.build());
|
index++;
|
}
|
}
|
|
/**
|
* 绑定活动封面附件
|
*
|
* 走 system 模块的附件中间表(record_type = wxcp_activity),
|
* 业务表不加文件地址列、不自建 upload 接口,符合项目统一文件管理规范。
|
*
|
* 传空集合会清掉关联记录但保留文件本身(bindAttachments 只删中间表记录)。
|
* 注意不要改用 deleteAttachmentsByRecordTypeAndRecordId 来"先删后加":
|
* 那个方法会连 blob 一起删掉,同一个文件重新绑定时会因为文件已不存在而查不出来。
|
*/
|
private void bindActivityAttachments(Long activityId, List<Long> blobIds) {
|
bindAttachments(ACTIVITY_RECORD_TYPE, activityId, blobIds);
|
}
|
|
/**
|
* 绑定活动详情图片附件(record_type = wxcp_activity_detail),规则同封面
|
*/
|
private void bindActivityDetailAttachments(Long activityId, List<Long> blobIds) {
|
bindAttachments(ACTIVITY_DETAIL_RECORD_TYPE, activityId, blobIds);
|
}
|
|
private void bindAttachments(String recordType, Long activityId, List<Long> blobIds) {
|
SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
|
bindReqVO.setApplication(ATTACHMENT_APPLICATION);
|
bindReqVO.setRecordType(recordType);
|
bindReqVO.setRecordId(activityId);
|
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);
|
}
|
|
@Override
|
public void publishActivity(Long id) {
|
WxCpActivityDO activity = validateActivityExists(id);
|
if (Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
|
return; // 已发布,幂等返回
|
}
|
if (Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.FINISHED.getStatus())
|
|| Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.CANCELED.getStatus())) {
|
throw exception(ACTIVITY_STATUS_FINISHED);
|
}
|
wxcpActivityMapper.updateById(WxCpActivityDO.builder()
|
.id(id).status(WxCpActivityStatusEnum.PUBLISHED.getStatus()).build());
|
}
|
|
@Override
|
public void finishActivity(Long id) {
|
validateActivityExists(id);
|
wxcpActivityMapper.updateById(WxCpActivityDO.builder()
|
.id(id).status(WxCpActivityStatusEnum.FINISHED.getStatus()).build());
|
}
|
|
@Override
|
public void cancelActivity(Long id) {
|
validateActivityExists(id);
|
wxcpActivityMapper.updateById(WxCpActivityDO.builder()
|
.id(id).status(WxCpActivityStatusEnum.CANCELED.getStatus()).build());
|
}
|
|
// ==================== 推送 ====================
|
|
@Override
|
public WxCpPushResultVO pushActivity(WxCpPushReqVO pushReqVO) {
|
WxCpActivityDO activity = validateActivityExists(pushReqVO.getId());
|
if (!Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
|
throw exception(ACTIVITY_STATUS_NOT_PUBLISHED);
|
}
|
|
// 推送范围与目标:请求里传了就用请求的,否则沿用活动上配置的
|
Integer scope = pushReqVO.getPushScope() != null ? pushReqVO.getPushScope() : activity.getPushScope();
|
String target = StrUtil.isNotBlank(pushReqVO.getPushTarget())
|
? pushReqVO.getPushTarget() : activity.getPushTarget();
|
if (scope == null) {
|
scope = WxCpPushScopeEnum.ALL.getScope();
|
}
|
|
// 按活动模板类型选择推送形态
|
Integer activityType = activity.getActivityType() != null
|
? activity.getActivityType() : WxCpActivityTypeEnum.FORM.getType();
|
WxCpPushResultVO result;
|
if (Objects.equals(activityType, WxCpActivityTypeEnum.TEXT.getType())) {
|
result = messagePushService.pushText(buildTextContent(activity), scope, target);
|
} else if (Objects.equals(activityType, WxCpActivityTypeEnum.IMAGE.getType())) {
|
result = pushImages(activity, scope, target);
|
} else if (Objects.equals(activityType, WxCpActivityTypeEnum.FILE.getType())) {
|
result = pushFiles(activity, scope, target);
|
} else if (Objects.equals(activityType, WxCpActivityTypeEnum.MESSAGE.getType())) {
|
String url = tryBuildSignupUrl(activity.getId());
|
result = StrUtil.isBlank(url)
|
? messagePushService.pushText(buildTextContent(activity), scope, target)
|
: messagePushService.pushCard(activity.getTitle(), buildMessageDescription(activity),
|
url, pushButtonText(activity), scope, target);
|
} else {
|
// 数据采集/评价:卡片直达采集页,点消息或扫码都能填写
|
result = messagePushService.pushCard(
|
activity.getTitle(), buildPushDescription(activity),
|
buildSignupUrl(activity.getId()), pushButtonText(activity), scope, target);
|
}
|
|
if (Boolean.TRUE.equals(result.getSuccess())) {
|
wxcpActivityMapper.updateById(WxCpActivityDO.builder()
|
.id(activity.getId())
|
.pushScope(scope)
|
.pushTarget(target)
|
.pushStatus(1)
|
.pushTime(LocalDateTime.now())
|
.build());
|
}
|
return result;
|
}
|
|
/**
|
* 拼装推送消息正文
|
*
|
* 只放成员报名前最关心的信息:时间、地点、报名截止;详情让用户点进报名页看
|
*/
|
private String buildPushDescription(WxCpActivityDO activity) {
|
StringBuilder sb = new StringBuilder();
|
if (activity.getStartTime() != null) {
|
sb.append("活动时间:").append(activity.getStartTime().format(TIME_FMT));
|
if (activity.getEndTime() != null) {
|
sb.append(" ~ ").append(activity.getEndTime().format(TIME_FMT));
|
}
|
sb.append("\n");
|
}
|
if (StrUtil.isNotBlank(activity.getLocation())) {
|
sb.append("活动地点:").append(activity.getLocation()).append("\n");
|
}
|
if (activity.getSignupEndTime() != null) {
|
sb.append("报名截止:").append(activity.getSignupEndTime().format(TIME_FMT)).append("\n");
|
}
|
if (activity.getSignupLimit() != null && activity.getSignupLimit() > 0) {
|
sb.append("名额:").append(activity.getSignupCount()).append("/")
|
.append(activity.getSignupLimit()).append("\n");
|
}
|
sb.append("点击下方按钮查看详情并报名");
|
return sb.toString();
|
}
|
|
/**
|
* 纯文本/图片/附件模板的正文:标题 + 时间地点 + 活动详情,过长截断避免整条消息发送失败
|
*/
|
private String buildTextContent(WxCpActivityDO activity) {
|
StringBuilder body = new StringBuilder();
|
if (activity.getStartTime() != null) {
|
body.append("活动时间:").append(activity.getStartTime().format(TIME_FMT));
|
if (activity.getEndTime() != null) {
|
body.append(" ~ ").append(activity.getEndTime().format(TIME_FMT));
|
}
|
body.append("\n");
|
}
|
if (StrUtil.isNotBlank(activity.getLocation())) {
|
body.append("活动地点:").append(activity.getLocation()).append("\n");
|
}
|
if (StrUtil.isNotBlank(activity.getContent())) {
|
body.append(activity.getContent());
|
}
|
String content = "【" + activity.getTitle() + "】\n" + body.toString().trim();
|
if (content.length() > MAX_PUSH_CONTENT_LENGTH) {
|
content = content.substring(0, MAX_PUSH_CONTENT_LENGTH)
|
+ "\n……(内容过长已截断,请到系统内查看完整内容)";
|
}
|
return content;
|
}
|
|
/**
|
* 单条消息模板的卡片正文:活动摘要 + 引导点击
|
*/
|
private String buildMessageDescription(WxCpActivityDO activity) {
|
String body = buildTextContent(activity);
|
// textcard 的 description 约 512 字节,留出余量,避免整条卡片发送失败
|
if (body.length() > 300) {
|
body = body.substring(0, 300) + "……";
|
}
|
return body + "\n点击下方按钮查看详情";
|
}
|
|
private String pushButtonText(WxCpActivityDO activity) {
|
return StrUtil.blankToDefault(activity.getButtonText(),
|
defaultButtonText(activity.getActivityType()));
|
}
|
|
private String defaultButtonText(Integer activityType) {
|
if (Objects.equals(activityType, WxCpActivityTypeEnum.MESSAGE.getType())) {
|
return "查看详情";
|
}
|
return "立即报名";
|
}
|
|
/**
|
* 拼装活动页地址;未配置外网前缀时返回 null,由调用方降级为文本推送
|
*/
|
private String tryBuildSignupUrl(Long id) {
|
try {
|
return buildSignupUrl(id);
|
} catch (Exception e) {
|
log.warn("[tryBuildSignupUrl][未配置活动页外网地址,降级为文本推送 id={}]", id);
|
return null;
|
}
|
}
|
|
/**
|
* 图片通知:先发文字说明,再逐张发送图片
|
*/
|
private WxCpPushResultVO pushImages(WxCpActivityDO activity, Integer scope, String target) {
|
List<SystemStorageBlobRespVO> images = getActivityImageAttachments(activity.getId());
|
if (CollUtil.isEmpty(images)) {
|
throw exception(ACTIVITY_IMAGE_REQUIRED);
|
}
|
List<WxCpPushResultVO> results = new ArrayList<>();
|
// 有正文就先发一条文字说明;文字失败不影响后续图片发送
|
if (StrUtil.isNotBlank(activity.getContent())) {
|
results.add(messagePushService.pushText(buildTextContent(activity), scope, target));
|
}
|
for (SystemStorageBlobRespVO image : images) {
|
SystemStorageBlobDO blob = storageBlobService.getStorageBlob(image.getId());
|
if (blob == null) {
|
throw exception(ACTIVITY_FILE_NOT_EXISTS);
|
}
|
if (StrUtil.isNotBlank(blob.getContentType())
|
&& !StrUtil.startWithIgnoreCase(blob.getContentType(), "image/")) {
|
throw exception(ACTIVITY_IMAGE_ONLY);
|
}
|
File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
|
if (file == null || !file.exists()) {
|
throw exception(ACTIVITY_FILE_NOT_EXISTS);
|
}
|
results.add(messagePushService.pushImage(file, scope, target));
|
}
|
return aggregatePushResults(results, "图片");
|
}
|
|
/**
|
* 附件通知:先发文字说明,再逐个发送文件
|
*/
|
private WxCpPushResultVO pushFiles(WxCpActivityDO activity, Integer scope, String target) {
|
List<SystemStorageBlobRespVO> files = getActivityFileAttachments(activity.getId());
|
if (CollUtil.isEmpty(files)) {
|
throw exception(ACTIVITY_FILE_REQUIRED);
|
}
|
List<WxCpPushResultVO> results = new ArrayList<>();
|
if (StrUtil.isNotBlank(activity.getContent())) {
|
results.add(messagePushService.pushText(buildTextContent(activity), scope, target));
|
}
|
for (SystemStorageBlobRespVO item : files) {
|
SystemStorageBlobDO blob = storageBlobService.getStorageBlob(item.getId());
|
if (blob == null) {
|
throw exception(ACTIVITY_FILE_NOT_EXISTS);
|
}
|
if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
|
throw exception(ACTIVITY_FILE_TOO_LARGE);
|
}
|
File file = storageBlobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
|
if (file == null || !file.exists()) {
|
throw exception(ACTIVITY_FILE_NOT_EXISTS);
|
}
|
results.add(messagePushService.pushFile(file, blob.getOriginalFilename(), null, scope, target));
|
}
|
return aggregatePushResults(results, "附件");
|
}
|
|
/**
|
* 多条消息合并结果:任一条失败即视为整体失败,并把失败原因汇总给前端
|
*/
|
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(),
|
label + "推送失败:" + message, null, null);
|
}
|
|
@Override
|
public byte[] generateSignupQrCode(Long id, int size) {
|
validateActivityExists(id);
|
String url = buildSignupUrl(id);
|
Integer configured = wxCpProperties.getQrcodeSize();
|
int actualSize = size > 0 ? size : (configured != null ? configured : WxCpQrCodeUtil.DEFAULT_SIZE);
|
return WxCpQrCodeUtil.generatePng(url, actualSize);
|
}
|
|
@Override
|
public String buildSignupUrl(Long id) {
|
String prefix = wxCpProperties.getSignupUrlPrefix();
|
if (StrUtil.isBlank(prefix)) {
|
throw exception(QRCODE_SIGNUP_URL_NOT_CONFIGURED);
|
}
|
// 去掉结尾斜杠,避免拼出 //admin-api
|
String base = StrUtil.removeSuffix(prefix.trim(), "/");
|
return base + "/admin-api/wxcp/activity/signup-page?id=" + id;
|
}
|
|
// ==================== 报名 ====================
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public Long createSignup(WxCpSignupPublicReqVO reqVO) {
|
WxCpActivityDO activity = validateActivityExists(reqVO.getActivityId());
|
|
// 1. 活动必须是已发布状态
|
if (!Objects.equals(activity.getStatus(), WxCpActivityStatusEnum.PUBLISHED.getStatus())) {
|
throw exception(SIGNUP_ACTIVITY_NOT_PUBLISHED);
|
}
|
// 只有「数据采集/评价」模板有表单,其它模板只做消息推送
|
Integer activityType = activity.getActivityType() != null
|
? activity.getActivityType() : WxCpActivityTypeEnum.FORM.getType();
|
if (!Objects.equals(activityType, WxCpActivityTypeEnum.FORM.getType())) {
|
throw exception(SIGNUP_NOT_SUPPORTED);
|
}
|
|
// 2. 校验报名时间窗口
|
LocalDateTime now = LocalDateTime.now();
|
if (activity.getSignupStartTime() != null && now.isBefore(activity.getSignupStartTime())) {
|
throw exception(SIGNUP_NOT_IN_PERIOD);
|
}
|
if (activity.getSignupEndTime() != null && now.isAfter(activity.getSignupEndTime())) {
|
throw exception(SIGNUP_NOT_IN_PERIOD);
|
}
|
|
// 3. 校验名额
|
if (activity.getSignupLimit() != null && activity.getSignupLimit() > 0
|
&& activity.getSignupCount() != null && activity.getSignupCount() >= activity.getSignupLimit()) {
|
throw exception(SIGNUP_FULL);
|
}
|
|
// 3.1 校验自定义采集字段与评价,避免把空值写进库
|
List<WxCpActivityFieldDO> fields = getActivityFields(activity.getId());
|
List<WxCpActivitySignupValueDO> fieldValues =
|
buildSignupValues(activity.getId(), fields, reqVO.getFieldValues());
|
validateRating(activity, reqVO.getRatingScore());
|
|
// 4. 去重:有企业微信账号按账号查;没有账号但有手机号则按手机号查;
|
// 两者都没有时无法判定身份,不做去重——否则空手机号会互相误判成同一人
|
String wxUserId = StrUtil.trimToEmpty(reqVO.getWxUserId());
|
String mobile = StrUtil.trimToEmpty(reqVO.getMobile());
|
WxCpActivitySignupDO exists = null;
|
if (StrUtil.isNotBlank(wxUserId)) {
|
exists = wxcpSignupMapper.selectByActivityIdAndWxUserId(activity.getId(), wxUserId);
|
} else if (StrUtil.isNotBlank(mobile)) {
|
exists = wxcpSignupMapper.selectByActivityIdAndMobile(activity.getId(), mobile);
|
}
|
if (exists != null && !Objects.equals(exists.getStatus(), WxCpSignupStatusEnum.CANCELED.getStatus())) {
|
throw exception(SIGNUP_DUPLICATED);
|
}
|
// 之前取消过,则复用原记录重新报名,避免同一个人留多条历史
|
if (exists != null) {
|
wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
|
.id(exists.getId())
|
.name(StrUtil.trimToEmpty(reqVO.getName()))
|
.mobile(mobile)
|
.deptName(reqVO.getDeptName())
|
.content(reqVO.getContent())
|
.ratingScore(reqVO.getRatingScore())
|
.remark(reqVO.getRemark())
|
.status(WxCpSignupStatusEnum.SIGNED_UP.getStatus())
|
.signupTime(now)
|
.checkInTime(null)
|
.build());
|
// 重新报名时旧附件/采集值作废,按本次提交覆盖
|
bindSignupAttachments(exists.getId(), reqVO.getBlobIds());
|
signupValueMapper.deleteBySignupId(exists.getId());
|
insertSignupValues(fieldValues, exists.getId());
|
incrementSignupCount(activity.getId());
|
return exists.getId();
|
}
|
|
// 5. 新增报名记录
|
WxCpActivitySignupDO signup = BeanUtils.toBean(reqVO, WxCpActivitySignupDO.class);
|
signup.setWxUserId(wxUserId);
|
// 姓名、手机号改为选填:统一存空串而不是 null,避免后续按手机号查重时空值语义混乱
|
signup.setName(StrUtil.trimToEmpty(reqVO.getName()));
|
signup.setMobile(mobile);
|
signup.setRatingScore(reqVO.getRatingScore());
|
signup.setStatus(WxCpSignupStatusEnum.SIGNED_UP.getStatus());
|
signup.setSignupTime(now);
|
wxcpSignupMapper.insert(signup);
|
|
// 6. 绑定报名人上传的图片/附件与采集字段值
|
bindSignupAttachments(signup.getId(), reqVO.getBlobIds());
|
insertSignupValues(fieldValues, signup.getId());
|
|
// 7. 用 SQL 自增,避免并发下的读改写丢失更新
|
incrementSignupCount(activity.getId());
|
return signup.getId();
|
}
|
|
/**
|
* 绑定报名附件(全量覆盖:先清掉该报名记录下的旧关联,再绑新的)
|
*
|
* 走 system 模块的附件中间表(record_type = wxcp_activity_signup),
|
* 不新建 upload 接口、不落文件地址字段,符合项目统一文件管理规范。
|
* 传空集合只清关联、不删文件本身。
|
*/
|
private void bindSignupAttachments(Long signupId, List<Long> blobIds) {
|
SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
|
bindReqVO.setApplication(ATTACHMENT_APPLICATION);
|
bindReqVO.setRecordType(SIGNUP_RECORD_TYPE);
|
bindReqVO.setRecordId(signupId);
|
bindReqVO.setBlobItems(toBlobItems(blobIds));
|
storageAttachmentService.bindAttachments(bindReqVO);
|
}
|
|
@Override
|
public List<SystemStorageBlobRespVO> getSignupAttachments(Long signupId) {
|
return listAttachments(SIGNUP_RECORD_TYPE, signupId);
|
}
|
|
/**
|
* 组装报名采集值:按字段定义逐项校验必填,没填的字段不落行
|
*/
|
private List<WxCpActivitySignupValueDO> buildSignupValues(Long activityId, List<WxCpActivityFieldDO> fields,
|
List<WxCpSignupValueReqVO> reqValues) {
|
if (CollUtil.isEmpty(fields)) {
|
return Collections.emptyList();
|
}
|
Map<Long, String> valueMap = CollUtil.isEmpty(reqValues) ? Collections.emptyMap()
|
: reqValues.stream()
|
.filter(v -> v != null && v.getFieldId() != null)
|
.collect(Collectors.toMap(WxCpSignupValueReqVO::getFieldId,
|
v -> StrUtil.trimToEmpty(v.getFieldValue()), (a, b) -> b));
|
List<WxCpActivitySignupValueDO> result = new ArrayList<>();
|
for (WxCpActivityFieldDO field : fields) {
|
String value = StrUtil.trimToEmpty(valueMap.get(field.getId()));
|
if (Boolean.TRUE.equals(field.getRequired()) && StrUtil.isBlank(value)) {
|
throw exception(SIGNUP_FIELD_REQUIRED, field.getFieldLabel());
|
}
|
if (StrUtil.isBlank(value)) {
|
continue;
|
}
|
result.add(WxCpActivitySignupValueDO.builder()
|
.activityId(activityId)
|
.fieldId(field.getId())
|
.fieldLabel(field.getFieldLabel())
|
.fieldType(field.getFieldType())
|
.fieldValue(value)
|
.sortNo(field.getSortNo())
|
.build());
|
}
|
return result;
|
}
|
|
private void insertSignupValues(List<WxCpActivitySignupValueDO> values, Long signupId) {
|
if (CollUtil.isEmpty(values)) {
|
return;
|
}
|
values.forEach(value -> value.setSignupId(signupId));
|
signupValueMapper.insertBatch(values);
|
}
|
|
/**
|
* 开启评价时校验星级范围
|
*/
|
private void validateRating(WxCpActivityDO activity, Integer score) {
|
if (!Boolean.TRUE.equals(activity.getRatingEnabled())) {
|
return;
|
}
|
if (score == null) {
|
throw exception(SIGNUP_RATING_REQUIRED);
|
}
|
int max = activity.getRatingMax() != null && activity.getRatingMax() > 0
|
? activity.getRatingMax() : DEFAULT_RATING_MAX;
|
if (score < 1 || score > max) {
|
throw exception(SIGNUP_RATING_INVALID, max);
|
}
|
}
|
|
private void incrementSignupCount(Long activityId) {
|
wxcpActivityMapper.update(null, new LambdaUpdateWrapper<WxCpActivityDO>()
|
.setSql("signup_count = signup_count + 1")
|
.eq(WxCpActivityDO::getId, activityId));
|
}
|
|
private void decrementSignupCount(Long activityId) {
|
wxcpActivityMapper.update(null, new LambdaUpdateWrapper<WxCpActivityDO>()
|
.setSql("signup_count = CASE WHEN signup_count > 0 THEN signup_count - 1 ELSE 0 END")
|
.eq(WxCpActivityDO::getId, activityId));
|
}
|
|
@Override
|
@Transactional(rollbackFor = Exception.class)
|
public void cancelSignup(Long id) {
|
WxCpActivitySignupDO signup = validateSignupExists(id);
|
if (Objects.equals(signup.getStatus(), WxCpSignupStatusEnum.CANCELED.getStatus())) {
|
throw exception(SIGNUP_ALREADY_CANCELED);
|
}
|
wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
|
.id(id).status(WxCpSignupStatusEnum.CANCELED.getStatus()).build());
|
decrementSignupCount(signup.getActivityId());
|
}
|
|
@Override
|
public void checkInSignup(Long id) {
|
WxCpActivitySignupDO signup = validateSignupExists(id);
|
if (Objects.equals(signup.getStatus(), WxCpSignupStatusEnum.CANCELED.getStatus())) {
|
throw exception(SIGNUP_ALREADY_CANCELED);
|
}
|
if (Objects.equals(signup.getStatus(), WxCpSignupStatusEnum.CHECKED_IN.getStatus())) {
|
throw exception(SIGNUP_ALREADY_CHECKED_IN);
|
}
|
wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
|
.id(id)
|
.status(WxCpSignupStatusEnum.CHECKED_IN.getStatus())
|
.checkInTime(LocalDateTime.now())
|
.build());
|
}
|
|
@Override
|
public WxCpActivitySignupDO getSignup(Long id) {
|
return wxcpSignupMapper.selectById(id);
|
}
|
|
@Override
|
public PageResult<WxCpActivitySignupDO> getSignupPage(WxCpSignupPageReqVO pageReqVO) {
|
return wxcpSignupMapper.selectPage(pageReqVO);
|
}
|
|
@Override
|
public WxCpActivityStatsRespVO getActivityStats(Long activityId) {
|
WxCpActivityDO activity = validateActivityExists(activityId);
|
List<WxCpActivitySignupDO> all = wxcpSignupMapper.selectListByActivityId(activityId);
|
|
// 一次遍历同时算总数与三类分布,避免多次全表扫描
|
int signedUp = 0, checkedIn = 0, canceled = 0;
|
Map<String, Integer> deptMap = new LinkedHashMap<>();
|
Map<String, Integer> dateMap = new TreeMap<>();
|
int fromWxCp = 0, fromForm = 0;
|
for (WxCpActivitySignupDO s : all) {
|
Integer st = s.getStatus();
|
if (Objects.equals(st, WxCpSignupStatusEnum.CANCELED.getStatus())) {
|
canceled++;
|
continue; // 已取消不计入部门/日期分布,否则统计会虚高
|
}
|
if (Objects.equals(st, WxCpSignupStatusEnum.CHECKED_IN.getStatus())) {
|
checkedIn++;
|
} else {
|
signedUp++;
|
}
|
deptMap.merge(StrUtil.blankToDefault(s.getDeptName(), "未填写部门"), 1, Integer::sum);
|
if (s.getSignupTime() != null) {
|
dateMap.merge(s.getSignupTime().toLocalDate().toString(), 1, Integer::sum);
|
}
|
if (StrUtil.isNotBlank(s.getWxUserId())) {
|
fromWxCp++;
|
} else {
|
fromForm++;
|
}
|
}
|
|
int totalSignup = signedUp + checkedIn;
|
WxCpActivityStatsRespVO vo = new WxCpActivityStatsRespVO();
|
vo.setActivityId(activity.getId());
|
vo.setActivityTitle(activity.getTitle());
|
vo.setSignupLimit(activity.getSignupLimit());
|
vo.setTotalSignup(totalSignup);
|
vo.setSignedUpCount(signedUp);
|
vo.setCheckedInCount(checkedIn);
|
vo.setCanceledCount(canceled);
|
|
// 名额相关指标:不限名额时保持 null,前端据此显示"不限"
|
Integer limit = activity.getSignupLimit();
|
if (limit != null && limit > 0) {
|
vo.setRemaining(Math.max(0, limit - totalSignup));
|
vo.setSignupPercent(Math.min(100, (int) Math.round(totalSignup * 100.0 / limit)));
|
}
|
vo.setCheckInPercent(totalSignup == 0 ? 0
|
: (int) Math.round(checkedIn * 100.0 / totalSignup));
|
|
vo.setDeptStats(toNameCountList(deptMap));
|
vo.setDateStats(toNameCountList(dateMap));
|
// 来源分布:用显式 put,避免双大括号初始化捕获循环中变更的局部变量
|
Map<String, Integer> sourceMap = new LinkedHashMap<>();
|
sourceMap.put("企业微信身份", fromWxCp);
|
sourceMap.put("表单填写", fromForm);
|
vo.setSourceStats(toNameCountList(sourceMap));
|
return vo;
|
}
|
|
private List<WxCpActivityStatsRespVO.NameCount> toNameCountList(Map<String, Integer> map) {
|
return map.entrySet().stream()
|
.map(e -> new WxCpActivityStatsRespVO.NameCount(e.getKey(), e.getValue()))
|
.toList();
|
}
|
|
// ==================== 校验辅助 ====================
|
|
private WxCpActivityDO validateActivityExists(Long id) {
|
if (id == null) {
|
throw exception(ACTIVITY_NOT_EXISTS);
|
}
|
WxCpActivityDO activity = wxcpActivityMapper.selectById(id);
|
if (activity == null) {
|
throw exception(ACTIVITY_NOT_EXISTS);
|
}
|
return activity;
|
}
|
|
private WxCpActivitySignupDO validateSignupExists(Long id) {
|
WxCpActivitySignupDO signup = wxcpSignupMapper.selectById(id);
|
if (signup == null) {
|
throw exception(SIGNUP_NOT_EXISTS);
|
}
|
return signup;
|
}
|
|
}
|