221 lines
9.0 KiB
Java
221 lines
9.0 KiB
Java
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;
|
||
}
|
||
}
|
||
}
|