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 images = noticeService.getNoticeImageAttachments(notice.getId()); List files = noticeService.getNoticeFileAttachments(notice.getId()); List videos = noticeService.getNoticeVideoAttachments(notice.getId()); StringBuilder sb = new StringBuilder(2048); sb.append("") .append("") .append("").append(esc(notice.getTitle())).append("") .append(""); sb.append("

").append(esc(notice.getTitle())).append("

"); if (StrUtil.isNotBlank(notice.getContent())) { if (Objects.equals(notice.getNoticeType(), WxCpNoticeTypeEnum.MARKDOWN.getType())) { // Markdown 通知在预览页按富文本展示,渲染前已对用户内容做 HTML 转义 sb.append("
").append(renderMarkdown(notice.getContent())).append("
"); } else { sb.append("
").append(esc(notice.getContent())).append("
"); } } if (StrUtil.isNotBlank(notice.getLinkUrl())) { String link = StrUtil.blankToDefault(notice.getLinkBtnText(), "查看详情"); sb.append("").append(esc(link)).append(""); } sb.append("
"); // 图片:按原图比例完整展示,点一下用浏览器打开原图,方便放大查看 if (CollUtil.isNotEmpty(images)) { sb.append("
"); for (SystemStorageBlobRespVO image : images) { String raw = StrUtil.blankToDefault(image.getPreviewURL(), image.getUrl()); if (StrUtil.isBlank(raw)) { continue; } String url = toSameOriginUrl(raw); sb.append("") .append("\"通知图片\"") .append(""); } sb.append("
"); } // 视频:成员点开即可播放 if (CollUtil.isNotEmpty(videos)) { sb.append("
"); for (SystemStorageBlobRespVO video : videos) { String raw = StrUtil.blankToDefault(video.getPreviewURL(), video.getUrl()); if (StrUtil.isBlank(raw)) { continue; } String url = toSameOriginUrl(raw); sb.append("
"); } sb.append("
"); } // 附件:成员点开即可预览/下载 if (CollUtil.isNotEmpty(files)) { sb.append("
附件
"); 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("
") .append(esc(name)).append(""); if (file.getByteSize() != null) { sb.append("").append(formatSize(file.getByteSize())).append(""); } sb.append("
"); } sb.append("
"); } sb.append("
来自企业微信通知,请在企业微信内查看
"); 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 "" + "" + "" + esc(title) + "

" + esc(title) + "

" + esc(desc) + "

"; } /** * 轻量 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(""); inList = false; } continue; } if (line.startsWith("- ") || line.startsWith("* ")) { if (!inList) { html.append("
    "); inList = true; } html.append("
  • ").append(renderInline(line.substring(2))).append("
  • "); continue; } if (inList) { html.append("
"); inList = false; } if (line.startsWith("### ")) { html.append("

").append(renderInline(line.substring(4))).append("

"); } else if (line.startsWith("## ")) { html.append("

").append(renderInline(line.substring(3))).append("

"); } else if (line.startsWith("# ")) { html.append("

").append(renderInline(line.substring(2))).append("

"); } else if (line.startsWith("> ")) { html.append("
").append(renderInline(line.substring(2))).append("
"); } else if ("---".equals(line) || "***".equals(line)) { html.append("
"); } else { html.append("

").append(renderInline(line)).append("

"); } } if (inList) { html.append(""); } return html.toString(); } /** * 渲染行内 Markdown;输入已转义,输出只包含受控标签 */ private String renderInline(String text) { String result = esc(text); result = MD_CODE.matcher(result).replaceAll("$1"); result = MD_STRONG.matcher(result).replaceAll("$1"); 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) ? "" + label + "" : 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("'", "'"); } }