liu
2026-09-30 91491239c5d4611b5b1404a7abeb2bb23d0edad5
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\">"