| | |
| | | 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 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.*; |
| | |
| | | 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"; |
| | |
| | | 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; |
| | |
| | | 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()); |
| | |
| | | // 封面走 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(); |
| | | } |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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++; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 绑定活动封面附件 |
| | | * |
| | |
| | | * 那个方法会连 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(ACTIVITY_RECORD_TYPE); |
| | | bindReqVO.setRecordType(recordType); |
| | | bindReqVO.setRecordId(activityId); |
| | | bindReqVO.setBlobItems(toBlobItems(blobIds)); |
| | | storageAttachmentService.bindAttachments(bindReqVO); |
| | |
| | | scope = WxCpPushScopeEnum.ALL.getScope(); |
| | | } |
| | | |
| | | // 文本卡片消息:按钮直达报名页,点消息或扫码都能报名 |
| | | WxCpPushResultVO result = messagePushService.pushCard( |
| | | activity.getTitle(), buildPushDescription(activity), |
| | | buildSignupUrl(activity.getId()), "立即报名", scope, target); |
| | | // 按活动模板类型选择推送形态 |
| | | 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() |
| | |
| | | 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); |
| | |
| | | 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(); |
| | |
| | | && 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. 去重:有企业微信账号按账号查;没有账号但有手机号则按手机号查; |
| | | // 两者都没有时无法判定身份,不做去重——否则空手机号会互相误判成同一人 |
| | |
| | | .mobile(mobile) |
| | | .deptName(reqVO.getDeptName()) |
| | | .content(reqVO.getContent()) |
| | | .ratingScore(reqVO.getRatingScore()) |
| | | .remark(reqVO.getRemark()) |
| | | .status(WxCpSignupStatusEnum.SIGNED_UP.getStatus()) |
| | | .signupTime(now) |
| | | .checkInTime(null) |
| | | .build()); |
| | | // 重新报名时旧附件作废,直接重新绑定(bindAttachments 会先清旧关联,且不会删掉文件本身) |
| | | // 重新报名时旧附件/采集值作废,按本次提交覆盖 |
| | | bindSignupAttachments(exists.getId(), reqVO.getBlobIds()); |
| | | signupValueMapper.deleteBySignupId(exists.getId()); |
| | | insertSignupValues(fieldValues, exists.getId()); |
| | | incrementSignupCount(activity.getId()); |
| | | return exists.getId(); |
| | | } |
| | |
| | | // 姓名、手机号改为选填:统一存空串而不是 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. 绑定报名人上传的图片/附件 |
| | | // 6. 绑定报名人上传的图片/附件与采集字段值 |
| | | bindSignupAttachments(signup.getId(), reqVO.getBlobIds()); |
| | | insertSignupValues(fieldValues, signup.getId()); |
| | | |
| | | // 7. 用 SQL 自增,避免并发下的读改写丢失更新 |
| | | incrementSignupCount(activity.getId()); |
| | |
| | | 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") |