liu
2026-09-30 91491239c5d4611b5b1404a7abeb2bb23d0edad5
feat(wxcp): 活动封面改用附件中间表,报名姓名手机号改为选填

- 活动表去掉 cover_url 字段,封面统一走 system_storage_attachment
(record_type = wxcp_activity),并注册进 StorageRecordTypeEnum 白名单
- 活动创建/修改接收 blobIds,列表与详情返回 attachmentList;
删除活动时一并清理封面附件关系
- 报名页顶部展示封面:附件地址剥掉协议与域名转同源路径,避免手机扫码时
直连 yudao.storage.domain 内网地址加载不出图片
- 报名姓名、手机号改为选填,手机号填了才校验格式;
两者都没有时不判重,避免空值互相误判成同一人
- 重新报名不再先删附件(deleteAttachmentsByRecordTypeAndRecordId 会连文件一起删),
改为 bindAttachments 全量覆盖关联,只清关系不删文件
- 测试环境 signup-url-prefix 端口 9007 改为 9008:9007 是前端端口且无 /admin-api 代理,
扫码打开报名页必 404
- 同步更新 wxcp_module.sql 升级脚本与活动联调文档

Co-Authored-By: Claude <noreply@anthropic.com>
已修改13个文件
290 ■■■■■ 文件已修改
docs/sql/config_export_all_20260929.sql 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/wxcp_module.sql 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/wxcp_activity_integration.md 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityController.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityRespVO.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivitySaveReqVO.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPublicReqVO.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityService.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-test.yaml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/sql/config_export_all_20260929.sql
@@ -11469,7 +11469,6 @@
  `id`                 bigint       NOT NULL AUTO_INCREMENT                COMMENT '编号',
  `title`              varchar(128) NOT NULL                               COMMENT '活动标题',
  `content`            text                                                COMMENT '活动详情',
  `cover_url`          varchar(512)          DEFAULT ''                   COMMENT '封面图地址',
  `location`           varchar(255)          DEFAULT ''                   COMMENT '活动地点',
  `start_time`         datetime              DEFAULT NULL                 COMMENT '活动开始时间',
  `end_time`           datetime              DEFAULT NULL                 COMMENT '活动结束时间',
docs/sql/wxcp_module.sql
@@ -3,8 +3,9 @@
-- 目标库:mom-jssm(36.213.150.237:10000)
-- 说明:项目不使用多租户,因此不含 tenant_id 字段
--
-- 报名提交的图片/附件不落本模块的表:
-- 统一走 system_storage_attachment 中间表,record_type = 'wxcp_activity_signup'
-- 活动封面、报名提交的图片/附件都不落本模块的表:
-- 统一走 system_storage_attachment 中间表,
-- 活动封面 record_type = 'wxcp_activity',报名附件 record_type = 'wxcp_activity_signup'
-- ============================================================
-- ----------------------------
@@ -14,7 +15,6 @@
  `id`                 bigint       NOT NULL AUTO_INCREMENT                COMMENT '编号',
  `title`              varchar(128) NOT NULL                               COMMENT '活动标题',
  `content`            text                                                COMMENT '活动详情',
  `cover_url`          varchar(512)          DEFAULT ''                   COMMENT '封面图地址',
  `location`           varchar(255)          DEFAULT ''                   COMMENT '活动地点',
  `start_time`         datetime              DEFAULT NULL                 COMMENT '活动开始时间',
  `end_time`           datetime              DEFAULT NULL                 COMMENT '活动结束时间',
@@ -86,3 +86,17 @@
  PRIMARY KEY (`id`),
  KEY `idx_status` (`status`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企业微信通知';
-- ============================================================
-- 升级脚本:封面图从「业务表存地址」改为「附件中间表」
-- 已建过库的环境执行一次即可;新库执行上面的 CREATE TABLE 已是最新结构,无需执行
-- MySQL 的 ADD/DROP COLUMN 不支持 IF NOT EXISTS,这里先判断再执行
-- ============================================================
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE() AND table_name = 'wxcp_activity' AND column_name = 'cover_url');
SET @sql = IF(@exists > 0,
              'ALTER TABLE `wxcp_activity` DROP COLUMN `cover_url`',
              'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
docs/wxcp_activity_integration.md
@@ -52,7 +52,7 @@
| id | Long | 修改时必填 | 编号 |
| title | String | 是 | 活动标题,≤128 字符 |
| content | String | 否 | 活动详情 |
| coverUrl | String | 否 | 封面图地址 |
| blobIds | Long[] | 否 | **封面图文件编号**,最多 1 个;不传 = 不改动封面,传 `[]` = 清空封面 |
| location | String | 否 | 活动地点,≤255 字符 |
| startTime | DateTime | 否 | 活动开始时间 |
| endTime | DateTime | 否 | 活动结束时间 |
@@ -72,6 +72,7 @@
| signupCount | Integer | 已报名人数 |
| pushStatus | Integer | 0未推送 1已推送 |
| pushTime | DateTime | 最近推送时间 |
| attachmentList | Object[] | **封面图附件列表**(最多 1 条),字段同报名附件:`id`、`name`、`url`、`previewURL`、`downloadURL`。列表缩略图、表单回显都从这里取 |
### 推送与二维码
@@ -116,6 +117,18 @@
|------|------|------|
| GET | `/wxcp/activity/signup-page?id={活动编号}` | 报名落地页 HTML,由后端渲染 |
| POST | `/wxcp/activity/signup` | 提交报名(支持 `content` 文字 + `blobIds` 附件) |
**提交报名请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| activityId | Long | 是 | 活动编号 |
| name | String | 否 | 姓名,≤64 字符 |
| mobile | String | 否 | 手机号;**填了才校验** 11 位格式,留空通过 |
| wxUserId | String | 否 | 企业微信成员账号 |
| deptName | String | 否 | 部门名称 |
| content | String | 否 | 参与说明 |
| blobIds | Long[] | 否 | 附件文件编号 |
### 报名统计与附件
@@ -180,7 +193,7 @@
1. 新建活动 → 保存为**草稿**,此时扫码报名会提示「报名已结束」
2. 点「发布」→ 状态变「已发布」,此时二维码和报名页才可用
3. 点「推送」→ 成员企业微信收到卡片消息,标题为活动标题,正文含**活动时间 / 地点 / 报名截止 / 名额**,按钮「立即报名」直达报名页
4. 成员点按钮或扫码 → 打开报名页 → 填姓名和手机号 → 提交
4. 成员点按钮或扫码 → 打开报名页 →(可选)填姓名和手机号 + 传附件 → 提交
5. 报名成功 → 活动 `signupCount` 自动 +1
6. 后台在活动详情的报名记录列表里可看到该成员,可执行「取消报名」「签到」
@@ -219,9 +232,12 @@
| 推送成功后 | 活动 `pushStatus` 置 1、`pushTime` 记录时间;重复推送会覆盖这两个字段 |
| 报名时间窗口 | 未到 `signupStartTime` 或已过 `signupEndTime` 均不能报名 |
| 报名名额 | `signupLimit=0` 表示不限;已满时提示「报名人数已满」 |
| 重复报名 | 有企业微信身份按成员账号判重;普通浏览器按**手机号**判重 |
| 报名姓名/手机号 | **均为选填**。手机号填了才校验格式,留空允许提交 |
| 重复报名 | 有企业微信身份按成员账号判重;没有账号但有手机号则按手机号判重;**两者都没有时不做判重**(无法判定身份,每次提交都新建一条) |
| 取消后重新报名 | 复用原记录,不会产生第二条历史 |
| 取消报名 | 活动 `signupCount` 自动 -1 |
| 封面图 | 不存业务表字段,存 `system_storage_attachment`(`record_type = wxcp_activity`)。删除活动时一并清掉附件关系 |
| 封面图展示 | 报名页顶部以 `object-fit: cover` 展示;管理后台列表在标题左侧显示缩略图 |
## 注意事项
@@ -236,6 +252,9 @@
- **推送目标用的是 `userId`(成员账号,如 `LiuYi`),不是姓名、也不是手机号。** 从通讯录接口取 `userId` 填进 `pushTarget`。
- **附件上传走 system 模块的公共上传接口**:报名页先调 `POST /admin-api/system/storage-blob/public-upload`(表单字段名 `files`)拿到 `blobId`,再把 `blobIds` 随报名提交,由**服务端**完成与报名记录的绑定。管理后台查附件用 `/wxcp/activity/signup/attachments`。
- **报名的 `recordType` 固定为 `wxcp_activity_signup`**,已注册进 system 模块的 `StorageRecordTypeEnum` 白名单。以后新增别的附件关联类型,必须同步注册,否则绑定会报「无效的记录类型」。
- **活动封面的 `recordType` 为 `wxcp_activity`**,同样已注册进白名单。封面**不再有 `coverUrl` 字段**,`POST/PUT /wxcp/activity/create|update` 提交 `blobIds`,`GET` 读 `attachmentList`。
- **封面图在报名页显示时会去掉协议和域名**。附件地址里的域名取自 `yudao.storage.domain`(一般是内网地址),而手机扫码是从公网域名打开页面的,直连内网地址加载不出图片,所以后端只保留 `/admin-api/...` 路径交给同源代理。**如果部署时管理后台与报名页不同源,这个处理需要重新评估。**
- 表单里的封面用 `ImageUpload` 组件(`maxNumber: 1`)时,组件返回的是**单值而不是数组**,提交前必须包成数组(`blobIds` 后端签名是 `List<Long>`)。
- 通讯录接口**实时调企业微信**,有频率限制,前端务必缓存,不要随输入实时请求。
- 通知正文超过 600 字会**自动截断**(企业微信文本消息有长度上限),截断提示会写在消息末尾。
yudao-module-system/src/main/java/cn/iocoder/yudao/module/system/enums/storage/StorageRecordTypeEnum.java
@@ -85,6 +85,7 @@
    ARCHIVE_DOCUMENT("archive_document", "档案"),
    // ===== 企业微信活动 =====
    WXCP_ACTIVITY("wxcp_activity", "企业微信活动封面"),
    WXCP_ACTIVITY_SIGNUP("wxcp_activity_signup", "企业微信活动报名");
    private final String type;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityController.java
@@ -83,7 +83,9 @@
    @PreAuthorize("@ss.hasPermission('wxcp:activity:query')")
    public CommonResult<PageResult<WxCpActivityRespVO>> getActivityPage(@Valid WxCpActivityPageReqVO pageReqVO) {
        PageResult<WxCpActivityDO> pageResult = activityService.getActivityPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, WxCpActivityRespVO.class, this::fillActivityNames));
        PageResult<WxCpActivityRespVO> result = BeanUtils.toBean(pageResult, WxCpActivityRespVO.class);
        result.getList().forEach(this::fillActivityExtra);
        return success(result);
    }
    @PutMapping("/publish")
@@ -206,15 +208,24 @@
            return null;
        }
        WxCpActivityRespVO vo = BeanUtils.toBean(activity, WxCpActivityRespVO.class);
        fillActivityNames(vo);
        fillActivityExtra(vo);
        return vo;
    }
    private void fillActivityNames(WxCpActivityRespVO vo) {
    /**
     * 补齐状态名、推送范围名与封面附件
     *
     * 封面不落业务表字段,统一从 system_storage_attachment 中间表读,
     * 前端取 attachmentList[0].previewURL 展示。
     */
    private void fillActivityExtra(WxCpActivityRespVO vo) {
        WxCpActivityStatusEnum status = WxCpActivityStatusEnum.valueOf(vo.getStatus());
        vo.setStatusName(status != null ? status.getName() : null);
        WxCpPushScopeEnum scope = WxCpPushScopeEnum.valueOf(vo.getPushScope());
        vo.setPushScopeName(scope != null ? scope.getName() : null);
        if (vo.getId() != null) {
            vo.setAttachmentList(activityService.getActivityAttachments(vo.getId()));
        }
    }
}
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/WxCpActivityPublicController.java
@@ -1,7 +1,9 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo.WxCpSignupPublicReqVO;
import cn.iocoder.yudao.module.wxcp.dal.dataobject.activity.WxCpActivityDO;
import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum;
@@ -17,8 +19,10 @@
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -91,6 +95,7 @@
                .append("body{margin:0;padding:16px;font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Microsoft YaHei',sans-serif;")
                .append("background:#f5f6f8;color:#1f2329;font-size:15px;line-height:1.6}")
                .append(".card{background:#fff;border-radius:12px;padding:18px;margin-bottom:12px;box-shadow:0 1px 4px rgba(0,0,0,.06)}")
                .append(".cover{display:block;width:100%;max-height:260px;object-fit:cover;border-radius:8px;margin-bottom:14px;background:#f2f3f5}")
                .append("h1{font-size:19px;margin:0 0 14px;font-weight:600;word-break:break-all}")
                .append(".meta{color:#646a73;font-size:14px}")
                .append(".meta div{margin-bottom:6px;display:flex}")
@@ -113,7 +118,13 @@
                .append("</style></head><body>");
        // 活动信息
        sb.append("<div class=\"card\"><h1>").append(esc(activity.getTitle())).append("</h1>")
        sb.append("<div class=\"card\">");
        // 封面图:来自附件中间表(record_type = wxcp_activity),业务表不存文件地址
        String coverUrl = resolveCoverUrl(activity.getId());
        if (StrUtil.isNotBlank(coverUrl)) {
            sb.append("<img class=\"cover\" src=\"").append(esc(coverUrl)).append("\" alt=\"活动封面\">");
        }
        sb.append("<h1>").append(esc(activity.getTitle())).append("</h1>")
                .append("<div class=\"meta\">");
        String timeText = formatTimeRange(activity.getStartTime(), activity.getEndTime());
        if (StrUtil.isNotBlank(timeText)) {
@@ -139,12 +150,12 @@
        }
        sb.append("</div>");
        // 报名表单
        // 报名表单:姓名、手机号均为选填,只填了手机号才校验格式
        sb.append("<div class=\"card\">")
                .append("<label>姓名 <span style=\"color:#f53f3f\">*</span></label>")
                .append("<input id=\"name\" type=\"text\" placeholder=\"请输入姓名\" maxlength=\"64\" autocomplete=\"name\">")
                .append("<label>手机号 <span style=\"color:#f53f3f\">*</span></label>")
                .append("<input id=\"mobile\" type=\"tel\" inputmode=\"numeric\" placeholder=\"请输入 11 位手机号\" maxlength=\"11\" autocomplete=\"tel\">")
                .append("<label>姓名</label>")
                .append("<input id=\"name\" type=\"text\" placeholder=\"请输入姓名(选填)\" maxlength=\"64\" autocomplete=\"name\">")
                .append("<label>手机号</label>")
                .append("<input id=\"mobile\" type=\"tel\" inputmode=\"numeric\" placeholder=\"请输入 11 位手机号(选填)\" maxlength=\"11\" autocomplete=\"tel\">")
                .append("<label>参与说明</label>")
                .append("<textarea id=\"content\" placeholder=\"可填写备注、需求或其他说明(选填)\" maxlength=\"1000\"></textarea>")
                .append("<label>图片 / 附件</label>")
@@ -172,8 +183,7 @@
                .append("var n=document.getElementById('name').value.trim();")
                .append("var m=document.getElementById('mobile').value.trim();")
                .append("var c=document.getElementById('content').value.trim();")
                .append("if(!n){tip('请填写姓名');return}")
                .append("if(!/^1[3-9]\\d{9}$/.test(m)){tip('手机号格式不正确');return}")
                .append("if(m&&!/^1[3-9]\\d{9}$/.test(m)){tip('手机号格式不正确,请填 11 位手机号或留空');return}")
                .append("btn.disabled=true;")
                .append("var fs=fileInput.files;")
                .append("var upload=Promise.resolve([]);")
@@ -202,6 +212,50 @@
        return sb.toString();
    }
    /**
     * 取活动封面地址(取附件列表里的第一张)
     */
    private String resolveCoverUrl(Long activityId) {
        List<SystemStorageBlobRespVO> attachments = activityService.getActivityAttachments(activityId);
        if (CollUtil.isEmpty(attachments)) {
            return null;
        }
        SystemStorageBlobRespVO cover = attachments.get(0);
        String raw = StrUtil.blankToDefault(cover.getPreviewURL(), cover.getUrl());
        if (StrUtil.isBlank(raw)) {
            return null;
        }
        return toSameOriginUrl(raw);
    }
    /**
     * 把附件地址转成同源相对地址
     *
     * 附件里存的是带签名的绝对地址,域名取自 yudao.storage.domain(通常是内网地址)。
     * 而手机扫码是通过公网域名打开本页面的,直连内网地址会加载不出图片。
     * 这里剥掉协议与域名只留 /admin-api/... 路径,交给同源代理转发,
     * 内网、公网访问都能正常显示;因为只取路径不取域名,也不存在跳转到外部站点的风险。
     */
    private String toSameOriginUrl(String url) {
        String trimmed = url.trim();
        if (!StrUtil.startWithAnyIgnoreCase(trimmed, "http://", "https://")) {
            return trimmed;
        }
        try {
            URI uri = URI.create(trimmed);
            String path = uri.getRawPath();
            if (StrUtil.isBlank(path)) {
                return trimmed;
            }
            String query = uri.getRawQuery();
            return StrUtil.isBlank(query) ? path : path + "?" + query;
        } catch (IllegalArgumentException e) {
            // 地址不合规就原样透出,不能因为一张封面图让整页打不开
            log.warn("活动封面地址解析失败,按原样输出:{}", trimmed);
            return trimmed;
        }
    }
    private String renderMessagePage(String title, String desc) {
        return "<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">"
                + "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivityRespVO.java
@@ -1,9 +1,11 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 企业微信活动 Response VO")
@Data
@@ -18,8 +20,8 @@
    @Schema(description = "活动详情")
    private String content;
    @Schema(description = "封面图地址")
    private String coverUrl;
    @Schema(description = "封面图附件列表(最多 1 张)")
    private List<SystemStorageBlobRespVO> attachmentList;
    @Schema(description = "活动地点", example = "公司大会议室")
    private String location;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpActivitySaveReqVO.java
@@ -9,6 +9,7 @@
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Schema(description = "管理后台 - 企业微信活动 创建/修改 Request VO")
@Data
@@ -25,9 +26,9 @@
    @Schema(description = "活动详情")
    private String content;
    @Schema(description = "封面图地址")
    @Size(max = 512, message = "封面图地址长度不能超过 512 个字符")
    private String coverUrl;
    @Schema(description = "封面图文件编号列表(最多 1 张)", example = "[1]")
    @Size(max = 1, message = "封面图最多上传 1 张")
    private List<Long> blobIds;
    @Schema(description = "活动地点", example = "公司大会议室")
    @Size(max = 255, message = "活动地点长度不能超过 255 个字符")
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/controller/admin/activity/vo/WxCpSignupPublicReqVO.java
@@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.wxcp.controller.admin.activity.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@@ -17,14 +16,12 @@
    @NotNull(message = "活动编号不能为空")
    private Long activityId;
    @Schema(description = "姓名", requiredMode = Schema.RequiredMode.REQUIRED, example = "刘意")
    @NotEmpty(message = "姓名不能为空")
    @Schema(description = "姓名(选填)", example = "刘意")
    @Size(max = 64, message = "姓名长度不能超过 64 个字符")
    private String name;
    @Schema(description = "手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "13800138000")
    @NotEmpty(message = "手机号不能为空")
    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    @Schema(description = "手机号(选填,填了才校验格式)", example = "13800138000")
    @Pattern(regexp = "^$|^1[3-9]\\d{9}$", message = "手机号格式不正确,请填写 11 位手机号或留空")
    private String mobile;
    @Schema(description = "企业微信成员账号;从企业微信内打开时由前端自动带上,普通浏览器报名可不传")
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/dal/dataobject/activity/WxCpActivityDO.java
@@ -39,10 +39,6 @@
     */
    private String content;
    /**
     * 封面图地址
     */
    private String coverUrl;
    /**
     * 活动地点
     */
    private String location;
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityService.java
@@ -59,6 +59,14 @@
    PageResult<WxCpActivityDO> getActivityPage(@Valid WxCpActivityPageReqVO pageReqVO);
    /**
     * 获得活动封面附件列表(最多 1 张)
     *
     * @param activityId 活动编号
     * @return 附件(含访问地址)
     */
    List<SystemStorageBlobRespVO> getActivityAttachments(Long activityId);
    /**
     * 发布活动(草稿 -&gt; 已发布)
     *
     * @param id 活动编号
yudao-module-wxcp/src/main/java/cn/iocoder/yudao/module/wxcp/service/activity/WxCpActivityServiceImpl.java
@@ -30,6 +30,7 @@
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -56,6 +57,16 @@
     */
    public static final String SIGNUP_RECORD_TYPE = "wxcp_activity_signup";
    /**
     * 活动封面的关联记录类型,对应 system_storage_attachment.record_type
     */
    public static final String ACTIVITY_RECORD_TYPE = "wxcp_activity";
    /**
     * 附件用途,与其它模块保持一致
     */
    private static final String ATTACHMENT_APPLICATION = "file";
    @Resource
    private WxCpActivityMapper wxcpActivityMapper;
    @Resource
@@ -72,6 +83,7 @@
    // ==================== 活动管理 ====================
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Long createActivity(WxCpActivitySaveReqVO createReqVO) {
        WxCpActivityDO activity = BeanUtils.toBean(createReqVO, WxCpActivityDO.class);
        // 新建的一律是草稿,避免未配置完就被扫码报名
@@ -83,10 +95,15 @@
            activity.setPushScope(WxCpPushScopeEnum.ALL.getScope());
        }
        wxcpActivityMapper.insert(activity);
        // 封面走 system 的附件中间表,业务表不落文件地址字段
        if (CollUtil.isNotEmpty(createReqVO.getBlobIds())) {
            bindActivityAttachments(activity.getId(), createReqVO.getBlobIds());
        }
        return activity.getId();
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void updateActivity(WxCpActivitySaveReqVO updateReqVO) {
        WxCpActivityDO activity = validateActivityExists(updateReqVO.getId());
        // 已结束或已取消的活动不再允许修改
@@ -101,12 +118,19 @@
        updateObj.setPushStatus(null);
        updateObj.setPushTime(null);
        wxcpActivityMapper.updateById(updateObj);
        // blobIds 为 null 表示请求里没带这个字段,视为不改动封面;传空数组才是明确要清空封面
        if (updateReqVO.getBlobIds() != null) {
            bindActivityAttachments(activity.getId(), updateReqVO.getBlobIds());
        }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public void deleteActivity(Long id) {
        validateActivityExists(id);
        wxcpActivityMapper.deleteById(id);
        // 活动删了,封面附件关系一并清掉,避免残留成孤儿附件
        storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(ACTIVITY_RECORD_TYPE, id);
    }
    @Override
@@ -117,6 +141,53 @@
    @Override
    public PageResult<WxCpActivityDO> getActivityPage(WxCpActivityPageReqVO pageReqVO) {
        return wxcpActivityMapper.selectPage(pageReqVO);
    }
    @Override
    public List<SystemStorageBlobRespVO> getActivityAttachments(Long activityId) {
        return listAttachments(ACTIVITY_RECORD_TYPE, activityId);
    }
    /**
     * 绑定活动封面附件
     *
     * 走 system 模块的附件中间表(record_type = wxcp_activity),
     * 业务表不加文件地址列、不自建 upload 接口,符合项目统一文件管理规范。
     *
     * 传空集合会清掉关联记录但保留文件本身(bindAttachments 只删中间表记录)。
     * 注意不要改用 deleteAttachmentsByRecordTypeAndRecordId 来"先删后加":
     * 那个方法会连 blob 一起删掉,同一个文件重新绑定时会因为文件已不存在而查不出来。
     */
    private void bindActivityAttachments(Long activityId, List<Long> blobIds) {
        SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
        bindReqVO.setApplication(ATTACHMENT_APPLICATION);
        bindReqVO.setRecordType(ACTIVITY_RECORD_TYPE);
        bindReqVO.setRecordId(activityId);
        bindReqVO.setBlobItems(toBlobItems(blobIds));
        storageAttachmentService.bindAttachments(bindReqVO);
    }
    private List<SystemStorageAttachmentSaveReqVO.BlobItem> toBlobItems(List<Long> blobIds) {
        if (CollUtil.isEmpty(blobIds)) {
            return Collections.emptyList();
        }
        return blobIds.stream()
                .filter(Objects::nonNull)
                .distinct()
                .map(blobId -> {
                    SystemStorageAttachmentSaveReqVO.BlobItem item = new SystemStorageAttachmentSaveReqVO.BlobItem();
                    item.setBlobId(blobId);
                    return item;
                })
                .toList();
    }
    private List<SystemStorageBlobRespVO> listAttachments(String recordType, Long recordId) {
        SystemStorageAttachmentListReqVO listReqVO = new SystemStorageAttachmentListReqVO();
        listReqVO.setApplication(ATTACHMENT_APPLICATION);
        listReqVO.setRecordType(recordType);
        listReqVO.setRecordId(recordId);
        return storageAttachmentService.listAttachments(listReqVO);
    }
    @Override
@@ -256,10 +327,16 @@
            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());
        // 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);
        }
@@ -267,8 +344,8 @@
        if (exists != null) {
            wxcpSignupMapper.updateById(WxCpActivitySignupDO.builder()
                    .id(exists.getId())
                    .name(reqVO.getName())
                    .mobile(reqVO.getMobile())
                    .name(StrUtil.trimToEmpty(reqVO.getName()))
                    .mobile(mobile)
                    .deptName(reqVO.getDeptName())
                    .content(reqVO.getContent())
                    .remark(reqVO.getRemark())
@@ -276,9 +353,7 @@
                    .signupTime(now)
                    .checkInTime(null)
                    .build());
            // 重新报名时旧附件已失效,先清掉再绑新的
            storageAttachmentService.deleteAttachmentsByRecordTypeAndRecordId(
                    SIGNUP_RECORD_TYPE, exists.getId());
            // 重新报名时旧附件作废,直接重新绑定(bindAttachments 会先清旧关联,且不会删掉文件本身)
            bindSignupAttachments(exists.getId(), reqVO.getBlobIds());
            incrementSignupCount(activity.getId());
            return exists.getId();
@@ -286,7 +361,10 @@
        // 5. 新增报名记录
        WxCpActivitySignupDO signup = BeanUtils.toBean(reqVO, WxCpActivitySignupDO.class);
        signup.setWxUserId(StrUtil.trimToEmpty(reqVO.getWxUserId()));
        signup.setWxUserId(wxUserId);
        // 姓名、手机号改为选填:统一存空串而不是 null,避免后续按手机号查重时空值语义混乱
        signup.setName(StrUtil.trimToEmpty(reqVO.getName()));
        signup.setMobile(mobile);
        signup.setStatus(WxCpSignupStatusEnum.SIGNED_UP.getStatus());
        signup.setSignupTime(now);
        wxcpSignupMapper.insert(signup);
@@ -300,36 +378,24 @@
    }
    /**
     * 绑定报名附件
     * 绑定报名附件(全量覆盖:先清掉该报名记录下的旧关联,再绑新的)
     *
     * 走 system 模块的附件中间表(record_type = wxcp_activity_signup),
     * 不新建 upload 接口、不落文件地址字段,符合项目统一文件管理规范。
     * 传空集合只清关联、不删文件本身。
     */
    private void bindSignupAttachments(Long signupId, List<Long> blobIds) {
        if (CollUtil.isEmpty(blobIds)) {
            return;
        }
        SystemStorageAttachmentSaveReqVO bindReqVO = new SystemStorageAttachmentSaveReqVO();
        bindReqVO.setApplication("file");
        bindReqVO.setApplication(ATTACHMENT_APPLICATION);
        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());
        bindReqVO.setBlobItems(toBlobItems(blobIds));
        storageAttachmentService.bindAttachments(bindReqVO);
    }
    @Override
    public List<SystemStorageBlobRespVO> getSignupAttachments(Long signupId) {
        SystemStorageAttachmentListReqVO listReqVO = new SystemStorageAttachmentListReqVO();
        listReqVO.setRecordType(SIGNUP_RECORD_TYPE);
        listReqVO.setRecordId(signupId);
        return storageAttachmentService.listAttachments(listReqVO);
        return listAttachments(SIGNUP_RECORD_TYPE, signupId);
    }
    private void incrementSignupCount(Long activityId) {
yudao-server/src/main/resources/application-test.yaml
@@ -8,7 +8,7 @@
    aes-key: # EncodingAESKey,接收加密消息时才需要
    # 报名页外网地址前缀:必须是企业微信成员手机能访问到的地址(内网穿透域名或正式域名),
    # 生成的二维码内容为 {signupUrlPrefix}/admin-api/wxcp/activity/signup-page?id={活动编号}
    signup-url-prefix: http://36.213.150.237:9007
    signup-url-prefix: http://36.213.150.237:9008 # 后端端口(容器 9008->48080);填 9007 会打到前端,而前端无 /admin-api 代理,扫码必 404
    qrcode-size: 400 # 报名二维码边长(像素),不配置默认 400
server:
  port: 48080