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.dal.dataobject.activity.WxCpActivityFieldDO; import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityStatusEnum; import cn.iocoder.yudao.module.wxcp.enums.activity.WxCpActivityTypeEnum; import cn.iocoder.yudao.module.wxcp.service.activity.WxCpActivityService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.annotation.security.PermitAll; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; 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.Collections; import java.util.List; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; /** * 企业微信活动 - 报名落地页与报名接口(免登录) * * 说明:这两个接口刻意放在 controller.admin 包下,从而带上 /admin-api 前缀。 * 原因是前端 Vite 的开发代理只转发 /admin-api,若放到 /app-api, * 扫码后请求会打到前端静态服务返回 404,报名页打不开。 * * @author 超级管理员 */ @Tag(name = "用户 App - 企业微信活动报名") @RestController @RequestMapping("/wxcp/activity") @Validated @Slf4j public class WxCpActivityPublicController { private static final DateTimeFormatter TIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); @Resource private WxCpActivityService activityService; @GetMapping(value = "/signup-page", produces = MediaType.TEXT_HTML_VALUE) @PermitAll @Operation(summary = "活动报名页(HTML)", description = "二维码指向此页面,企业微信成员扫码后打开") @Parameter(name = "id", description = "活动编号", required = true) public String signupPage(@RequestParam("id") Long id, @RequestParam(value = "wxUserId", required = false) String wxUserId) { WxCpActivityDO activity = activityService.getActivity(id); if (activity == null) { return renderMessagePage("活动不存在", "活动可能已被删除,请联系活动发起人确认。"); } if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) { return renderMessagePage("活动未开放", "该活动尚未发布(或已结束/取消),请联系活动发起人确认。"); } // 只有数据采集模板有表单;其它模板只展示详情内容 if (WxCpActivityTypeEnum.needSignup(activity.getActivityType()) && !isInSignupPeriod(activity)) { return renderMessagePage("报名已结束", "该活动当前不在报名时间内,请联系活动发起人确认。"); } return renderSignupPage(activity, wxUserId); } @PostMapping("/signup") @PermitAll @Operation(summary = "提交活动报名", description = "免登录;有企业微信身份时按成员账号去重,否则按手机号去重") public CommonResult signup(@Valid @RequestBody WxCpSignupPublicReqVO reqVO) { return success(activityService.createSignup(reqVO)); } /** * 是否处于报名时间窗口内 */ private boolean isInSignupPeriod(WxCpActivityDO activity) { LocalDateTime now = LocalDateTime.now(); if (activity.getSignupStartTime() != null && now.isBefore(activity.getSignupStartTime())) { return false; } return activity.getSignupEndTime() == null || !now.isAfter(activity.getSignupEndTime()); } private String renderSignupPage(WxCpActivityDO activity, String wxUserId) { StringBuilder sb = new StringBuilder(2048); sb.append("") .append("") .append("").append(esc(activity.getTitle())).append("") .append(""); // 活动信息 sb.append("
"); // 封面图:来自附件中间表(record_type = wxcp_activity),业务表不存文件地址 String coverUrl = resolveCoverUrl(activity.getId()); if (StrUtil.isNotBlank(coverUrl)) { sb.append("\"活动封面\""); } sb.append("

").append(esc(activity.getTitle())).append("

") .append("
"); String timeText = formatTimeRange(activity.getStartTime(), activity.getEndTime()); if (StrUtil.isNotBlank(timeText)) { sb.append("
活动时间").append(esc(timeText)).append("
"); } if (StrUtil.isNotBlank(activity.getLocation())) { sb.append("
活动地点").append(esc(activity.getLocation())).append("
"); } String signupText = formatTimeRange(activity.getSignupStartTime(), activity.getSignupEndTime()); if (StrUtil.isNotBlank(signupText)) { sb.append("
报名时间").append(esc(signupText)).append("
"); } sb.append("
已报名").append(activity.getSignupCount() != null ? activity.getSignupCount() : 0); if (activity.getSignupLimit() != null && activity.getSignupLimit() > 0) { sb.append(" / ").append(activity.getSignupLimit()).append(" 人"); } else { sb.append(" 人(不限名额)"); } sb.append("
"); if (StrUtil.isNotBlank(activity.getContent())) { sb.append("
").append(esc(activity.getContent())).append("
"); } // 详情图片:紧跟在活动详情文字下方(同一张卡片内),按原图比例全宽展示, // 点一下用浏览器打开原图,方便成员放大看二维码/海报 for (String detailImgUrl : resolveDetailImageUrls(activity.getId())) { sb.append("") .append("\"活动详情图片\"") .append(""); } sb.append("
"); // 图片通知模板:展示上传的图片,按原图比例完整展示 appendImageGallery(sb, activity); // 附件通知模板:列出附件,成员点开即可下载 appendFileList(sb, activity); // 只有「数据采集/评价」模板才有填写表单 if (WxCpActivityTypeEnum.needSignup(activity.getActivityType())) { appendSignupForm(sb, activity, wxUserId); } return sb.toString(); } /** * 图片通知模板的图片展示区 */ private void appendImageGallery(StringBuilder sb, WxCpActivityDO activity) { List images = activityService.getActivityImageAttachments(activity.getId()); if (CollUtil.isEmpty(images)) { return; } sb.append("
"); for (SystemStorageBlobRespVO image : images) { String raw = StrUtil.blankToDefault(image.getPreviewURL(), image.getUrl()); if (StrUtil.isBlank(raw)) { continue; } String url = toSameOriginUrl(raw); sb.append("") .append("\"活动图片\"") .append(""); } sb.append("
"); } /** * 附件通知模板的附件列表区 */ private void appendFileList(StringBuilder sb, WxCpActivityDO activity) { List files = activityService.getActivityFileAttachments(activity.getId()); if (CollUtil.isEmpty(files)) { return; } sb.append("
附件下载
") .append("
"); for (SystemStorageBlobRespVO file : files) { String raw = StrUtil.blankToDefault(file.getDownloadURL(), StrUtil.blankToDefault(file.getUrl(), file.getPreviewURL())); if (StrUtil.isBlank(raw)) { continue; } String url = toSameOriginUrl(raw); String name = StrUtil.blankToDefault(file.getOriginalFilename(), StrUtil.blankToDefault(file.getName(), "附件")); sb.append(""); } sb.append("
"); } /** * 数据采集/评价模板的填写表单 */ private void appendSignupForm(StringBuilder sb, WxCpActivityDO activity, String wxUserId) { // 报名表单:姓名、手机号均为选填,只填了手机号才校验格式 sb.append("
") .append("") .append("") .append("") .append("") .append("") .append(""); appendCollectFields(sb, activity); sb.append("") .append("
") .append("") .append("") .append("
") .append("") .append("
提交后如需修改,请联系活动发起人
") .append("
"); // 提交逻辑:先把文件传到 system 的公共上传接口拿到 blobId,再带上 blobId 提交报名 sb.append(""); } /** * 渲染数据采集模板的自定义字段与评价 */ private void appendCollectFields(StringBuilder sb, WxCpActivityDO activity) { String base = "f_"; for (WxCpActivityFieldDO field : activityService.getActivityFields(activity.getId())) { String id = base + field.getId(); String label = esc(field.getFieldLabel()); String required = Boolean.TRUE.equals(field.getRequired()) ? " *" : ""; String placeholder = esc(StrUtil.blankToDefault(field.getPlaceholder(), "请输入" + field.getFieldLabel())); String type = StrUtil.blankToDefault(field.getFieldType(), WxCpActivityFieldDO.TYPE_TEXT); switch (type) { case WxCpActivityFieldDO.TYPE_TEXTAREA -> sb.append("") .append(""); case WxCpActivityFieldDO.TYPE_NUMBER -> sb.append("") .append(""); case WxCpActivityFieldDO.TYPE_DATE -> sb.append("") .append(""); case WxCpActivityFieldDO.TYPE_SELECT -> { sb.append("") .append(""); } case WxCpActivityFieldDO.TYPE_RADIO -> { sb.append("
"); for (String option : splitOptions(field.getFieldOptions())) { sb.append(""); } sb.append("
"); } case WxCpActivityFieldDO.TYPE_CHECKBOX -> { sb.append("
"); for (String option : splitOptions(field.getFieldOptions())) { sb.append(""); } sb.append("
"); } case WxCpActivityFieldDO.TYPE_RATING -> { sb.append("
"); int max = activity.getRatingMax() != null && activity.getRatingMax() > 0 ? activity.getRatingMax() : 5; for (int i = 1; i <= max; i++) { sb.append(""); } sb.append("
"); } default -> sb.append("") .append(""); } } // 独立评价项:开启后必须选择星级 if (Boolean.TRUE.equals(activity.getRatingEnabled())) { int max = activity.getRatingMax() != null && activity.getRatingMax() > 0 ? activity.getRatingMax() : 5; sb.append("
"); for (int i = 1; i <= max; i++) { sb.append(""); } sb.append("
"); } } private List splitOptions(String options) { if (StrUtil.isBlank(options)) { return Collections.emptyList(); } return StrUtil.split(options, ',', true, true); } /** * 把自定义字段定义转成前端提交脚本可用的 JSON */ private String buildFieldsJson(WxCpActivityDO activity) { List fields = activityService.getActivityFields(activity.getId()); StringBuilder json = new StringBuilder("["); for (int i = 0; i < fields.size(); i++) { WxCpActivityFieldDO field = fields.get(i); if (i > 0) { json.append(','); } json.append("{\"id\":").append(field.getId()) .append(",\"label\":\"").append(esc(jsStr(field.getFieldLabel()))).append("\"") .append(",\"type\":\"").append(esc(jsStr(field.getFieldType()))).append("\"") .append(",\"required\":").append(Boolean.TRUE.equals(field.getRequired())).append('}'); } return json.append(']').toString(); } /** * 取活动详情图片地址列表(按上传先后排序,地址同样转成同源相对路径) * * 与封面分开取:封面按 16:9 裁剪展示,详情图按原图比例完整展示, * 二维码这类不能裁的图要放详情图里。 */ private List resolveDetailImageUrls(Long activityId) { List attachments = activityService.getActivityDetailAttachments(activityId); if (CollUtil.isEmpty(attachments)) { return Collections.emptyList(); } return attachments.stream() .map(attachment -> StrUtil.blankToDefault(attachment.getPreviewURL(), attachment.getUrl())) .filter(StrUtil::isNotBlank) .map(this::toSameOriginUrl) .toList(); } /** * 取活动封面地址(取附件列表里的第一张) */ private String resolveCoverUrl(Long activityId) { List 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 "" + "" + "" + esc(title) + "

" + esc(title) + "

" + esc(desc) + "

"; } private String formatTimeRange(LocalDateTime start, LocalDateTime end) { if (start == null && end == null) { return ""; } if (start == null) { return "截止 " + end.format(TIME_FMT); } if (end == null) { return start.format(TIME_FMT) + " 起"; } return start.format(TIME_FMT) + " ~ " + end.format(TIME_FMT); } /** * HTML 转义,避免活动标题/内容里的特殊字符破坏页面或造成 XSS */ private String esc(String raw) { if (raw == null) { return ""; } return raw.replace("&", "&") .replace("<", "<") .replace(">", ">") .replace("\"", """) .replace("'", "'"); } /** * JS 字符串字面量转义(在 esc 基础上再处理换行与反斜杠) */ private String jsStr(String raw) { return raw.replace("\\", "\\\\") .replace("\n", "\\n") .replace("\r", "\\r"); } }