liu
9 小时以前 5d34bdd3c426bb2ca5f6627424ae67984f745603
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java
@@ -7,13 +7,20 @@
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;
@@ -28,14 +35,19 @@
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.*;
@@ -53,6 +65,11 @@
    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";
@@ -63,17 +80,51 @@
    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;
@@ -90,6 +141,23 @@
        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());
@@ -98,6 +166,21 @@
        // 封面走 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();
    }
@@ -122,6 +205,23 @@
        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
@@ -129,8 +229,13 @@
    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
@@ -148,6 +253,69 @@
        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++;
        }
    }
    /**
     * 绑定活动封面附件
     *
@@ -159,9 +327,20 @@
     * 那个方法会连 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);
@@ -235,10 +414,28 @@
            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()
@@ -280,6 +477,150 @@
        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);
@@ -311,6 +652,12 @@
        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();
@@ -326,6 +673,12 @@
                && 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. 去重:有企业微信账号按账号查;没有账号但有手机号则按手机号查;
        //    两者都没有时无法判定身份,不做去重——否则空手机号会互相误判成同一人
@@ -348,13 +701,16 @@
                    .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();
        }
@@ -365,12 +721,14 @@
        // 姓名、手机号改为选填:统一存空串而不是 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());
@@ -398,6 +756,65 @@
        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")