package com.yunhai.cms.utils; import javax.servlet.http.HttpServletRequest; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * 门户 URL 生成器(方案 §8.6 前置约定2):所有链接、主题资产、canonical 统一经此生成, * 禁止模板硬编码绝对地址——base 域子路径与学校独立域名两种暴露形态共用同一套渲染。 * *

基准 = 站点 config_json.public_base_url(绑学校域名后为学校域), * 未配置时从当前请求派生(预览/平台内跳转形态)。 */ public class PortalUrlHelper { /** 正文/单页内容里出现的上传资源地址:可选历史绝对前缀 + 相对路径(B-1 渲染期统一补基准) */ private static final Pattern UPLOAD_URL_IN_CONTENT = Pattern.compile("(?:https?://[^/\\s\"']+)?((?:/cms)?/upload/[^\\s\"']+)"); /** 不带尾斜杠的门户基地址,形如 {scheme}://{host}{context}/portal/{siteCode} 或 https://www.xxx.edu.cn */ private final String baseUrl; private final String siteCode; /** 当前皮肤包名:进入资产 URL(B-3,换肤即换 URL 使浏览器/CDN 缓存天然失效) */ private final String theme; /** * base 域对外基地址(cms.portal.base-origin,含 context-path,如 https://base.yunhaixingtong.com/cms)。 * C-6:站点未配 public_base_url 时的规范派生基准;B-4:siteHome 无段形态退化目标。空 = 未配置。 */ private final String baseOrigin; /** 预览模式资产前缀(非 null 时 asset() 改走管理端代理,方案 §5.5 防资产串包) */ private final String assetProxyPrefix; public PortalUrlHelper(String baseUrl, String siteCode, String theme) { this(baseUrl, siteCode, theme, null, null); } public PortalUrlHelper(String baseUrl, String siteCode, String theme, String baseOrigin) { this(baseUrl, siteCode, theme, baseOrigin, null); } private PortalUrlHelper(String baseUrl, String siteCode, String theme, String baseOrigin, String assetProxyPrefix) { this.baseUrl = trimTail(baseUrl); this.siteCode = siteCode; this.theme = theme; this.baseOrigin = trimTail(baseOrigin); this.assetProxyPrefix = assetProxyPrefix; } /** * 预览模式工厂(方案 §5.5):资产 URL 指向管理端主题资产代理 /api/theme/{theme}/asset/**, * 其余站内链接仍指向真实门户(点击回真实门户是已接受的预览局限)。 */ public static PortalUrlHelper forPreview(HttpServletRequest request, String siteCode, String theme) { PortalUrlHelper base = fromRequest(request, siteCode, theme); // 显式走 5 参构造:第 4 参是 base-origin,代理前缀必须落第 5 参(避免误绑 4 参重载) return new PortalUrlHelper(base.baseUrl, siteCode, theme, null, "/api/theme/" + theme + "/asset/"); } /** * 从当前请求派生门户基地址(public_base_url / base-origin 均未配置时的兜底; * 反代形态经 ForwardedHeaderFilter 修正后的 scheme/host/port 已反映在请求上,B-2)。 */ public static PortalUrlHelper fromRequest(HttpServletRequest request, String siteCode, String theme) { String scheme = request.getScheme(); String host = request.getServerName(); int port = request.getServerPort(); String context = request.getContextPath(); StringBuilder sb = new StringBuilder(scheme).append("://").append(host); boolean defaultPort = "http".equals(scheme) && port == 80 || "https".equals(scheme) && port == 443; if (!defaultPort) { sb.append(':').append(port); } sb.append(context).append("/portal/").append(siteCode); return new PortalUrlHelper(sb.toString(), siteCode, theme); } /** * base 域规范形态工厂(C-6):站点未配 public_base_url 且平台配了 cms.portal.base-origin 时, * 以 base 域规范路径为基准(base-origin + /portal/{siteCode}),不随访问 host 漂移—— * 方案 §8.6「默认派生 https://base.yunhaixingtong.com/cms/portal/{code}」的落地。 */ public static PortalUrlHelper fromBaseOrigin(String baseOrigin, String siteCode, String theme) { return new PortalUrlHelper(trimTail(baseOrigin) + "/portal/" + siteCode, siteCode, theme, baseOrigin); } /** 首页 */ public String home() { return baseUrl + "/"; } /** * 其他站点首页("专业院系"下拉用,《院系子站方案》):目标站配置了 public_base_url 时以它为准; * 否则把当前基准里的站点段替换为目标站点(base 域子路径形态); * 学校域形态(当前基准无 /portal/ 段)时退化为 base 域规范路径(B-4,base-origin 未配置才回 "/")。 */ public String siteHome(String targetCode, String targetPublicBaseUrl) { if (targetPublicBaseUrl != null && !targetPublicBaseUrl.trim().isEmpty()) { return trimTail(targetPublicBaseUrl) + "/"; } int idx = baseUrl.lastIndexOf("/portal/"); if (idx >= 0) { return baseUrl.substring(0, idx + "/portal/".length()) + targetCode + "/"; } if (baseOrigin != null && !baseOrigin.isEmpty()) { return baseOrigin + "/portal/" + targetCode + "/"; } return "/"; } /** 栏目列表/单页(type=3 外链不在此渲染,直接取 linkUrl) */ public String category(Object categoryId) { return baseUrl + "/c/" + categoryId; } /** 文章详情 */ public String article(Object articleId) { return baseUrl + "/a/" + articleId; } /** 站内搜索 */ public String search(String q, Integer page) { String url = baseUrl + "/search?q=" + urlEncode(q == null ? "" : q); return page != null && page > 1 ? url + "&page=" + page : url; } /** * 主题资产(css/图片):常规经 PortalAssetController 按站点皮肤解析;预览模式走管理端代理。 * URL 含主题段(B-3):换肤即换 URL,浏览器/CDN 的 max-age 缓存天然失效。 */ public String asset(String path) { if (assetProxyPrefix != null) { return assetProxyPrefix + trimLead(path); } String themeSeg = theme == null || theme.isEmpty() ? "default" : theme; return baseUrl + "/assets/" + themeSeg + "/" + trimLead(path); } /** * 上传资源地址补基准(B-1):入库的 webUrl 一律为相对路径(FILE_WEB_PATH 相对形态), * 渲染时按当前基准补 origin 成完整 URL——base 域/学校域/任意外部域访问不断图。 * 已是完整 http(s) 地址(外链图片等)原样返回;空值原样返回。 */ public String upload(String storedUrl) { if (storedUrl == null || storedUrl.trim().isEmpty()) { return storedUrl; } String s = storedUrl.trim(); if (s.startsWith("http://") || s.startsWith("https://")) { return s; } return originOf(baseUrl) + (s.startsWith("/") ? s : "/" + s); } /** * 富文本内容后处理(B-1):把正文里历史绝对前缀(http://localhost:8072 等) * 与相对形态的上传地址统一重写为当前基准下的完整 URL。外链图片不受影响。 */ public String rewriteUploadUrls(String html) { if (html == null || html.isEmpty()) { return html; } Matcher matcher = UPLOAD_URL_IN_CONTENT.matcher(html); StringBuffer sb = new StringBuffer(html.length()); while (matcher.find()) { matcher.appendReplacement(sb, Matcher.quoteReplacement(upload(matcher.group(1)))); } matcher.appendTail(sb); return sb.toString(); } public String getSiteCode() { return siteCode; } public String getBaseUrl() { return baseUrl; } /** 从基地址截取 scheme://host[:port](去掉 /portal 段及任何路径),upload() 补基准用 */ private static String originOf(String baseUrl) { if (baseUrl == null) { return ""; } int schemeEnd = baseUrl.indexOf("://"); if (schemeEnd < 0) { return ""; } int pathStart = baseUrl.indexOf('/', schemeEnd + 3); return pathStart < 0 ? baseUrl : baseUrl.substring(0, pathStart); } private static String trimTail(String url) { String s = url == null ? "" : url.trim(); while (s.endsWith("/")) { s = s.substring(0, s.length() - 1); } return s; } private static String trimLead(String path) { String s = path == null ? "" : path; while (s.startsWith("/")) { s = s.substring(1); } return s; } private static String urlEncode(String value) { try { return java.net.URLEncoder.encode(value, "UTF-8"); } catch (java.io.UnsupportedEncodingException e) { return value; } } }