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 getActivityPage(WxCpActivityPageReqVO pageReqVO) { return wxcpActivityMapper.selectPage(pageReqVO); } @Override public List getActivityAttachments(Long activityId) { return listAttachments(ACTIVITY_RECORD_TYPE, activityId); } @Override public List getActivityDetailAttachments(Long activityId) { List 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 getActivityImageAttachments(Long activityId) { return listAttachments(ACTIVITY_IMAGE_RECORD_TYPE, activityId); } @Override public List getActivityFileAttachments(Long activityId) { return listAttachments(ACTIVITY_FILE_RECORD_TYPE, activityId); } @Override public List getActivityFields(Long activityId) { return activityFieldMapper.selectListByActivityId(activityId); } @Override public List getSignupValues(Long signupId) { return signupValueMapper.selectListBySignupId(signupId); } /** * 保存活动自定义采集字段 * * 先整体清空再插入,保证前端提交的顺序与内容就是最终结果; * 已产生的报名值做字段名/类型快照,不会因为这里重建字段而丢失含义。 */ private void saveActivityFields(Long activityId, List 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 blobIds) { bindAttachments(ACTIVITY_RECORD_TYPE, activityId, blobIds); } /** * 绑定活动详情图片附件(record_type = wxcp_activity_detail),规则同封面 */ private void bindActivityDetailAttachments(Long activityId, List blobIds) { bindAttachments(ACTIVITY_DETAIL_RECORD_TYPE, activityId, blobIds); } private void bindAttachments(String recordType, Long activityId, List blobIds) { SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO(); bindReqVO.setApplication(ATTACHMENT_APPLICATION); bindReqVO.setRecordType(recordType); bindReqVO.setRecordId(activityId); 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); } @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 images = getActivityImageAttachments(activity.getId()); if (CollUtil.isEmpty(images)) { throw exception(ACTIVITY_IMAGE_REQUIRED); } List 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 files = getActivityFileAttachments(activity.getId()); if (CollUtil.isEmpty(files)) { throw exception(ACTIVITY_FILE_REQUIRED); } List 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 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(), 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 fields = getActivityFields(activity.getId()); List 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 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 getSignupAttachments(Long signupId) { return listAttachments(SIGNUP_RECORD_TYPE, signupId); } /** * 组装报名采集值:按字段定义逐项校验必填,没填的字段不落行 */ private List buildSignupValues(Long activityId, List fields, List reqValues) { if (CollUtil.isEmpty(fields)) { return Collections.emptyList(); } Map 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 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 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() .setSql("signup_count = signup_count + 1") .eq(WxCpActivityDO::getId, activityId)); } private void decrementSignupCount(Long activityId) { wxcpActivityMapper.update(null, new LambdaUpdateWrapper() .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 getSignupPage(WxCpSignupPageReqVO pageReqVO) { return wxcpSignupMapper.selectPage(pageReqVO); } @Override public WxCpActivityStatsRespVO getActivityStats(Long activityId) { WxCpActivityDO activity = validateActivityExists(activityId); List all = wxcpSignupMapper.selectListByActivityId(activityId); // 一次遍历同时算总数与三类分布,避免多次全表扫描 int signedUp = 0, checkedIn = 0, canceled = 0; Map deptMap = new LinkedHashMap<>(); Map 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 sourceMap = new LinkedHashMap<>(); sourceMap.put("企业微信身份", fromWxCp); sourceMap.put("表单填写", fromForm); vo.setSourceStats(toNameCountList(sourceMap)); return vo; } private List toNameCountList(Map 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; } }