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
+2 -2
View File
@@ -17,8 +17,8 @@
-- ============================================================ -- ============================================================
-- 1. 站点(config_json 五键与 day3-seed.cjs 一致;logo 相对路径) -- 1. 站点(config_json 五键与 day3-seed.cjs 一致;logo 相对路径)
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES INSERT INTO cms_site (id, code, path, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
(1, 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default', (1, 'demo', 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default',
'{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "地址:云海市学府路 1 号 · 电话:010-88888888", "copyright": "云海大学 版权所有 © 2026(演示数据)", "beian": "京ICP备00000000号-1 · 京公网安备110000000000号", "seo_description": "云海大学门户网站演示站点(day3 验收演示数据)"}', '{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "地址:云海市学府路 1 号 · 电话:010-88888888", "copyright": "云海大学 版权所有 © 2026(演示数据)", "beian": "京ICP备00000000号-1 · 京公网安备110000000000号", "seo_description": "云海大学门户网站演示站点(day3 验收演示数据)"}',
1, 'cmssuper', NOW()); 1, 'cmssuper', NOW());
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================
-- W4 升级脚本:cms_site 嵌套 path(《功能修订方案.md》§8 / 附录 A,幂等)
-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本
-- 顺序:加列 → 回填(先顶层后子站,两层固定一轮)→ 约束切换 → 校验
-- ============================================================
-- 1) 加列(存量默认空串,随后回填;已加列则跳过)
ALTER TABLE cms_site ADD COLUMN path VARCHAR(191) NOT NULL DEFAULT '' COMMENT '门户路径(顶层=code;子站=父path/code),URL 唯一标识';
-- 2) 回填:先顶层后子站(幂等:仅处理 path='')
UPDATE cms_site SET path = code WHERE (parent_site_id = 0 OR parent_site_id IS NULL) AND path = '';
UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
SET s.path = CONCAT(p.path, '/', s.code)
WHERE s.parent_site_id > 0 AND s.path = '';
-- 3) 约束切换(存量数据天然满足:code 曾全局唯一 ⇒ path 唯一)
ALTER TABLE cms_site DROP INDEX uk_site_code;
ALTER TABLE cms_site ADD UNIQUE KEY uk_site_path (path);
ALTER TABLE cms_site ADD UNIQUE KEY uk_site_parent_code (parent_site_id, code);
-- 4) 校验(应返回 0 行)
SELECT id, code, path FROM cms_site WHERE path = '';
-- ============================================================
-- 附:R1 预留模板——学校主站编码改学校 ID(1000xxx)级联重算子站 path
-- (切换生成规则后执行;把 {OLD_PREFIX} 替换为旧前缀、按新编码规则回填顶层)
-- UPDATE cms_site SET path = CONCAT('1000', sch_id) WHERE parent_site_id = 0 AND code LIKE '{OLD_PREFIX}%';
-- UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
-- SET s.path = CONCAT(p.path, '/', s.code)
-- WHERE s.parent_site_id > 0;
-- ============================================================
@@ -38,9 +38,10 @@ public class ThemePreviewController {
private final PortalPageRenderer pageRenderer; private final PortalPageRenderer pageRenderer;
private final SiteService siteService; private final SiteService siteService;
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表) */ /** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表)。
* W4:内容源站点参数 siteCode→sitePath(嵌套路径语义,siteSnapshotByPath 解析)。 */
@GetMapping("/preview") @GetMapping("/preview")
public String preview(@RequestParam("siteCode") String siteCode, public String preview(@RequestParam("sitePath") String sitePath,
@RequestParam("theme") String theme, @RequestParam("theme") String theme,
@RequestParam(value = "page", required = false, defaultValue = "home") String page, @RequestParam(value = "page", required = false, defaultValue = "home") String page,
BoaUser authUserWithDept, BoaUser authUserWithDept,
@@ -51,10 +52,10 @@ public class ThemePreviewController {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)"); throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)");
} }
ThemeInfo info = themeService.requireRegistered(theme); ThemeInfo info = themeService.requireRegistered(theme);
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode); PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site == null) { if (site == null) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"内容源站点不存在或已停用:" + siteCode); "内容源站点不存在或已停用:" + sitePath);
} }
// B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable) // B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable)
if (authUserWithDept == null) { if (authUserWithDept == null) {
@@ -66,6 +67,6 @@ public class ThemePreviewController {
Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap()); Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap());
previewSite.setThemeParams(params); previewSite.setThemeParams(params);
previewSite.setThemeVarsCss(ThemeVarsCss.build(info, 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, public ResponseEntity<Resource> asset(@PathVariable("siteCode") String siteCode,
@PathVariable("theme") String theme, @PathVariable("theme") String theme,
HttpServletRequest request) { 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) // 主题段必须已注册(兼防路径注入:未注册名一律 404)
if (!themeRegistry.contains(theme)) { if (!themeRegistry.contains(theme)) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode); PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site == null) { if (site == null) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
String path = extractRelativePath(request, siteCode, theme); String path = extractRelativePath(request, sitePath, theme);
if (path == null || path.isEmpty() || path.contains("..")) { if (path == null || path.isEmpty() || path.contains("..")) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -68,10 +81,11 @@ public class PortalAssetController {
/** /**
* 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。 * 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。
* 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。 * 直接解析 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 uri = request.getRequestURI();
String prefix = request.getContextPath() + "/portal/" + siteCode + "/assets/" + theme + "/"; String prefix = request.getContextPath() + "/portal/" + sitePath + "/assets/" + theme + "/";
int index = uri.indexOf(prefix); int index = uri.indexOf(prefix);
if (index < 0) { if (index < 0) {
return null; 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.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData; import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSiteSnapshot; import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsArticleRepository; 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.PortalQueryService;
import com.yunhai.cms.service.impl.ViewCounterService; import com.yunhai.cms.service.impl.ViewCounterService;
import com.yunhai.cms.utils.PortalUrlHelper; import com.yunhai.cms.utils.PortalUrlHelper;
@@ -48,6 +50,7 @@ public class PortalController {
private final PortalQueryService portalQueryService; private final PortalQueryService portalQueryService;
private final CmsArticleRepository articleRepository; private final CmsArticleRepository articleRepository;
private final CmsSiteRepository siteRepository;
private final ViewCounterService viewCounterService; private final ViewCounterService viewCounterService;
private final PortalPageRenderer pageRenderer; private final PortalPageRenderer pageRenderer;
@@ -58,11 +61,25 @@ public class PortalController {
@Value("${cms.portal.base-origin:}") @Value("${cms.portal.base-origin:}")
private String baseOrigin; private String baseOrigin;
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5) */ /** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5)。
* W4:单段/嵌套双映射收敛到 doHome(sitePath 为唯一身份)。 */
@GetMapping("/portal/{siteCode}/") @GetMapping("/portal/{siteCode}/")
public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request, public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception { 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) { if (site == null) {
return null; return null;
} }
@@ -76,7 +93,23 @@ public class PortalController {
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page, @RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response, HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception { 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) { if (site == null) {
return null; return null;
} }
@@ -126,7 +159,22 @@ public class PortalController {
@PathVariable("articleId") Integer articleId, @PathVariable("articleId") Integer articleId,
HttpServletRequest request, HttpServletResponse response, HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception { 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) { if (site == null) {
return null; return null;
} }
@@ -172,7 +220,23 @@ public class PortalController {
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page, @RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response, HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception { 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) { if (site == null) {
return null; return null;
} }
@@ -208,30 +272,57 @@ public class PortalController {
// ===== 内部装配 ===== // ===== 内部装配 =====
private PortalSiteSnapshot requireSite(String siteCode, HttpServletResponse response) throws Exception { /**
PortalSiteSnapshot site = portalQueryService.siteSnapshot(siteCode); * 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
if (site == null) { * 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
response.sendError(HttpServletResponse.SC_NOT_FOUND); * 命中子站(path≠code)则 301 到嵌套形态(保留子路径 subPath 与 query)后返回 null;
return 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): * URL 生成器基准三级决策(方案 §8.6):
* ① 站点 public_base_url(绑学校域名); * ① 站点 public_base_url(绑学校域名;W4 起子站可为 D4 派生值 {主站base}/{code});
* ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6); * ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6);
* ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。 * ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
* theme 随 helper 走,使资产 URL 含主题段(B-3)。 * theme 随 helper 走,使资产 URL 含主题段(B-3)。
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
*/ */
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) { private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
if (site.getPublicBaseUrl() != null) { 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()) { 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) */ /** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
@@ -32,6 +32,6 @@ public class PortalIndexController {
.filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0) .filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0)
.findFirst() .findFirst()
.orElse(sites.get(0)); .orElse(sites.get(0));
response.sendRedirect(request.getContextPath() + "/portal/" + target.getCode() + "/"); response.sendRedirect(request.getContextPath() + "/portal/" + target.getPath() + "/");
} }
} }
@@ -24,14 +24,24 @@ import javax.persistence.UniqueConstraint;
@Setter @Setter
@Entity @Entity
@Table(name = "cms_site", @Table(name = "cms_site",
uniqueConstraints = @UniqueConstraint(name = "uk_site_code", columnNames = "code"), uniqueConstraints = {
@UniqueConstraint(name = "uk_site_path", columnNames = "path"),
@UniqueConstraint(name = "uk_site_parent_code", columnNames = {"parent_site_id", "code"})
},
indexes = @Index(name = "idx_site_sch", columnList = "sch_id")) indexes = @Index(name = "idx_site_sch", columnList = "sch_id"))
public class CmsSite extends BaseEntity { public class CmsSite extends BaseEntity {
/** 站点编码,URL 路径用(/portal/{code}) */ /** 站点编码(URL 末段;W4 起不再全局唯一——不同学校可重用同一编码) */
@Column(name = "code", nullable = false, length = 64) @Column(name = "code", nullable = false, length = 64)
private String code; private String code;
/**
* 门户路径(W4 嵌套路径,方案 §8.4/D2):顶层=code;子站=父path+'/'+code。
* 门户 URL 唯一标识(/portal/{path}/**),全站唯一。
*/
@Column(name = "path", nullable = false, length = 191)
private String path = "";
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层) */ /** 上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层) */
@Column(name = "parent_site_id", nullable = false) @Column(name = "parent_site_id", nullable = false)
private Integer parentSiteId = 0; private Integer parentSiteId = 0;
@@ -18,6 +18,9 @@ public class PortalSiteBrief {
private String code; private String code;
/** 门户路径(W4:嵌套 path,下拉链接用) */
private String path;
private String name; private String name;
/** 站点 logo(config_json.logo,下拉项可选展示) */ /** 站点 logo(config_json.logo,下拉项可选展示) */
@@ -30,6 +33,7 @@ public class PortalSiteBrief {
PortalSiteBrief brief = new PortalSiteBrief(); PortalSiteBrief brief = new PortalSiteBrief();
brief.setId(site.getId()); brief.setId(site.getId());
brief.setCode(site.getCode()); brief.setCode(site.getCode());
brief.setPath(site.getPath());
brief.setName(site.getName()); brief.setName(site.getName());
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson()); Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
brief.setLogoUrl(ExtJsonUtils.getString(config, "logo")); brief.setLogoUrl(ExtJsonUtils.getString(config, "logo"));
@@ -20,6 +20,9 @@ public class PortalSiteSnapshot {
private String code; private String code;
/** 门户路径(W4:顶层=code;子站=父path/code) */
private String path;
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */ /** 上级站点ID(0=顶层校站点,《院系子站方案》) */
private Integer parentSiteId; private Integer parentSiteId;
@@ -64,6 +67,7 @@ public class PortalSiteSnapshot {
PortalSiteSnapshot snapshot = new PortalSiteSnapshot(); PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
snapshot.setId(site.getId()); snapshot.setId(site.getId());
snapshot.setCode(site.getCode()); snapshot.setCode(site.getCode());
snapshot.setPath(site.getPath());
snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId()); snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
snapshot.setName(site.getName()); snapshot.setName(site.getName());
snapshot.setSchName(site.getSchName()); snapshot.setSchName(site.getSchName());
@@ -85,6 +89,7 @@ public class PortalSiteSnapshot {
PortalSiteSnapshot copy = new PortalSiteSnapshot(); PortalSiteSnapshot copy = new PortalSiteSnapshot();
copy.setId(id); copy.setId(id);
copy.setCode(code); copy.setCode(code);
copy.setPath(path);
copy.setParentSiteId(parentSiteId); copy.setParentSiteId(parentSiteId);
copy.setName(name); copy.setName(name);
copy.setSchName(schName); copy.setSchName(schName);
@@ -20,6 +20,8 @@ public class SchoolVo {
private Integer mainSiteId; private Integer mainSiteId;
/** 主站编码(学校上下文 URL 段 /sch/{code}) */ /** 主站编码(学校上下文 URL 段 /sch/{code}) */
private String mainSiteCode; private String mainSiteCode;
/** 主站门户路径(W4:/portal/{mainSitePath}/,顶层=code) */
private String mainSitePath;
private String mainSiteName; private String mainSiteName;
private String theme; private String theme;
@@ -12,6 +12,9 @@ public class SiteVo {
private String code; private String code;
/** 门户路径(W4:顶层=code;子站=父path/code,URL 唯一标识) */
private String path;
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */ /** 上级站点ID(0=顶层校站点,《院系子站方案》) */
private Integer parentSiteId; private Integer parentSiteId;
@@ -38,6 +41,7 @@ public class SiteVo {
SiteVo vo = new SiteVo(); SiteVo vo = new SiteVo();
vo.setId(site.getId()); vo.setId(site.getId());
vo.setCode(site.getCode()); vo.setCode(site.getCode());
vo.setPath(site.getPath());
vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId()); vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
vo.setSchId(site.getSchId()); vo.setSchId(site.getSchId());
vo.setSchName(site.getSchName()); vo.setSchName(site.getSchName());
@@ -10,6 +10,17 @@ public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
Optional<CmsSite> findByCode(String code); Optional<CmsSite> findByCode(String code);
/** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */
Optional<CmsSite> findByPath(String path);
/** 路径冲突检查(新建/改挂重算用;编辑排除自身) */
boolean existsByPath(String path);
boolean existsByPathAndIdNot(String path, Integer id);
/** 皮肤包被用站点数(W1 删除保护共用) */
long countByTheme(String theme);
/** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */ /** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */
List<CmsSite> findByStatusOrderByIdAsc(Integer status); List<CmsSite> findByStatusOrderByIdAsc(Integer status);
@@ -19,6 +19,7 @@ import com.yunhai.cms.repository.CmsBannerRepository;
import com.yunhai.cms.repository.CmsCategoryRepository; import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsLinkRepository; import com.yunhai.cms.repository.CmsLinkRepository;
import com.yunhai.cms.repository.CmsSiteRepository; import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.utils.ExtJsonUtils;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
@@ -54,11 +55,28 @@ public class PortalQueryService {
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode") @Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
public PortalSiteSnapshot siteSnapshot(String siteCode) { public PortalSiteSnapshot siteSnapshot(String siteCode) {
CmsSite site = siteRepository.findByCode(siteCode).orElse(null); CmsSite site = siteRepository.findByCode(siteCode).orElse(null);
return assembleSnapshot(site);
}
/**
* 站点快照(W4:按门户 path 解析,/portal/{path}/** 身份唯一入口)。
* 复用 cms:site 缓存命名空间(key=path);失效由既有 SiteContentChangedEvent 整缓存 clear
* 覆盖(PortalCacheEvictListener),T-W4-14。
*/
@Cacheable(cacheNames = CacheNames.SITE, key = "#path")
public PortalSiteSnapshot siteSnapshotByPath(String path) {
CmsSite site = siteRepository.findByPath(path).orElse(null);
return assembleSnapshot(site);
}
/** 快照装配(siteSnapshot/siteSnapshotByPath 共用;站点不存在/停用返回 null 缓存哨兵) */
private PortalSiteSnapshot assembleSnapshot(CmsSite site) {
if (site == null || site.getStatus() == null || site.getStatus() != 1) { if (site == null || site.getStatus() == null || site.getStatus() != 1) {
return null; return null;
} }
PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site); PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site);
snapshot.setTheme(resolveTheme(snapshot.getTheme())); snapshot.setTheme(resolveTheme(snapshot.getTheme()));
derivePublicBaseUrl(site, snapshot);
// 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致 // 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致
com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme()); com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme());
java.util.Map<String, String> themeParams = java.util.Map<String, String> themeParams =
@@ -234,6 +252,32 @@ public class PortalQueryService {
}); });
} }
/**
* D4(W4):public_base_url 继承派生——子站未配置且主站配置了 public_base_url 时,
* 派生为 {主站public_base_url}/{本站code};子站自配优先(T-W4-15)。
* 支撑"学校独立域名按 /portal/{schoolPath}/** 前缀整段代理"形态:子站链接自动正确。
*/
private void derivePublicBaseUrl(CmsSite site, PortalSiteSnapshot snapshot) {
if (snapshot.getPublicBaseUrl() != null && !snapshot.getPublicBaseUrl().trim().isEmpty()) {
return;
}
if (snapshot.getParentSiteId() == null || snapshot.getParentSiteId() <= 0) {
return;
}
CmsSite parent = siteRepository.findById(snapshot.getParentSiteId()).orElse(null);
if (parent == null) {
return;
}
String parentBase = ExtJsonUtils.getString(ExtJsonUtils.parse(parent.getConfigJson()), "public_base_url");
if (parentBase != null && !parentBase.trim().isEmpty()) {
String base = parentBase.trim();
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
snapshot.setPublicBaseUrl(base + "/" + site.getCode());
}
}
/** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */ /** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */
private String resolveTheme(String theme) { private String resolveTheme(String theme) {
if (themeRegistry.contains(theme)) { if (themeRegistry.contains(theme)) {
@@ -49,6 +49,7 @@ public class SchoolService {
vo.setSchName(top.getSchName()); vo.setSchName(top.getSchName());
vo.setMainSiteId(top.getId()); vo.setMainSiteId(top.getId());
vo.setMainSiteCode(top.getCode()); vo.setMainSiteCode(top.getCode());
vo.setMainSitePath(top.getPath());
vo.setMainSiteName(top.getName()); vo.setMainSiteName(top.getName());
vo.setTheme(top.getTheme()); vo.setTheme(top.getTheme());
vo.setStatus(top.getStatus()); vo.setStatus(top.getStatus());
@@ -120,22 +120,34 @@ public class SiteService {
return ExtJsonUtils.getString(config, key); return ExtJsonUtils.getString(config, key);
} }
/** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主) */ /** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主)
* W4 嵌套路径(方案 §8.4 步骤 3):validateParent 后计算 path(顶层=code;子站=parent.path+'/'+code),
* existsByPath 冲突拒绝(同父同码冲突=D2 uk_site_path 兜底);编辑分支 code 不可变(T-W4-05);改挂自然重算(T-W4-06)。 */
@Transactional @Transactional
public SiteVo save(BoaUser user, SiteSaveRequest req) { public SiteVo save(BoaUser user, SiteSaveRequest req) {
requireSiteSavePermission(user, req); requireSiteSavePermission(user, req);
CmsSite site; CmsSite site;
if (req.getId() == null) { if (req.getId() == null) {
if (siteRepository.findByCode(req.getCode()).isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在");
}
site = new CmsSite(); site = new CmsSite();
site.setCode(req.getCode()); site.setCode(req.getCode());
} else { } else {
site = siteRepository.findById(req.getId()) site = siteRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在")); .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
// W4:编码=URL 末段,创建后不可修改(对齐前端 SiteManage 编辑弹窗 code disabled)
if (!site.getCode().equals(req.getCode())) {
throw new BizException(ErrorCode.PARAM_ERROR, "编码创建后不可修改");
}
} }
validateParent(req, site); validateParent(req, site);
// W4:path 计算(唯一入口,SitePathBuilder 语义收敛于此)与冲突拒绝
String path = buildPath(site.getParentSiteId(), site.getCode());
boolean pathConflict = site.getId() == null
? siteRepository.existsByPath(path)
: siteRepository.existsByPathAndIdNot(path, site.getId());
if (pathConflict) {
throw new BizException(ErrorCode.PARAM_ERROR, "路径冲突:" + path + "(同上级下编码重复或地址已被占用)");
}
site.setPath(path);
// B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配, // B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配,
// 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响) // 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响)
if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) { if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) {
@@ -160,6 +172,19 @@ public class SiteService {
return SiteVo.of(site); return SiteVo.of(site);
} }
/**
* W4 门户 path 计算(唯一入口,前后端各一份纯函数语义:front/src/utils/sitePath.ts 同构):
* 顶层(parentSiteId≤0)=code;子站=父站 path + '/' + code。
*/
private String buildPath(Integer parentSiteId, String code) {
if (parentSiteId == null || parentSiteId <= 0) {
return code;
}
CmsSite parent = siteRepository.findById(parentSiteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在"));
return parent.getPath() + "/" + code;
}
/** /**
* 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化): * 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项—— * 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
+4 -2
View File
@@ -8,7 +8,8 @@
-- 1. 站点表(多租户根;一校一主站 + N 院系子站,见《院系子站方案.md》) -- 1. 站点表(多租户根;一校一主站 + N 院系子站,见《院系子站方案.md》)
CREATE TABLE IF NOT EXISTS `cms_site` ( CREATE TABLE IF NOT EXISTS `cms_site` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(64) NOT NULL COMMENT '站点编码,URL 路径用(/portal/{code})', `code` VARCHAR(64) NOT NULL COMMENT '站点编码(URL 末段;W4 起不再全局唯一)',
`path` VARCHAR(191) NOT NULL DEFAULT '' COMMENT '门户路径(顶层=code;子站=父path/code),URL 唯一标识',
`parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层)', `parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层)',
`sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)', `sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)',
`sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)', `sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)',
@@ -21,7 +22,8 @@ CREATE TABLE IF NOT EXISTS `cms_site` (
`update_by` VARCHAR(64) NULL, `update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL, `update_time` DATETIME NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_site_code` (`code`), UNIQUE KEY `uk_site_path` (`path`),
UNIQUE KEY `uk_site_parent_code` (`parent_site_id`, `code`),
KEY `idx_site_sch` (`sch_id`), KEY `idx_site_sch` (`sch_id`),
KEY `idx_site_parent` (`parent_site_id`) KEY `idx_site_parent` (`parent_site_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点(一校一主站 + N 院系子站)'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点(一校一主站 + N 院系子站)';
@@ -20,8 +20,8 @@
<i class="colleges-arrow">▾</i> <i class="colleges-arrow">▾</i>
</span> </span>
<ul class="colleges-menu"> <ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'"> <li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}" <a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a> th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li> </li>
</ul> </ul>
@@ -21,8 +21,8 @@
<i class="colleges-arrow">▾</i> <i class="colleges-arrow">▾</i>
</span> </span>
<ul class="colleges-menu"> <ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'"> <li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}" <a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a> th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li> </li>
</ul> </ul>
@@ -17,8 +17,8 @@
<i class="colleges-arrow" aria-hidden="true">▾</i> <i class="colleges-arrow" aria-hidden="true">▾</i>
</span> </span>
<ul class="colleges-menu"> <ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'"> <li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}" <a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a> th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li> </li>
</ul> </ul>
@@ -17,8 +17,8 @@
<i class="sp-colleges-arrow" aria-hidden="true">▾</i> <i class="sp-colleges-arrow" aria-hidden="true">▾</i>
</span> </span>
<ul class="sp-colleges-menu"> <ul class="sp-colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'"> <li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}" <a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a> th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li> </li>
</ul> </ul>
@@ -20,8 +20,8 @@
<i class="colleges-arrow">▾</i> <i class="colleges-arrow">▾</i>
</span> </span>
<ul class="colleges-menu"> <ul class="colleges-menu">
<li th:each="col : ${site.colleges}" th:classappend="${col.code == site.code} ? 'active'"> <li th:each="col : ${site.colleges}" th:classappend="${col.path == site.path} ? 'active'">
<a th:href="${url.siteHome(col.code, col.publicBaseUrl)}" <a th:href="${url.siteHome(col.path, col.publicBaseUrl)}"
th:title="${col.name}" th:text="${col.name}">学院名称</a> th:title="${col.name}" th:text="${col.name}">学院名称</a>
</li> </li>
</ul> </ul>
@@ -99,4 +99,25 @@ class PortalCollegeNavIT extends CmsIntegrationTestBase {
mockMvc.perform(get("/portal")).andExpect(status().is3xxRedirection()) mockMvc.perform(get("/portal")).andExpect(status().is3xxRedirection())
.andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/portal/pmain/"))); .andExpect(header().string("Location", org.hamcrest.Matchers.containsString("/portal/pmain/")));
} }
@Test
@DisplayName("T-W4-13: 下拉嵌套链接——主站首页下拉 href 含嵌套子站路径;子站页下拉含兄弟嵌套路径")
void dropdownLinksUseNestedPath() throws Exception {
// 干净种子:主站 navmain + 两个子站(persistSite 4 参自动按父 path 拼接)
purgeTables();
Integer navMainId = persistSite("navmain", 1, "云海大学门户").getId();
persistSite("navjdxy", 1, "机电学院", navMainId);
persistSite("navxxgc", 1, "信息工程学院", navMainId);
// 主站首页:下拉含嵌套子站 href
String mainHtml = body(mockMvc.perform(get("/portal/navmain/")).andExpect(status().isOk()).andReturn());
assertThat(mainHtml).contains("/portal/navmain/navjdxy/")
.contains("/portal/navmain/navxxgc/");
// 子站页:下拉含兄弟站嵌套 href,且当前站高亮
String subHtml = body(mockMvc.perform(get("/portal/navmain/navjdxy/"))
.andExpect(status().isOk()).andReturn());
assertThat(subHtml).contains("/portal/navmain/navxxgc/");
assertThat(subHtml).containsPattern("class=\"active\"[^>]*>\\s*<a[^>]*>机电学院");
}
} }
@@ -448,4 +448,79 @@ class PortalSsrIT extends CmsIntegrationTestBase {
assertThat(html).contains("<span>教育部</span>"); assertThat(html).contains("<span>教育部</span>");
assertThat(html).doesNotContain("link-logo"); assertThat(html).doesNotContain("link-logo");
} }
// ===== W4 嵌套路径(方案 §8.3 T-W4-07..10)=====
/** 种子:主站 demo1 下建子站 jdxy(path=demo1/jdxy)+ 子站栏目与文章 */
private Integer seedNestedSubSite() {
Integer subId = persistSite("jdxy", 1, "机电学院", siteId).getId();
Integer subCatId = persistCategory(subId, 0, "学院新闻", CategoryTypeEnum.LIST).getId();
persistArticle(subId, subCatId, "机电学院开学典礼", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(60));
return subId;
}
@Test
@DisplayName("T-W4-07: 嵌套首页/栏目/详情/搜索全 200 且站点身份正确")
void nestedRoutes_homeCategoryArticleSearch() throws Exception {
seedNestedSubSite();
Integer subId = siteRepository.findByCode("jdxy").orElseThrow(IllegalStateException::new).getId();
Integer subCatId = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(subId).get(0).getId();
com.yunhai.cms.entity.CmsArticle subArticle =
articleRepository.findAll().stream()
.filter(a -> "机电学院开学典礼".equals(a.getTitle())).findFirst().orElseThrow(IllegalStateException::new);
// 首页
String home = body(mockMvc.perform(get("/portal/demo1/jdxy/")).andExpect(status().isOk()).andReturn());
assertThat(home).contains("机电学院");
// 栏目
String category = body(mockMvc.perform(get("/portal/demo1/jdxy/c/" + subCatId))
.andExpect(status().isOk()).andReturn());
assertThat(category).contains("学院新闻");
// 详情
String detail = body(mockMvc.perform(get("/portal/demo1/jdxy/a/" + subArticle.getId()))
.andExpect(status().isOk()).andReturn());
assertThat(detail).contains("机电学院开学典礼");
// 搜索
String search = body(mockMvc.perform(get("/portal/demo1/jdxy/search").param("q", "开学典礼"))
.andExpect(status().isOk()).andReturn());
assertThat(search).contains("机电学院开学典礼");
}
@Test
@DisplayName("T-W4-08: 嵌套资产路由 200")
void nestedAssets_served() throws Exception {
seedNestedSubSite();
mockMvc.perform(get("/portal/demo1/jdxy/assets/default/css/main.css"))
.andExpect(status().isOk());
}
@Test
@DisplayName("T-W4-09: 旧扁平 URL 301 到嵌套形态(保留子路径与 query)")
void legacyFlatUrl301RedirectsToNested() throws Exception {
seedNestedSubSite();
// 首页旧链
mockMvc.perform(get("/portal/jdxy/"))
.andExpect(status().isMovedPermanently())
.andExpect(header().string("Location", "/portal/demo1/jdxy/"));
// 栏目旧链带子路径
Integer subId = siteRepository.findByCode("jdxy").orElseThrow(IllegalStateException::new).getId();
Integer subCatId = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(subId).get(0).getId();
mockMvc.perform(get("/portal/jdxy/c/" + subCatId))
.andExpect(status().isMovedPermanently())
.andExpect(header().string("Location", "/portal/demo1/jdxy/c/" + subCatId));
// query 保留(MockMvc 走 URL 携带 query 才反映到 queryString)
mockMvc.perform(get("/portal/jdxy/search?q=开学"))
.andExpect(status().isMovedPermanently())
.andExpect(header().string("Location", org.hamcrest.Matchers.allOf(
org.hamcrest.Matchers.containsString("/portal/demo1/jdxy/search"),
org.hamcrest.Matchers.containsString("q=%E5%BC%80%E5%AD%A6")))); // query 原样透传(servlet 层 URL 编码)
}
@Test
@DisplayName("T-W4-10: 顶层站不回归(200),不存在路径 404")
void topLevelNoRegressionAndUnknown404() throws Exception {
mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk());
mockMvc.perform(get("/portal/no-such-site/")).andExpect(status().isNotFound());
mockMvc.perform(get("/portal/demo1/no-such-sub/")).andExpect(status().isNotFound());
}
} }
@@ -39,6 +39,7 @@ class ThemeRenderIT extends CmsIntegrationTestBase {
private void persistSiteWithConfig(String code, String configJson) { private void persistSiteWithConfig(String code, String configJson) {
CmsSite site = new CmsSite(); CmsSite site = new CmsSite();
site.setCode(code); site.setCode(code);
site.setPath(code); // W4:顶层 path=code(与 persistSite 同口径)
site.setSchId(1); site.setSchId(1);
site.setSchName("学校-1"); site.setSchName("学校-1");
site.setName("注入测试门户"); site.setName("注入测试门户");
@@ -257,4 +257,78 @@ class PortalQueryServiceCacheIT extends CmsIntegrationTestBase {
assertThat(portalQueryService.search(siteId, "%", 1, 10).getTotalElements()).isZero(); assertThat(portalQueryService.search(siteId, "%", 1, 10).getTotalElements()).isZero();
assertThat(portalQueryService.search(siteId, "_", 1, 10).getTotalElements()).isZero(); assertThat(portalQueryService.search(siteId, "_", 1, 10).getTotalElements()).isZero();
} }
// ===== W4 嵌套路径(方案 §8.3 T-W4-14/15)=====
@Test
@DisplayName("T-W4-14: siteSnapshotByPath 命中且与 byCode 快照同体;改挂后事件失效覆盖 path 键")
void siteSnapshotByPath_hitsAndEvictsOnReparent() {
Integer mainId = persistSite("sch-3", 1, "第一中学").getId();
Integer subId = persistSite("jdxy", 1, "机电学院", mainId).getId();
PortalSiteSnapshot byPath = portalQueryService.siteSnapshotByPath("sch-3/jdxy");
assertThat(byPath).isNotNull();
assertThat(byPath.getId()).isEqualTo(subId);
assertThat(byPath.getPath()).isEqualTo("sch-3/jdxy");
// 与 byCode 快照同体语义(同一站点装配)
PortalSiteSnapshot byCode = portalQueryService.siteSnapshot("jdxy");
assertThat(byCode.getId()).isEqualTo(byPath.getId());
assertThat(byCode.getPath()).isEqualTo("sch-3/jdxy");
// path 键缓存命中:直改库不失效
siteRepository.findById(subId).ifPresent(s -> {
s.setName("机电学院改名");
siteRepository.saveAndFlush(s);
});
assertThat(portalQueryService.siteSnapshotByPath("sch-3/jdxy").getName()).isEqualTo("机电学院");
// 改挂(走 siteService.save 发布 AFTER_COMMIT 事件)→ 整缓存 clear 覆盖 path 键
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setId(subId);
req.setCode("jdxy");
req.setParentSiteId(persistSite("sch-4", 1, "第二中学").getId());
req.setName("机电学院");
siteService.save(TestUsers.superAdmin(), req);
assertThat(portalQueryService.siteSnapshotByPath("sch-4/jdxy")).isNotNull();
assertThat(portalQueryService.siteSnapshotByPath("sch-3/jdxy")).isNull();
}
@Test
@DisplayName("T-W4-15: public_base_url 继承派生——主站配 base 子站未配派生 {base}/{code};子站自配优先")
void publicBaseUrl_derivedFromParentWhenUnset() {
Integer mainId = persistSite("sch-3d", 1, "第一中学").getId();
siteRepository.findById(mainId).ifPresent(s -> {
s.setConfigJson("{\"public_base_url\":\"https://demo.edu.cn\"}");
siteRepository.saveAndFlush(s);
});
persistSite("jdxy", 1, "机电学院", mainId);
// 子站未配 → 派生 {主站base}/{code}
PortalSiteSnapshot sub = portalQueryService.siteSnapshotByPath("sch-3d/jdxy");
assertThat(sub.getPublicBaseUrl()).isEqualTo("https://demo.edu.cn/jdxy");
// 子站自配优先
Integer subId = siteRepository.findByCode("jdxy").orElseThrow(IllegalStateException::new).getId();
siteRepository.findById(subId).ifPresent(s -> {
s.setConfigJson("{\"public_base_url\":\"https://jd.example.edu.cn\"}");
siteRepository.saveAndFlush(s);
});
siteService.save(TestUsers.superAdmin(), selfReq(subId)); // 触发事件失效缓存
assertThat(portalQueryService.siteSnapshotByPath("sch-3d/jdxy").getPublicBaseUrl())
.isEqualTo("https://jd.example.edu.cn");
}
/** 触发保存事件的最小编辑请求(同 code 同 parent;configJson 回传现有值防被覆写) */
private com.yunhai.cms.model.dto.SiteSaveRequest selfReq(Integer id) {
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setId(id);
req.setCode("jdxy");
com.yunhai.cms.entity.CmsSite site = siteRepository.findById(id).orElseThrow(IllegalStateException::new);
req.setParentSiteId(site.getParentSiteId());
req.setSchId(site.getSchId());
req.setName("机电学院");
req.setConfigJson(site.getConfigJson());
return req;
}
} }
@@ -146,7 +146,7 @@ class SiteServiceIT extends CmsIntegrationTestBase {
} }
@Test @Test
@DisplayName("站点编码重复被拒(唯一性)") @DisplayName("站点路径重复被拒(W4/D2:顶层同码新建=门户地址已被占用,原全局唯一语义由 path 唯一承接)")
void save_duplicateCodeRejected() { void save_duplicateCodeRejected() {
seedTwoSchools(); seedTwoSchools();
SiteSaveRequest req = new SiteSaveRequest(); SiteSaveRequest req = new SiteSaveRequest();
@@ -154,7 +154,7 @@ class SiteServiceIT extends CmsIntegrationTestBase {
req.setName("重复编码站点"); req.setName("重复编码站点");
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("已存在"); .hasMessageContaining("路径冲突");
} }
@Test @Test
@@ -804,4 +804,87 @@ class SiteServiceIT extends CmsIntegrationTestBase {
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("custom_css"); .hasMessageContaining("custom_css");
} }
// ===== W4 嵌套路径(方案 §8.3 T-W4-01..06)=====
/** 超管快捷建站:code/parent/sch 指定,名称自动生成 */
private SiteVo w4Save(String code, Integer parentSiteId, Integer schId, String schName) {
SiteSaveRequest req = new SiteSaveRequest();
req.setCode(code);
req.setParentSiteId(parentSiteId);
req.setSchId(schId);
req.setSchName(schName);
req.setName(code + "门户");
return siteService.save(TestUsers.superAdmin(), req);
}
@Test
@DisplayName("T-W4-01: 子站 path 落库=父path/code,SiteVo.path 同值")
void save_subSitePathConcatenatesParentPath() {
SiteVo main = w4Save("sch-3", 0, 1, "第一中学");
SiteVo sub = w4Save("jdxy", main.getId(), null, null);
assertThat(sub.getPath()).isEqualTo("sch-3/jdxy");
assertThat(siteRepository.findById(sub.getId()).orElseThrow(IllegalStateException::new).getPath()).isEqualTo("sch-3/jdxy");
}
@Test
@DisplayName("T-W4-02: 顶层 path=code")
void save_topLevelPathEqualsCode() {
SiteVo vo = w4Save("sch-9", 0, 3, "第九中学");
assertThat(vo.getPath()).isEqualTo("sch-9");
}
@Test
@DisplayName("T-W4-03: 同父同 code 冲突拒绝(路径冲突)")
void save_sameParentSameCodeRejected() {
SiteVo main = w4Save("sch-3a", 0, 1, "第一中学");
w4Save("jdxy", main.getId(), null, null);
assertThatThrownBy(() -> w4Save("jdxy", main.getId(), null, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("路径冲突");
}
@Test
@DisplayName("T-W4-04: 异父同 code 允许(两主站各建 jdxy,path 不同)")
void save_sameCodeUnderDifferentParentsAllowed() {
SiteVo main1 = w4Save("sch-3b", 0, 1, "第一中学");
SiteVo main2 = w4Save("sch-4b", 0, 2, "第二中学");
SiteVo sub1 = w4Save("jdxy", main1.getId(), null, null);
SiteVo sub2 = w4Save("jdxy", main2.getId(), null, null);
assertThat(sub1.getPath()).isEqualTo("sch-3b/jdxy");
assertThat(sub2.getPath()).isEqualTo("sch-4b/jdxy");
}
@Test
@DisplayName("T-W4-05: 编辑时 code 不可改(编码创建后不可修改)")
void save_changeCodeRejected() {
SiteVo vo = w4Save("sch-5", 0, 5, "第五中学");
SiteSaveRequest req = new SiteSaveRequest();
req.setId(vo.getId());
req.setCode("sch-5-changed");
req.setParentSiteId(0);
req.setSchId(5);
req.setSchName("第五中学");
req.setName("第五中学门户改名");
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req))
.isInstanceOf(BizException.class)
.hasMessageContaining("编码创建后不可修改");
}
@Test
@DisplayName("T-W4-06: 子站改挂重算 path")
void save_reparentRecalculatesPath() {
SiteVo main1 = w4Save("sch-3c", 0, 1, "第一中学");
SiteVo main2 = w4Save("sch-4c", 0, 1, "第一中学分部");
SiteVo sub = w4Save("jdxy", main1.getId(), null, null);
assertThat(sub.getPath()).isEqualTo("sch-3c/jdxy");
SiteSaveRequest req = new SiteSaveRequest();
req.setId(sub.getId());
req.setCode("jdxy");
req.setParentSiteId(main2.getId());
req.setName("机电学院");
SiteVo moved = siteService.save(TestUsers.superAdmin(), req);
assertThat(moved.getPath()).isEqualTo("sch-4c/jdxy");
}
} }
@@ -78,6 +78,7 @@ public abstract class CmsIntegrationTestBase {
protected CmsSite persistSite(String code, Integer schId, String name) { protected CmsSite persistSite(String code, Integer schId, String name) {
CmsSite site = new CmsSite(); CmsSite site = new CmsSite();
site.setCode(code); site.setCode(code);
site.setPath(code); // W4:顶层 path=code(与 SiteService.save 口径一致)
site.setSchId(schId); site.setSchId(schId);
site.setSchName("学校-" + schId); site.setSchName("学校-" + schId);
site.setName(name); site.setName(name);
@@ -89,7 +90,14 @@ public abstract class CmsIntegrationTestBase {
/** 带上级站点的落库(院系子站,《院系子站方案》):parentSiteId 传 null 视为顶层 0 */ /** 带上级站点的落库(院系子站,《院系子站方案》):parentSiteId 传 null 视为顶层 0 */
protected CmsSite persistSite(String code, Integer schId, String name, Integer parentSiteId) { protected CmsSite persistSite(String code, Integer schId, String name, Integer parentSiteId) {
CmsSite site = persistSite(code, schId, name); CmsSite site = persistSite(code, schId, name);
site.setParentSiteId(parentSiteId == null ? 0 : parentSiteId); if (parentSiteId == null || parentSiteId == 0) {
site.setParentSiteId(0);
} else {
CmsSite parent = siteRepository.findById(parentSiteId)
.orElseThrow(IllegalStateException::new);
site.setParentSiteId(parentSiteId);
site.setPath(parent.getPath() + "/" + code); // W4:子站 path=父path/code
}
return siteRepository.saveAndFlush(site); return siteRepository.saveAndFlush(site);
} }
@@ -111,7 +111,7 @@ class PreviewApiIT extends CmsIntegrationTestBase {
@DisplayName("T23: 预览按指定主题渲染(含站点名与主题标记)") @DisplayName("T23: 预览按指定主题渲染(含站点名与主题标记)")
void previewRendersSpecifiedTheme() throws Exception { void previewRendersSpecifiedTheme() throws Exception {
MvcResult result = standalone.perform(get("/api/theme/preview") MvcResult result = standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "t-demo-red")) .param("sitePath", "demo").param("theme", "t-demo-red"))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
String html = body(result); String html = body(result);
assertThat(html).contains("预览内容源门户"); assertThat(html).contains("预览内容源门户");
@@ -122,7 +122,7 @@ class PreviewApiIT extends CmsIntegrationTestBase {
@DisplayName("T24: 预览注入 p_ 参数(p_primary=#b01a2e → --primary:#b01a2e)") @DisplayName("T24: 预览注入 p_ 参数(p_primary=#b01a2e → --primary:#b01a2e)")
void previewInjectsParams() throws Exception { void previewInjectsParams() throws Exception {
MvcResult result = standalone.perform(get("/api/theme/preview") MvcResult result = standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "t-demo-red") .param("sitePath", "demo").param("theme", "t-demo-red")
.param("p_primary", "#b01a2e")) .param("p_primary", "#b01a2e"))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
assertThat(body(result)).contains("--primary:#b01a2e"); assertThat(body(result)).contains("--primary:#b01a2e");
@@ -132,7 +132,7 @@ class PreviewApiIT extends CmsIntegrationTestBase {
@DisplayName("T25: 预览资产指向管理端代理(R1 串包修复的核心断言)") @DisplayName("T25: 预览资产指向管理端代理(R1 串包修复的核心断言)")
void previewAssetsPointToAdminProxy() throws Exception { void previewAssetsPointToAdminProxy() throws Exception {
MvcResult result = standalone.perform(get("/api/theme/preview") MvcResult result = standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "t-demo-red")) .param("sitePath", "demo").param("theme", "t-demo-red"))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
assertThat(body(result)).contains("/api/theme/t-demo-red/asset/css/main.css"); assertThat(body(result)).contains("/api/theme/t-demo-red/asset/css/main.css");
} }
@@ -142,7 +142,7 @@ class PreviewApiIT extends CmsIntegrationTestBase {
void previewAssetHrefCarriesContextPathEndToEnd() throws Exception { void previewAssetHrefCarriesContextPathEndToEnd() throws Exception {
MvcResult result = standalone.perform(get("/yschcms/api/theme/preview") MvcResult result = standalone.perform(get("/yschcms/api/theme/preview")
.contextPath("/yschcms") .contextPath("/yschcms")
.param("siteCode", "demo").param("theme", "t-demo-red")) .param("sitePath", "demo").param("theme", "t-demo-red"))
.andExpect(status().isOk()).andReturn(); .andExpect(status().isOk()).andReturn();
assertThat(body(result)) assertThat(body(result))
.contains("href=\"/yschcms/api/theme/t-demo-red/asset/css/main.css\""); .contains("href=\"/yschcms/api/theme/t-demo-red/asset/css/main.css\"");
@@ -152,7 +152,7 @@ class PreviewApiIT extends CmsIntegrationTestBase {
@DisplayName("T26: 预览响应 no-store(不进缓存/CDN)") @DisplayName("T26: 预览响应 no-store(不进缓存/CDN)")
void previewNoStore() throws Exception { void previewNoStore() throws Exception {
standalone.perform(get("/api/theme/preview") standalone.perform(get("/api/theme/preview")
.param("siteCode", "demo").param("theme", "default")) .param("sitePath", "demo").param("theme", "default"))
.andExpect(header().string("Cache-Control", "no-store")); .andExpect(header().string("Cache-Control", "no-store"));
} }
@@ -213,4 +213,33 @@ class PortalUrlHelperTest {
assertThat(url.asset("css/main.css")) assertThat(url.asset("css/main.css"))
.isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/assets/default/css/main.css"); .isEqualTo("https://base.yunhaixingtong.com/cms/portal/demo/assets/default/css/main.css");
} }
// ===== W4 嵌套路径(方案 §8.3 T-W4-11/12:构造参语义 code→sitePath)=====
@Test
@DisplayName("T-W4-11: 嵌套 sitePath 的 baseUrl/asset/category 全嵌套")
void nestedSitePath_allUrlsNested() {
PortalUrlHelper url = PortalUrlHelper.fromBaseOrigin(
"https://base.yunhaixingtong.com/cms", "sch-3/jdxy", "scholar-blue");
assertThat(url.home()).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/");
assertThat(url.category(7)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/c/7");
assertThat(url.article(9)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/a/9");
assertThat(url.asset("css/main.css"))
.isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy/assets/scholar-blue/css/main.css");
}
@Test
@DisplayName("T-W4-12: siteHome 兄弟/父级——当前=sch-3/jdxy 时替换站点段为嵌套目标")
void nestedSitePath_siteHomeSiblingsAndParent() {
PortalUrlHelper url = new PortalUrlHelper(
"https://base.yunhaixingtong.com/cms/portal/sch-3/jdxy", "sch-3/jdxy", "default",
"https://base.yunhaixingtong.com/cms");
// 父级主站
assertThat(url.siteHome("sch-3", null)).isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/");
// 兄弟子站(嵌套形态同构)
assertThat(url.siteHome("sch-3/other", null))
.isEqualTo("https://base.yunhaixingtong.com/cms/portal/sch-3/other/");
// 目标配 public_base_url 时优先
assertThat(url.siteHome("sch-3/other", "https://other.xxx.edu.cn")).isEqualTo("https://other.xxx.edu.cn/");
}
} }
+4 -2
View File
@@ -13,7 +13,8 @@ DROP TABLE IF EXISTS `cms_site`;
CREATE TABLE `cms_site` ( CREATE TABLE `cms_site` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(64) NOT NULL COMMENT '站点编码,URL 路径用(/portal/{code})', `code` VARCHAR(64) NOT NULL COMMENT '站点编码(URL 末段;W4 起不再全局唯一)',
`path` VARCHAR(191) NOT NULL DEFAULT '' COMMENT '门户路径(顶层=code;子站=父path/code),URL 唯一标识',
`parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id)', `parent_site_id` INT NOT NULL DEFAULT 0 COMMENT '上级站点ID(0=顶层校站点;院系子站挂主站 id)',
`sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)', `sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)',
`sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)', `sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)',
@@ -26,7 +27,8 @@ CREATE TABLE `cms_site` (
`update_by` VARCHAR(64) NULL, `update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL, `update_time` DATETIME NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_site_code` (`code`), UNIQUE KEY `uk_site_path` (`path`),
UNIQUE KEY `uk_site_parent_code` (`parent_site_id`, `code`),
KEY `idx_site_sch` (`sch_id`), KEY `idx_site_sch` (`sch_id`),
KEY `idx_site_parent` (`parent_site_id`) KEY `idx_site_parent` (`parent_site_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点'; ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点';