package cn.iocoder.yudao.module.wxcp.controller.admin.notice;
|
|
import cn.hutool.core.collection.CollUtil;
|
import cn.hutool.core.util.StrUtil;
|
import cn.iocoder.yudao.module.system.controller.admin.storage.vo.SystemStorageBlobRespVO;
|
import cn.iocoder.yudao.module.wxcp.dal.dataobject.notice.WxCpNoticeDO;
|
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeStatusEnum;
|
import cn.iocoder.yudao.module.wxcp.enums.notice.WxCpNoticeTypeEnum;
|
import cn.iocoder.yudao.module.wxcp.service.notice.WxCpNoticeService;
|
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 lombok.extern.slf4j.Slf4j;
|
import org.springframework.http.MediaType;
|
import org.springframework.validation.annotation.Validated;
|
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RestController;
|
|
import java.net.URI;
|
import java.util.List;
|
import java.util.Objects;
|
import java.util.regex.Matcher;
|
import java.util.regex.Pattern;
|
|
/**
|
* 企业微信通知 - 成员预览页(免登录)
|
*
|
* 说明:放在 controller.admin 包下从而带上 /admin-api 前缀。
|
* 前端 Vite 开发代理只转发 /admin-api,若放到 /app-api,
|
* 成员在企业微信里打开链接会打到前端静态服务返回 404。
|
*
|
* @author 超级管理员
|
*/
|
@Tag(name = "用户 App - 企业微信通知预览")
|
@RestController
|
@RequestMapping("/wxcp/notice")
|
@Validated
|
@Slf4j
|
public class WxCpNoticePublicController {
|
|
/** 行内代码 */
|
private static final Pattern MD_CODE = Pattern.compile("`([^`]+)`");
|
/** 加粗 */
|
private static final Pattern MD_STRONG = Pattern.compile("\\*\\*(.+?)\\*\\*");
|
/** 链接 [文案](地址) */
|
private static final Pattern MD_LINK = Pattern.compile("\\[([^\\]]+)]\\(([^)]+)\\)");
|
|
@Resource
|
private WxCpNoticeService noticeService;
|
|
@GetMapping(value = "/preview-page", produces = MediaType.TEXT_HTML_VALUE)
|
@PermitAll
|
@Operation(summary = "通知预览页(HTML)", description = "图片/附件通知推送给成员后,成员打开此页查看图片与附件")
|
@Parameter(name = "id", description = "通知编号", required = true)
|
public String previewPage(@RequestParam("id") Long id) {
|
WxCpNoticeDO notice = noticeService.getNotice(id);
|
if (notice == null) {
|
return renderMessagePage("通知不存在", "通知可能已被删除,请联系通知发起人确认。");
|
}
|
if (!WxCpNoticeStatusEnum.PUBLISHED.getStatus().equals(notice.getStatus())) {
|
return renderMessagePage("通知未开放", "该通知尚未发布或已撤回,请联系通知发起人确认。");
|
}
|
return renderPreviewPage(notice);
|
}
|
|
private String renderPreviewPage(WxCpNoticeDO notice) {
|
List<SystemStorageBlobRespVO> images = noticeService.getNoticeImageAttachments(notice.getId());
|
List<SystemStorageBlobRespVO> files = noticeService.getNoticeFileAttachments(notice.getId());
|
List<SystemStorageBlobRespVO> videos = noticeService.getNoticeVideoAttachments(notice.getId());
|
|
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(notice.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("h1{font-size:19px;margin:0 0 14px;font-weight:600;word-break:break-all}")
|
.append(".desc{white-space:pre-wrap;word-break:break-word;color:#3f444d}")
|
.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(".file-title{font-weight:600;margin-bottom:8px}")
|
.append(".file-list div{padding:8px 0;border-bottom:1px solid #f0f1f3;word-break:break-all}")
|
.append(".file-list div:last-child{border-bottom:0}")
|
.append(".file-list a{color:#3370ff;text-decoration:none}")
|
.append(".file-size{color:#8f959e;font-size:12px;margin-left:6px}")
|
.append(".video-wrap video{display:block;width:100%;border-radius:8px;background:#000}")
|
.append(".markdown h1,.markdown h2,.markdown h3{margin:14px 0 8px;font-weight:600;line-height:1.4}")
|
.append(".markdown h1{font-size:19px}.markdown h2{font-size:17px}.markdown h3{font-size:16px}")
|
.append(".markdown p{margin:8px 0}")
|
.append(".markdown ul,.markdown ol{margin:8px 0;padding-left:20px}")
|
.append(".markdown li{margin:4px 0}")
|
.append(".markdown blockquote{margin:10px 0;padding:8px 12px;border-left:3px solid #dee0e3;")
|
.append("background:#f7f8fa;color:#646a73;border-radius:4px}")
|
.append(".markdown code{padding:1px 5px;border-radius:4px;background:#f2f3f5;font-size:13px}")
|
.append(".markdown a{color:#3370ff;text-decoration:none}")
|
.append(".btn{display:block;margin-top:16px;padding:12px;border-radius:8px;background:#3370ff;color:#fff;")
|
.append("font-size:15px;font-weight:500;text-align:center;text-decoration:none}")
|
.append(".tip{margin-top:12px;font-size:12px;color:#8f959e;text-align:center}")
|
.append("</style></head><body>");
|
|
sb.append("<div class=\"card\"><h1>").append(esc(notice.getTitle())).append("</h1>");
|
if (StrUtil.isNotBlank(notice.getContent())) {
|
if (Objects.equals(notice.getNoticeType(), WxCpNoticeTypeEnum.MARKDOWN.getType())) {
|
// Markdown 通知在预览页按富文本展示,渲染前已对用户内容做 HTML 转义
|
sb.append("<div class=\"desc markdown\">").append(renderMarkdown(notice.getContent())).append("</div>");
|
} else {
|
sb.append("<div class=\"desc\">").append(esc(notice.getContent())).append("</div>");
|
}
|
}
|
if (StrUtil.isNotBlank(notice.getLinkUrl())) {
|
String link = StrUtil.blankToDefault(notice.getLinkBtnText(), "查看详情");
|
sb.append("<a class=\"btn\" href=\"").append(esc(notice.getLinkUrl()))
|
.append("\" target=\"_blank\" rel=\"noopener\">").append(esc(link)).append("</a>");
|
}
|
sb.append("</div>");
|
|
// 图片:按原图比例完整展示,点一下用浏览器打开原图,方便放大查看
|
if (CollUtil.isNotEmpty(images)) {
|
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>");
|
}
|
|
// 视频:成员点开即可播放
|
if (CollUtil.isNotEmpty(videos)) {
|
sb.append("<div class=\"card\">");
|
for (SystemStorageBlobRespVO video : videos) {
|
String raw = StrUtil.blankToDefault(video.getPreviewURL(), video.getUrl());
|
if (StrUtil.isBlank(raw)) {
|
continue;
|
}
|
String url = toSameOriginUrl(raw);
|
sb.append("<div class=\"video-wrap\"><video controls preload=\"metadata\" src=\"")
|
.append(esc(url)).append("\"></video></div>");
|
}
|
sb.append("</div>");
|
}
|
|
// 附件:成员点开即可预览/下载
|
if (CollUtil.isNotEmpty(files)) {
|
sb.append("<div class=\"card\"><div class=\"file-title\">附件</div><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>");
|
if (file.getByteSize() != null) {
|
sb.append("<span class=\"file-size\">").append(formatSize(file.getByteSize())).append("</span>");
|
}
|
sb.append("</div>");
|
}
|
sb.append("</div></div>");
|
}
|
|
sb.append("<div class=\"tip\">来自企业微信通知,请在企业微信内查看</div></body></html>");
|
return sb.toString();
|
}
|
|
private String formatSize(Long bytes) {
|
if (bytes == null || bytes <= 0) {
|
return "";
|
}
|
if (bytes < 1024) {
|
return bytes + " B";
|
}
|
if (bytes < 1024 * 1024) {
|
return String.format("%.1f KB", bytes / 1024.0);
|
}
|
return String.format("%.1f MB", bytes / (1024.0 * 1024.0));
|
}
|
|
/**
|
* 把附件地址转成同源相对地址
|
*
|
* 附件里存的是带签名的绝对地址,域名取自 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>";
|
}
|
|
/**
|
* 轻量 Markdown 渲染:只支持企业微信通知常用的标题、加粗、列表、引用、代码、链接、分隔线
|
*
|
* 安全前提:所有用户内容先用 {@link #esc(String)} 转义,之后才拼接标签,
|
* 因此不会执行原始 HTML;链接只允许 http/https/站内路径。
|
*/
|
private String renderMarkdown(String markdown) {
|
String normalized = markdown.replace("\r\n", "\n").replace("\r", "\n");
|
String[] lines = normalized.split("\n", -1);
|
StringBuilder html = new StringBuilder(1024);
|
boolean inList = false;
|
for (String rawLine : lines) {
|
String line = rawLine.trim();
|
if (line.isEmpty()) {
|
if (inList) {
|
html.append("</ul>");
|
inList = false;
|
}
|
continue;
|
}
|
if (line.startsWith("- ") || line.startsWith("* ")) {
|
if (!inList) {
|
html.append("<ul>");
|
inList = true;
|
}
|
html.append("<li>").append(renderInline(line.substring(2))).append("</li>");
|
continue;
|
}
|
if (inList) {
|
html.append("</ul>");
|
inList = false;
|
}
|
if (line.startsWith("### ")) {
|
html.append("<h3>").append(renderInline(line.substring(4))).append("</h3>");
|
} else if (line.startsWith("## ")) {
|
html.append("<h2>").append(renderInline(line.substring(3))).append("</h2>");
|
} else if (line.startsWith("# ")) {
|
html.append("<h1>").append(renderInline(line.substring(2))).append("</h1>");
|
} else if (line.startsWith("> ")) {
|
html.append("<blockquote>").append(renderInline(line.substring(2))).append("</blockquote>");
|
} else if ("---".equals(line) || "***".equals(line)) {
|
html.append("<hr>");
|
} else {
|
html.append("<p>").append(renderInline(line)).append("</p>");
|
}
|
}
|
if (inList) {
|
html.append("</ul>");
|
}
|
return html.toString();
|
}
|
|
/**
|
* 渲染行内 Markdown;输入已转义,输出只包含受控标签
|
*/
|
private String renderInline(String text) {
|
String result = esc(text);
|
result = MD_CODE.matcher(result).replaceAll("<code>$1</code>");
|
result = MD_STRONG.matcher(result).replaceAll("<strong>$1</strong>");
|
Matcher matcher = MD_LINK.matcher(result);
|
StringBuilder sb = new StringBuilder();
|
while (matcher.find()) {
|
String label = matcher.group(1);
|
String url = matcher.group(2).trim();
|
String replacement = isSafeUrl(url)
|
? "<a href=\"" + url + "\" target=\"_blank\" rel=\"noopener\">" + label + "</a>"
|
: label;
|
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
|
}
|
matcher.appendTail(sb);
|
return sb.toString();
|
}
|
|
/**
|
* 只允许 http/https 与站内路径,避免 javascript: 等危险协议
|
*/
|
private boolean isSafeUrl(String url) {
|
String lower = url.trim().toLowerCase();
|
return lower.startsWith("http://") || lower.startsWith("https://") || lower.startsWith("/");
|
}
|
|
/**
|
* HTML 转义,避免通知标题/内容里的特殊字符破坏页面或造成 XSS
|
*/
|
private String esc(String raw) {
|
if (raw == null) {
|
return "";
|
}
|
return raw.replace("&", "&")
|
.replace("<", "<")
|
.replace(">", ">")
|
.replace("\"", """)
|
.replace("'", "'");
|
}
|
|
}
|