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.service.storage.SystemStorageAttachmentService; 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.WxCpActivitySignupDO; 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.enums.activity.WxCpActivityStatusEnum; 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.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.TreeMap; 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"); /** * 报名附件的关联记录类型,对应 system_storage_attachment.record_type */ public static final String SIGNUP_RECORD_TYPE = "wxcp_activity_signup"; @Resource private WxCpActivityMapper wxcpActivityMapper; @Resource private WxCpActivitySignupMapper wxcpSignupMapper; @Resource private SystemStorageAttachmentService storageAttachmentService; @Resource private WxCpMessagePushService messagePushService; @Resource private WxCpProperties wxCpProperties; // ==================== 活动管理 ==================== @Override 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.getPushScope() == null) { activity.setPushScope(WxCpPushScopeEnum.ALL.getScope()); } wxcpActivityMapper.insert(activity); return activity.getId(); } @Override 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); } @Override public void deleteActivity(Long id) { validateActivityExists(id); wxcpActivityMapper.deleteById(id); } @Override public WxCpActivityDO getActivity(Long id) { return wxcpActivityMapper.selectById(id); } @Override public PageResult getActivityPage(WxCpActivityPageReqVO pageReqVO) { return wxcpActivityMapper.selectPage(pageReqVO); } @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(); } // 文本卡片消息:按钮直达报名页,点消息或扫码都能报名 WxCpPushResultVO result = messagePushService.pushCard( activity.getTitle(), buildPushDescription(activity), buildSignupUrl(activity.getId()), "立即报名", 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(); } @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); } // 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); } // 4. 去重:有企业微信账号按账号查,否则按手机号查 WxCpActivitySignupDO exists = StrUtil.isNotBlank(reqVO.getWxUserId()) ? wxcpSignupMapper.selectByActivityIdAndWxUserId(activity.getId(), reqVO.getWxUserId().trim()) : wxcpSignupMapper.selectByActivityIdAndMobile(activity.getId(), reqVO.getMobile().trim()); 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(reqVO.getName()) .mobile(reqVO.getMobile()) .deptName(reqVO.getDeptName()) .content(reqVO.getContent()) .remark(reqVO.getRemark()) .status(WxCpSignupStatusEnum.SIGNED_UP.getStatus()) .signupTime(now) .checkInTime(null) .build()); // 重新报名时旧附件已失效,先清掉再绑新的 storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId( SIGNUP_RECORD_TYPE, exists.getId()); bindSignupAttachments(exists.getId(), reqVO.getBlobIds()); incrementSignupCount(activity.getId()); return exists.getId(); } // 5. 新增报名记录 WxCpActivitySignupDO signup = BeanUtils.toBean(reqVO, WxCpActivitySignupDO.class); signup.setWxUserId(StrUtil.trimToEmpty(reqVO.getWxUserId())); signup.setStatus(WxCpSignupStatusEnum.SIGNED_UP.getStatus()); signup.setSignupTime(now); wxcpSignupMapper.insert(signup); // 6. 绑定报名人上传的图片/附件 bindSignupAttachments(signup.getId(), reqVO.getBlobIds()); // 7. 用 SQL 自增,避免并发下的读改写丢失更新 incrementSignupCount(activity.getId()); return signup.getId(); } /** * 绑定报名附件 * * 走 system 模块的附件中间表(record_type = wxcp_activity_signup), * 不新建 upload 接口、不落文件地址字段,符合项目统一文件管理规范。 */ private void bindSignupAttachments(Long signupId, List blobIds) { if (CollUtil.isEmpty(blobIds)) { return; } SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO(); bindReqVO.setApplication("file"); bindReqVO.setRecordType(SIGNUP_RECORD_TYPE); bindReqVO.setRecordId(signupId); bindReqVO.setBlobItems(blobIds.stream() .filter(Objects::nonNull) .map(blobId -> { SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem(); item.setBlobId(blobId); return item; }) .toList()); storageAttachmentService.bindAttachments(bindReqVO); } @Override public List getSignupAttachments(Long signupId) { SystemStorageAttachmentListReqVO listReqVO = new SystemStorageAttachmentListReqVO(); listReqVO.setRecordType(SIGNUP_RECORD_TYPE); listReqVO.setRecordId(signupId); return storageAttachmentService.listAttachments(listReqVO); } 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; } }