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); /** * 发布活动(草稿 -> 已发布) * * @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