Files
YschcmsService/src/main/java/com/yunhai/cms/utils/PortalUrlHelper.java
T
2026-09-07 09:09:39 +08:00

221 lines
9.0 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 域子路径与学校独立域名两种暴露形态共用同一套渲染。
*
* <p>基准 = 站点 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;
}
}
}