liu
9 小时以前 5d34bdd3c426bb2ca5f6627424ae67984f745603
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
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("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }
 
    /**
     * JS 字符串字面量转义(在 esc 基础上再处理换行与反斜杠)
     */
    private String jsStr(String raw) {
        return raw.replace("\\", "\\\\")
                .replace("\n", "\\n")
                .replace("\r", "\\r");
    }
 
}