feat(W4): 院系子站嵌套路径-cms_site.path列+uk_site_path/parent_code,门户单段+嵌套双映射,旧链301,public_base_url派生,sitePath预览,SQL三处+升级脚本,T-W4-01..15 全绿279/279

This commit is contained in:
chenxing
2026-09-08 12:34:33 +08:00
parent 5da6f7ee5d
commit 536bd6cea4
30 changed files with 593 additions and 55 deletions
@@ -38,9 +38,10 @@ public class ThemePreviewController {
private final PortalPageRenderer pageRenderer;
private final SiteService siteService;
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表) */
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表)。
* W4:内容源站点参数 siteCode→sitePath(嵌套路径语义,siteSnapshotByPath 解析)。 */
@GetMapping("/preview")
public String preview(@RequestParam("siteCode") String siteCode,
public String preview(@RequestParam("sitePath") String sitePath,
@RequestParam("theme") String theme,
@RequestParam(value = "page", required = false, defaultValue = "home") String page,
BoaUser authUserWithDept,
@@ -51,10 +52,10 @@ public class ThemePreviewController {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)");
}
ThemeInfo info = themeService.requireRegistered(theme);
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"内容源站点不存在或已停用:" + siteCode);
"内容源站点不存在或已停用:" + sitePath);
}
// B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable)
if (authUserWithDept == null) {
@@ -66,6 +67,6 @@ public class ThemePreviewController {
Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap());
previewSite.setThemeParams(params);
previewSite.setThemeVarsCss(ThemeVarsCss.build(info, params));
return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, siteCode, theme), model);
return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, sitePath, theme), model);
}
}
@@ -40,15 +40,28 @@ public class PortalAssetController {
public ResponseEntity<Resource> asset(@PathVariable("siteCode") String siteCode,
@PathVariable("theme") String theme,
HttpServletRequest request) {
return doAsset(siteCode, theme, request);
}
/** W4 嵌套变体:/portal/{p}/{s}/assets/{theme}/**(层级固定两层) */
@GetMapping("/portal/{p}/{s}/assets/{theme}/**")
public ResponseEntity<Resource> assetNested(@PathVariable("p") String p,
@PathVariable("s") String s,
@PathVariable("theme") String theme,
HttpServletRequest request) {
return doAsset(p + "/" + s, theme, request);
}
private ResponseEntity<Resource> doAsset(String sitePath, String theme, HttpServletRequest request) {
// 主题段必须已注册(兼防路径注入:未注册名一律 404)
if (!themeRegistry.contains(theme)) {
return ResponseEntity.notFound().build();
}
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site == null) {
return ResponseEntity.notFound().build();
}
String path = extractRelativePath(request, siteCode, theme);
String path = extractRelativePath(request, sitePath, theme);
if (path == null || path.isEmpty() || path.contains("..")) {
return ResponseEntity.badRequest().build();
}
@@ -68,10 +81,11 @@ public class PortalAssetController {
/**
* 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。
* 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。
* W4:前缀用完整 sitePath(嵌套形态含两层段)。
*/
private String extractRelativePath(HttpServletRequest request, String siteCode, String theme) {
private String extractRelativePath(HttpServletRequest request, String sitePath, String theme) {
String uri = request.getRequestURI();
String prefix = request.getContextPath() + "/portal/" + siteCode + "/assets/" + theme + "/";
String prefix = request.getContextPath() + "/portal/" + sitePath + "/assets/" + theme + "/";
int index = uri.indexOf(prefix);
if (index < 0) {
return null;
@@ -8,7 +8,9 @@ import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.ViewCounterService;
import com.yunhai.cms.utils.PortalUrlHelper;
@@ -48,6 +50,7 @@ public class PortalController {
private final PortalQueryService portalQueryService;
private final CmsArticleRepository articleRepository;
private final CmsSiteRepository siteRepository;
private final ViewCounterService viewCounterService;
private final PortalPageRenderer pageRenderer;
@@ -58,11 +61,25 @@ public class PortalController {
@Value("${cms.portal.base-origin:}")
private String baseOrigin;
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5) */
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5)。
* W4:单段/嵌套双映射收敛到 doHome(sitePath 为唯一身份)。 */
@GetMapping("/portal/{siteCode}/")
public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
return doHome(siteCode, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/(层级固定两层) */
@GetMapping("/portal/{p}/{s}/")
public String homeNested(@PathVariable("p") String p, @PathVariable("s") String s,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doHome(p + "/" + s, request, response, model);
}
private String doHome(String sitePath, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "", request, response);
if (site == null) {
return null;
}
@@ -76,7 +93,23 @@ public class PortalController {
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
return doCategory(siteCode, categoryId, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/c/{categoryId} */
@GetMapping("/portal/{p}/{s}/c/{categoryId}")
public String categoryNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("categoryId") Integer categoryId,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doCategory(p + "/" + s, categoryId, page, request, response, model);
}
private String doCategory(String sitePath, Integer categoryId, Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "c/" + categoryId, request, response);
if (site == null) {
return null;
}
@@ -126,7 +159,22 @@ public class PortalController {
@PathVariable("articleId") Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
return doArticle(siteCode, articleId, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/a/{articleId} */
@GetMapping("/portal/{p}/{s}/a/{articleId}")
public String articleNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("articleId") Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doArticle(p + "/" + s, articleId, request, response, model);
}
private String doArticle(String sitePath, Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "a/" + articleId, request, response);
if (site == null) {
return null;
}
@@ -172,7 +220,23 @@ public class PortalController {
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSite(siteCode, response);
return doSearch(siteCode, q, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/search */
@GetMapping("/portal/{p}/{s}/search")
public String searchNested(@PathVariable("p") String p, @PathVariable("s") String s,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doSearch(p + "/" + s, q, page, request, response, model);
}
private String doSearch(String sitePath, String q, Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "search", request, response);
if (site == null) {
return null;
}
@@ -208,30 +272,57 @@ public class PortalController {
// ===== 内部装配 =====
private PortalSiteSnapshot requireSite(String siteCode, HttpServletResponse response) throws Exception {
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode);
if (site == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
/**
* 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
* 命中子站(path≠code)则 301 到嵌套形态(保留子路径 subPath 与 query)后返回 null;
* 仍未命中 404。
*/
private PortalSiteSnapshot requireSiteByPath(String sitePath, String subPath,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site != null) {
return site;
}
return site;
if (sitePath != null && !sitePath.contains("/")) {
CmsSite legacy = siteRepository.findByCode(sitePath).orElse(null);
if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty()
&& !legacy.getPath().equals(sitePath)) {
StringBuilder location = new StringBuilder(request.getContextPath())
.append("/portal/").append(legacy.getPath()).append('/');
if (subPath != null && !subPath.isEmpty()) {
location.append(subPath);
}
String query = request.getQueryString();
if (query != null && !query.isEmpty()) {
location.append('?').append(query);
}
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", location.toString());
return null;
}
}
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
/**
* URL 生成器基准三级决策(方案 §8.6):
* ① 站点 public_base_url(绑学校域名);
* ① 站点 public_base_url(绑学校域名;W4 起子站可为 D4 派生值 {主站base}/{code});
* ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6);
* ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
* theme 随 helper 走,使资产 URL 含主题段(B-3)。
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
if (site.getPublicBaseUrl() != null) {
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getCode(), site.getTheme(), baseOrigin);
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), site.getTheme(), baseOrigin);
}
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getCode(), site.getTheme());
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), site.getTheme());
}
return PortalUrlHelper.fromRequest(request, site.getCode(), site.getTheme());
return PortalUrlHelper.fromRequest(request, site.getPath(), site.getTheme());
}
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
@@ -32,6 +32,6 @@ public class PortalIndexController {
.filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0)
.findFirst()
.orElse(sites.get(0));
response.sendRedirect(request.getContextPath() + "/portal/" + target.getCode() + "/");
response.sendRedirect(request.getContextPath() + "/portal/" + target.getPath() + "/");
}
}