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<Long> 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("<!DOCTYPE html><html lang=\"zh-CN\"><head><meta charset=\"UTF-8\">")
|
.append("<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1\">")
|
.append("<title>").append(esc(activity.getTitle())).append("</title>")
|
.append("<style>")
|
.append("*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}")
|
.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}")
|
// 详情图必须按原图比例完整展示:二维码/海报被裁边就会失效,所以这里不能用 object-fit:cover
|
.append(".detail-img{display:block;margin:12px 0 0}")
|
.append(".detail-img img{display:block;width:100%;height:auto;border-radius:8px;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}")
|
.append(".meta b{color:#1f2329;font-weight:500;flex:0 0 72px}")
|
.append(".meta span{flex:1;word-break:break-all}")
|
.append(".desc{white-space:pre-wrap;word-break:break-word;color:#3f444d}")
|
.append("label{display:block;font-size:14px;color:#646a73;margin:14px 0 6px}")
|
.append("input,textarea{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}")
|
.append("input:focus,textarea:focus,select:focus{border-color:#3370ff}")
|
.append("select{width:100%;padding:11px 12px;border:1px solid #dee0e3;border-radius:8px;font-size:15px;outline:none;background:#fff;font-family:inherit}")
|
.append("textarea{resize:vertical;min-height:84px}")
|
.append(".file-title{font-weight:600;margin-bottom:8px}")
|
.append(".file-list a{color:#3370ff;text-decoration:none;word-break:break-all}")
|
.append(".choice-row{display:flex;flex-wrap:wrap;gap:14px;margin-top:4px}")
|
.append(".choice{display:inline-flex;align-items:center;gap:5px;margin:0;color:#1f2329;font-size:15px}")
|
.append(".file-row{margin-top:8px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}")
|
.append(".file-btn{display:inline-block;padding:8px 14px;border:1px dashed #c9cdd4;border-radius:8px;color:#3370ff;font-size:14px;background:#fff}")
|
.append(".file-list{margin-top:8px;font-size:13px;color:#646a73;word-break:break-all}")
|
.append(".file-list div{padding:3px 0}")
|
.append("button{width:100%;margin-top:22px;padding:13px;border:0;border-radius:8px;background:#3370ff;color:#fff;font-size:16px;font-weight:500}")
|
.append("button:disabled{background:#a8bdf5}")
|
.append(".tip{margin-top:12px;font-size:12px;color:#8f959e;text-align:center}")
|
.append(".toast{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,.78);color:#fff;")
|
.append("padding:11px 20px;border-radius:8px;font-size:14px;display:none;max-width:80%;text-align:center;z-index:99}")
|
.append("</style></head><body>");
|
|
// 活动信息
|
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)) {
|
sb.append("<div><b>活动时间</b><span>").append(esc(timeText)).append("</span></div>");
|
}
|
if (StrUtil.isNotBlank(activity.getLocation())) {
|
sb.append("<div><b>活动地点</b><span>").append(esc(activity.getLocation())).append("</span></div>");
|
}
|
String signupText = formatTimeRange(activity.getSignupStartTime(), activity.getSignupEndTime());
|
if (StrUtil.isNotBlank(signupText)) {
|
sb.append("<div><b>报名时间</b><span>").append(esc(signupText)).append("</span></div>");
|
}
|
sb.append("<div><b>已报名</b><span>").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("</span></div></div>");
|
|
if (StrUtil.isNotBlank(activity.getContent())) {
|
sb.append("<div class=\"desc\">").append(esc(activity.getContent())).append("</div>");
|
}
|
// 详情图片:紧跟在活动详情文字下方(同一张卡片内),按原图比例全宽展示,
|
// 点一下用浏览器打开原图,方便成员放大看二维码/海报
|
for (String detailImgUrl : resolveDetailImageUrls(activity.getId())) {
|
sb.append("<a class=\"detail-img\" href=\"").append(esc(detailImgUrl))
|
.append("\" target=\"_blank\" rel=\"noopener\">")
|
.append("<img src=\"").append(esc(detailImgUrl)).append("\" alt=\"活动详情图片\">")
|
.append("</a>");
|
}
|
sb.append("</div>");
|
|
// 图片通知模板:展示上传的图片,按原图比例完整展示
|
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<SystemStorageBlobRespVO> images = activityService.getActivityImageAttachments(activity.getId());
|
if (CollUtil.isEmpty(images)) {
|
return;
|
}
|
sb.append("<div class=\"card\">");
|
for (SystemStorageBlobRespVO image : images) {
|
String raw = StrUtil.blankToDefault(image.getPreviewURL(), image.getUrl());
|
if (StrUtil.isBlank(raw)) {
|
continue;
|
}
|
String url = toSameOriginUrl(raw);
|
sb.append("<a class=\"detail-img\" href=\"").append(esc(url))
|
.append("\" target=\"_blank\" rel=\"noopener\">")
|
.append("<img src=\"").append(esc(url)).append("\" alt=\"活动图片\">")
|
.append("</a>");
|
}
|
sb.append("</div>");
|
}
|
|
/**
|
* 附件通知模板的附件列表区
|
*/
|
private void appendFileList(StringBuilder sb, WxCpActivityDO activity) {
|
List<SystemStorageBlobRespVO> files = activityService.getActivityFileAttachments(activity.getId());
|
if (CollUtil.isEmpty(files)) {
|
return;
|
}
|
sb.append("<div class=\"card\"><div class=\"file-title\">附件下载</div>")
|
.append("<div class=\"file-list\">");
|
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("<div><a href=\"").append(esc(url)).append("\" target=\"_blank\" rel=\"noopener\">")
|
.append(esc(name)).append("</a></div>");
|
}
|
sb.append("</div></div>");
|
}
|
|
/**
|
* 数据采集/评价模板的填写表单
|
*/
|
private void appendSignupForm(StringBuilder sb, WxCpActivityDO activity, String wxUserId) {
|
// 报名表单:姓名、手机号均为选填,只填了手机号才校验格式
|
sb.append("<div class=\"card\">")
|
.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>");
|
appendCollectFields(sb, activity);
|
sb.append("<label>图片 / 附件</label>")
|
.append("<div class=\"file-row\">")
|
.append("<label class=\"file-btn\" for=\"files\">+ 选择图片或文件</label>")
|
.append("<input id=\"files\" type=\"file\" multiple accept=\"image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt\" style=\"display:none\">")
|
.append("</div><div class=\"file-list\" id=\"fileList\"></div>")
|
.append("<button id=\"submit\">提交</button>")
|
.append("<div class=\"tip\">提交后如需修改,请联系活动发起人</div>")
|
.append("</div><div class=\"toast\" id=\"toast\"></div>");
|
|
// 提交逻辑:先把文件传到 system 的公共上传接口拿到 blobId,再带上 blobId 提交报名
|
sb.append("<script>(function(){")
|
.append("var AID=").append(activity.getId()).append(";")
|
.append("var WXUID=").append(wxUserId == null ? "null" : "\"" + esc(jsStr(wxUserId)) + "\"").append(";")
|
.append("var FIELDS=").append(buildFieldsJson(activity)).append(";")
|
.append("var RATING_ENABLED=").append(Boolean.TRUE.equals(activity.getRatingEnabled())).append(";")
|
.append("var btn=document.getElementById('submit'),toast=document.getElementById('toast');")
|
.append("var fileInput=document.getElementById('files'),fileList=document.getElementById('fileList');")
|
.append("function tip(m){toast.textContent=m;toast.style.display='block';setTimeout(function(){toast.style.display='none'},2200)}")
|
.append("function reset(){btn.disabled=false;btn.textContent='提交'}")
|
.append("function collectFields(){")
|
.append("var arr=[],bad=null;")
|
.append("for(var i=0;i<FIELDS.length;i++){var f=FIELDS[i],v='';")
|
.append("if(f.type==='checkbox'){var cs=document.querySelectorAll('input[name=\"f_'+f.id+'\"]:checked'),vs=[];")
|
.append("for(var j=0;j<cs.length;j++){vs.push(cs[j].value)}v=vs.join(',')}")
|
.append("else if(f.type==='radio'){var r=document.querySelector('input[name=\"f_'+f.id+'\"]:checked');v=r?r.value:''}")
|
.append("else{var el=document.getElementById('f_'+f.id);v=el?el.value.trim():''}")
|
.append("if(f.required&&!v){bad=f.label;break}")
|
.append("if(v){arr.push({fieldId:f.id,fieldValue:v})}}")
|
.append("if(bad){tip('【'+bad+'】为必填项,请填写后再提交');return null}return arr}")
|
.append("function getRating(){")
|
.append("if(!RATING_ENABLED){return 0}")
|
.append("var r=document.querySelector('input[name=\"rating\"]:checked');")
|
.append("if(!r){tip('请先完成评价打分后再提交');return -1}")
|
.append("return parseInt(r.value,10)}")
|
.append("fileInput.addEventListener('change',function(){")
|
.append("var fs=fileInput.files,h='';")
|
.append("for(var i=0;i<fs.length;i++){h+='<div>· '+fs[i].name+'</div>'}")
|
.append("fileList.innerHTML=h})")
|
.append("btn.addEventListener('click',function(){")
|
.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(m&&!/^1[3-9]\\d{9}$/.test(m)){tip('手机号格式不正确,请填 11 位手机号或留空');return}")
|
.append("var fv=collectFields();if(fv===null){return}")
|
.append("var rs=getRating();if(rs<0){return}")
|
.append("btn.disabled=true;")
|
.append("var fs=fileInput.files;")
|
.append("var upload=Promise.resolve([]);")
|
.append("if(fs.length>0){")
|
.append("btn.textContent='上传附件中...';")
|
.append("var fd=new FormData();")
|
.append("for(var i=0;i<fs.length;i++){fd.append('files',fs[i])}")
|
.append("upload=fetch('/admin-api/system/storage-blob/public-upload',{method:'POST',body:fd})")
|
.append(".then(function(r){return r.json()})")
|
.append(".then(function(r){")
|
.append("if(r.code!==0){throw new Error(r.msg||'附件上传失败')}")
|
.append("return (r.data||[]).map(function(b){return b.id})})}")
|
.append("upload.then(function(blobIds){")
|
.append("btn.textContent='提交中...';")
|
.append("var body={activityId:AID,name:n,mobile:m};")
|
.append("if(c){body.content=c}")
|
.append("if(WXUID){body.wxUserId=WXUID}")
|
.append("if(blobIds.length>0){body.blobIds=blobIds}")
|
.append("if(fv.length>0){body.fieldValues=fv}")
|
.append("if(rs>0){body.ratingScore=rs}")
|
.append("return fetch('signup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})")
|
.append(".then(function(r){return r.json()})")
|
.append(".then(function(r){")
|
.append("if(r.code===0){tip('报名成功');btn.textContent='已报名';}")
|
.append("else{tip(r.msg||'报名失败');reset()}})}")
|
.append(".catch(function(e){tip((e&&e.message)||'网络异常,请稍后重试');reset()})")
|
.append("})})();</script></body></html>");
|
}
|
|
/**
|
* 渲染数据采集模板的自定义字段与评价
|
*/
|
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("<label>").append(label).append(required).append("</label>")
|
.append("<textarea id=\"").append(id).append("\" placeholder=\"")
|
.append(placeholder).append("\" maxlength=\"2000\"></textarea>");
|
case WxCpActivityFieldDO.TYPE_NUMBER ->
|
sb.append("<label>").append(label).append(required).append("</label>")
|
.append("<input id=\"").append(id)
|
.append("\" type=\"number\" placeholder=\"").append(placeholder).append("\">");
|
case WxCpActivityFieldDO.TYPE_DATE ->
|
sb.append("<label>").append(label).append(required).append("</label>")
|
.append("<input id=\"").append(id).append("\" type=\"date\">");
|
case WxCpActivityFieldDO.TYPE_SELECT -> {
|
sb.append("<label>").append(label).append(required).append("</label>")
|
.append("<select id=\"").append(id).append("\"><option value=\"\">请选择</option>");
|
for (String option : splitOptions(field.getFieldOptions())) {
|
sb.append("<option value=\"").append(esc(option)).append("\">")
|
.append(esc(option)).append("</option>");
|
}
|
sb.append("</select>");
|
}
|
case WxCpActivityFieldDO.TYPE_RADIO -> {
|
sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">");
|
for (String option : splitOptions(field.getFieldOptions())) {
|
sb.append("<label class=\"choice\"><input type=\"radio\" name=\"")
|
.append(id).append("\" value=\"").append(esc(option)).append("\">")
|
.append(esc(option)).append("</label>");
|
}
|
sb.append("</div>");
|
}
|
case WxCpActivityFieldDO.TYPE_CHECKBOX -> {
|
sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">");
|
for (String option : splitOptions(field.getFieldOptions())) {
|
sb.append("<label class=\"choice\"><input type=\"checkbox\" name=\"")
|
.append(id).append("\" value=\"").append(esc(option)).append("\">")
|
.append(esc(option)).append("</label>");
|
}
|
sb.append("</div>");
|
}
|
case WxCpActivityFieldDO.TYPE_RATING -> {
|
sb.append("<label>").append(label).append(required).append("</label><div class=\"choice-row\">");
|
int max = activity.getRatingMax() != null && activity.getRatingMax() > 0 ? activity.getRatingMax() : 5;
|
for (int i = 1; i <= max; i++) {
|
sb.append("<label class=\"choice\"><input type=\"radio\" name=\"")
|
.append(id).append("\" value=\"").append(i).append("\">")
|
.append(i).append(" 星</label>");
|
}
|
sb.append("</div>");
|
}
|
default ->
|
sb.append("<label>").append(label).append(required).append("</label>")
|
.append("<input id=\"").append(id).append("\" type=\"text\" placeholder=\"")
|
.append(placeholder).append("\" maxlength=\"255\">");
|
}
|
}
|
// 独立评价项:开启后必须选择星级
|
if (Boolean.TRUE.equals(activity.getRatingEnabled())) {
|
int max = activity.getRatingMax() != null && activity.getRatingMax() > 0
|
? activity.getRatingMax() : 5;
|
sb.append("<label>").append(esc(StrUtil.blankToDefault(activity.getRatingTitle(), "活动评价")))
|
.append(" *</label><div class=\"choice-row\">");
|
for (int i = 1; i <= max; i++) {
|
sb.append("<label class=\"choice\"><input type=\"radio\" name=\"rating\" value=\"")
|
.append(i).append("\">").append(i).append(" 星</label>");
|
}
|
sb.append("</div>");
|
}
|
}
|
|
private List<String> splitOptions(String options) {
|
if (StrUtil.isBlank(options)) {
|
return Collections.emptyList();
|
}
|
return StrUtil.split(options, ',', true, true);
|
}
|
|
/**
|
* 把自定义字段定义转成前端提交脚本可用的 JSON
|
*/
|
private String buildFieldsJson(WxCpActivityDO activity) {
|
List<WxCpActivityFieldDO> 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<String> resolveDetailImageUrls(Long activityId) {
|
List<SystemStorageBlobRespVO> 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<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\">"
|
+ "<title>" + esc(title) + "</title><style>"
|
+ "body{margin:0;padding:60px 24px;font-family:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;"
|
+ "background:#f5f6f8;color:#1f2329;text-align:center}"
|
+ "h1{font-size:18px;font-weight:600;margin:0 0 12px}"
|
+ "p{color:#646a73;font-size:14px;line-height:1.7;margin:0}"
|
+ "</style></head><body><h1>" + esc(title) + "</h1><p>" + esc(desc) + "</p></body></html>";
|
}
|
|
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");
|
}
|
|
}
|