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;
|
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.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 (!isSignupOpen(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 isSignupOpen(WxCpActivityDO activity) {
|
if (!WxCpActivityStatusEnum.PUBLISHED.getStatus().equals(activity.getStatus())) {
|
return false;
|
}
|
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}")
|
.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{border-color:#3370ff}")
|
.append("textarea{resize:vertical;min-height:84px}")
|
.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>");
|
}
|
sb.append("</div>");
|
|
// 报名表单:姓名、手机号均为选填,只填了手机号才校验格式
|
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>")
|
.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 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("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("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("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>");
|
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\">"
|
+ "<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");
|
}
|
|
}
|