diff --git a/sql/cms_seed_local.sql b/sql/cms_seed_local.sql index b7a14f1..c6a14d1 100644 --- a/sql/cms_seed_local.sql +++ b/sql/cms_seed_local.sql @@ -17,8 +17,8 @@ -- ============================================================ -- 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 -(1, 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default', +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', '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 验收演示数据)"}', 1, 'cmssuper', NOW()); diff --git a/sql/upgrade_w4_site_path.sql b/sql/upgrade_w4_site_path.sql new file mode 100644 index 0000000..d077fe6 --- /dev/null +++ b/sql/upgrade_w4_site_path.sql @@ -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; +-- ============================================================ diff --git a/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java b/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java index 436a090..5f16582 100644 --- a/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java +++ b/src/main/java/com/yunhai/cms/controller/api/ThemePreviewController.java @@ -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 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); } } diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java index f99525a..07904f2 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java @@ -40,15 +40,28 @@ public class PortalAssetController { public ResponseEntity 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 assetNested(@PathVariable("p") String p, + @PathVariable("s") String s, + @PathVariable("theme") String theme, + HttpServletRequest request) { + return doAsset(p + "/" + s, theme, request); + } + + private ResponseEntity 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; diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java index 7853029..bde72a3 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalController.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalController.java @@ -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) */ diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java index a6e3b1c..3454e85 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalIndexController.java @@ -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() + "/"); } } diff --git a/src/main/java/com/yunhai/cms/entity/CmsSite.java b/src/main/java/com/yunhai/cms/entity/CmsSite.java index 9bb38a8..39d9dee 100644 --- a/src/main/java/com/yunhai/cms/entity/CmsSite.java +++ b/src/main/java/com/yunhai/cms/entity/CmsSite.java @@ -24,14 +24,24 @@ import javax.persistence.UniqueConstraint; @Setter @Entity @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")) public class CmsSite extends BaseEntity { - /** 站点编码,URL 路径用(/portal/{code}) */ + /** 站点编码(URL 末段;W4 起不再全局唯一——不同学校可重用同一编码) */ @Column(name = "code", nullable = false, length = 64) 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,层级固定两层) */ @Column(name = "parent_site_id", nullable = false) private Integer parentSiteId = 0; diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java index 07aeb61..aa0eb53 100644 --- a/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteBrief.java @@ -18,6 +18,9 @@ public class PortalSiteBrief { private String code; + /** 门户路径(W4:嵌套 path,下拉链接用) */ + private String path; + private String name; /** 站点 logo(config_json.logo,下拉项可选展示) */ @@ -30,6 +33,7 @@ public class PortalSiteBrief { PortalSiteBrief brief = new PortalSiteBrief(); brief.setId(site.getId()); brief.setCode(site.getCode()); + brief.setPath(site.getPath()); brief.setName(site.getName()); Map config = ExtJsonUtils.parse(site.getConfigJson()); brief.setLogoUrl(ExtJsonUtils.getString(config, "logo")); diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java index 98e8ff8..08ff4aa 100644 --- a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java @@ -20,6 +20,9 @@ public class PortalSiteSnapshot { private String code; + /** 门户路径(W4:顶层=code;子站=父path/code) */ + private String path; + /** 上级站点ID(0=顶层校站点,《院系子站方案》) */ private Integer parentSiteId; @@ -64,6 +67,7 @@ public class PortalSiteSnapshot { PortalSiteSnapshot snapshot = new PortalSiteSnapshot(); snapshot.setId(site.getId()); snapshot.setCode(site.getCode()); + snapshot.setPath(site.getPath()); snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId()); snapshot.setName(site.getName()); snapshot.setSchName(site.getSchName()); @@ -85,6 +89,7 @@ public class PortalSiteSnapshot { PortalSiteSnapshot copy = new PortalSiteSnapshot(); copy.setId(id); copy.setCode(code); + copy.setPath(path); copy.setParentSiteId(parentSiteId); copy.setName(name); copy.setSchName(schName); diff --git a/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java b/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java index 27ea7a1..6c89de9 100644 --- a/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java +++ b/src/main/java/com/yunhai/cms/model/vo/SchoolVo.java @@ -20,6 +20,8 @@ public class SchoolVo { private Integer mainSiteId; /** 主站编码(学校上下文 URL 段 /sch/{code}) */ private String mainSiteCode; + /** 主站门户路径(W4:/portal/{mainSitePath}/,顶层=code) */ + private String mainSitePath; private String mainSiteName; private String theme; diff --git a/src/main/java/com/yunhai/cms/model/vo/SiteVo.java b/src/main/java/com/yunhai/cms/model/vo/SiteVo.java index 7f82b27..0083135 100644 --- a/src/main/java/com/yunhai/cms/model/vo/SiteVo.java +++ b/src/main/java/com/yunhai/cms/model/vo/SiteVo.java @@ -12,6 +12,9 @@ public class SiteVo { private String code; + /** 门户路径(W4:顶层=code;子站=父path/code,URL 唯一标识) */ + private String path; + /** 上级站点ID(0=顶层校站点,《院系子站方案》) */ private Integer parentSiteId; @@ -38,6 +41,7 @@ public class SiteVo { SiteVo vo = new SiteVo(); vo.setId(site.getId()); vo.setCode(site.getCode()); + vo.setPath(site.getPath()); vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId()); vo.setSchId(site.getSchId()); vo.setSchName(site.getSchName()); diff --git a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java index 1b38741..c4e4457 100644 --- a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java +++ b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java @@ -10,6 +10,17 @@ public interface CmsSiteRepository extends JpaRepository { Optional findByCode(String code); + /** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */ + Optional findByPath(String path); + + /** 路径冲突检查(新建/改挂重算用;编辑排除自身) */ + boolean existsByPath(String path); + + boolean existsByPathAndIdNot(String path, Integer id); + + /** 皮肤包被用站点数(W1 删除保护共用) */ + long countByTheme(String theme); + /** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */ List findByStatusOrderByIdAsc(Integer status); diff --git a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java index 716b51e..b0ec6d2 100644 --- a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java +++ b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java @@ -19,6 +19,7 @@ import com.yunhai.cms.repository.CmsBannerRepository; import com.yunhai.cms.repository.CmsCategoryRepository; import com.yunhai.cms.repository.CmsLinkRepository; import com.yunhai.cms.repository.CmsSiteRepository; +import com.yunhai.cms.utils.ExtJsonUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; @@ -54,11 +55,28 @@ public class PortalQueryService { @Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode") public PortalSiteSnapshot siteSnapshot(String siteCode) { 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) { return null; } PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site); snapshot.setTheme(resolveTheme(snapshot.getTheme())); + derivePublicBaseUrl(site, snapshot); // 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致 com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme()); java.util.Map 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) */ private String resolveTheme(String theme) { if (themeRegistry.contains(theme)) { diff --git a/src/main/java/com/yunhai/cms/service/impl/SchoolService.java b/src/main/java/com/yunhai/cms/service/impl/SchoolService.java index 72968ea..5af5a39 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SchoolService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SchoolService.java @@ -49,6 +49,7 @@ public class SchoolService { vo.setSchName(top.getSchName()); vo.setMainSiteId(top.getId()); vo.setMainSiteCode(top.getCode()); + vo.setMainSitePath(top.getPath()); vo.setMainSiteName(top.getName()); vo.setTheme(top.getTheme()); vo.setStatus(top.getStatus()); diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index 4b224f6..ec76874 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -120,22 +120,34 @@ public class SiteService { 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 public SiteVo save(BoaUser user, SiteSaveRequest req) { requireSiteSavePermission(user, req); CmsSite site; if (req.getId() == null) { - if (siteRepository.findByCode(req.getCode()).isPresent()) { - throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在"); - } site = new CmsSite(); site.setCode(req.getCode()); } else { site = siteRepository.findById(req.getId()) .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); + // 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 匹配, // 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响) if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) { @@ -160,6 +172,19 @@ public class SiteService { 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 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化): * 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项—— diff --git a/src/main/resources/sql/cms_init.sql b/src/main/resources/sql/cms_init.sql index 7ad828a..13574ec 100644 --- a/src/main/resources/sql/cms_init.sql +++ b/src/main/resources/sql/cms_init.sql @@ -8,7 +8,8 @@ -- 1. 站点表(多租户根;一校一主站 + N 院系子站,见《院系子站方案.md》) CREATE TABLE IF NOT EXISTS `cms_site` ( `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,层级固定两层)', `sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)', `sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)', @@ -21,7 +22,8 @@ CREATE TABLE IF NOT EXISTS `cms_site` ( `update_by` VARCHAR(64) NULL, `update_time` DATETIME NULL, 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_parent` (`parent_site_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点(一校一主站 + N 院系子站)'; diff --git a/src/main/resources/templates/themes/blue-white/fragments/header.html b/src/main/resources/templates/themes/blue-white/fragments/header.html index 3704f5d..0f43a5b 100644 --- a/src/main/resources/templates/themes/blue-white/fragments/header.html +++ b/src/main/resources/templates/themes/blue-white/fragments/header.html @@ -20,8 +20,8 @@ ▾ diff --git a/src/main/resources/templates/themes/classic-red/fragments/header.html b/src/main/resources/templates/themes/classic-red/fragments/header.html index 1d3035f..b9927b0 100644 --- a/src/main/resources/templates/themes/classic-red/fragments/header.html +++ b/src/main/resources/templates/themes/classic-red/fragments/header.html @@ -21,8 +21,8 @@ ▾ diff --git a/src/main/resources/templates/themes/default/fragments/header.html b/src/main/resources/templates/themes/default/fragments/header.html index f6aca08..de6f617 100644 --- a/src/main/resources/templates/themes/default/fragments/header.html +++ b/src/main/resources/templates/themes/default/fragments/header.html @@ -17,8 +17,8 @@ diff --git a/src/main/resources/templates/themes/scholar-blue/fragments/header.html b/src/main/resources/templates/themes/scholar-blue/fragments/header.html index 4c0fdfd..b23dc9d 100644 --- a/src/main/resources/templates/themes/scholar-blue/fragments/header.html +++ b/src/main/resources/templates/themes/scholar-blue/fragments/header.html @@ -17,8 +17,8 @@ diff --git a/src/main/resources/templates/themes/warm-kinder/fragments/header.html b/src/main/resources/templates/themes/warm-kinder/fragments/header.html index 3704f5d..0f43a5b 100644 --- a/src/main/resources/templates/themes/warm-kinder/fragments/header.html +++ b/src/main/resources/templates/themes/warm-kinder/fragments/header.html @@ -20,8 +20,8 @@ ▾ diff --git a/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java b/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java index ad1c188..cffe594 100644 --- a/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java +++ b/src/test/java/com/yunhai/cms/portal/PortalCollegeNavIT.java @@ -99,4 +99,25 @@ class PortalCollegeNavIT extends CmsIntegrationTestBase { mockMvc.perform(get("/portal")).andExpect(status().is3xxRedirection()) .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*]*>机电学院"); + } } diff --git a/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java b/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java index 4f430d9..0ca89d8 100644 --- a/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java +++ b/src/test/java/com/yunhai/cms/portal/PortalSsrIT.java @@ -448,4 +448,79 @@ class PortalSsrIT extends CmsIntegrationTestBase { assertThat(html).contains("教育部"); 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()); + } } diff --git a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java index 0ff4c45..289923a 100644 --- a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java +++ b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java @@ -39,6 +39,7 @@ class ThemeRenderIT extends CmsIntegrationTestBase { private void persistSiteWithConfig(String code, String configJson) { CmsSite site = new CmsSite(); site.setCode(code); + site.setPath(code); // W4:顶层 path=code(与 persistSite 同口径) site.setSchId(1); site.setSchName("学校-1"); site.setName("注入测试门户"); diff --git a/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java b/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java index 1e4f52c..9131e4d 100644 --- a/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/PortalQueryServiceCacheIT.java @@ -257,4 +257,78 @@ class PortalQueryServiceCacheIT extends CmsIntegrationTestBase { 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; + } } diff --git a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java index 6103f5b..c8e71c8 100644 --- a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java @@ -146,7 +146,7 @@ class SiteServiceIT extends CmsIntegrationTestBase { } @Test - @DisplayName("站点编码重复被拒(唯一性)") + @DisplayName("站点路径重复被拒(W4/D2:顶层同码新建=门户地址已被占用,原全局唯一语义由 path 唯一承接)") void save_duplicateCodeRejected() { seedTwoSchools(); SiteSaveRequest req = new SiteSaveRequest(); @@ -154,7 +154,7 @@ class SiteServiceIT extends CmsIntegrationTestBase { req.setName("重复编码站点"); assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) .isInstanceOf(BizException.class) - .hasMessageContaining("已存在"); + .hasMessageContaining("路径冲突"); } @Test @@ -804,4 +804,87 @@ class SiteServiceIT extends CmsIntegrationTestBase { .isInstanceOf(BizException.class) .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"); + } } diff --git a/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java b/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java index 6285c14..0e68df6 100644 --- a/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java +++ b/src/test/java/com/yunhai/cms/support/CmsIntegrationTestBase.java @@ -78,6 +78,7 @@ public abstract class CmsIntegrationTestBase { protected CmsSite persistSite(String code, Integer schId, String name) { CmsSite site = new CmsSite(); site.setCode(code); + site.setPath(code); // W4:顶层 path=code(与 SiteService.save 口径一致) site.setSchId(schId); site.setSchName("学校-" + schId); site.setName(name); @@ -89,7 +90,14 @@ public abstract class CmsIntegrationTestBase { /** 带上级站点的落库(院系子站,《院系子站方案》):parentSiteId 传 null 视为顶层 0 */ protected CmsSite persistSite(String code, Integer schId, String name, Integer parentSiteId) { 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); } diff --git a/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java b/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java index b5816a8..50d0bdf 100644 --- a/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java +++ b/src/test/java/com/yunhai/cms/theme/PreviewApiIT.java @@ -111,7 +111,7 @@ class PreviewApiIT extends CmsIntegrationTestBase { @DisplayName("T23: 预览按指定主题渲染(含站点名与主题标记)") void previewRendersSpecifiedTheme() throws Exception { 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(); String html = body(result); assertThat(html).contains("预览内容源门户"); @@ -122,7 +122,7 @@ class PreviewApiIT extends CmsIntegrationTestBase { @DisplayName("T24: 预览注入 p_ 参数(p_primary=#b01a2e → --primary:#b01a2e)") void previewInjectsParams() throws Exception { 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")) .andExpect(status().isOk()).andReturn(); assertThat(body(result)).contains("--primary:#b01a2e"); @@ -132,7 +132,7 @@ class PreviewApiIT extends CmsIntegrationTestBase { @DisplayName("T25: 预览资产指向管理端代理(R1 串包修复的核心断言)") void previewAssetsPointToAdminProxy() throws Exception { 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(); 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 { MvcResult result = standalone.perform(get("/yschcms/api/theme/preview") .contextPath("/yschcms") - .param("siteCode", "demo").param("theme", "t-demo-red")) + .param("sitePath", "demo").param("theme", "t-demo-red")) .andExpect(status().isOk()).andReturn(); assertThat(body(result)) .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)") void previewNoStore() throws Exception { 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")); } diff --git a/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java b/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java index 30f1e25..d81ba1f 100644 --- a/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java +++ b/src/test/java/com/yunhai/cms/utils/PortalUrlHelperTest.java @@ -213,4 +213,33 @@ class PortalUrlHelperTest { assertThat(url.asset("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/"); + } } diff --git a/src/test/resources/schema-cms-test.sql b/src/test/resources/schema-cms-test.sql index d68d5af..198fb3c 100644 --- a/src/test/resources/schema-cms-test.sql +++ b/src/test/resources/schema-cms-test.sql @@ -13,7 +13,8 @@ DROP TABLE IF EXISTS `cms_site`; CREATE TABLE `cms_site` ( `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)', `sch_id` INT NULL COMMENT '学校ID(逻辑外键 → UserBase sys_group.id)', `sch_name` VARCHAR(128) NULL COMMENT '学校名称(冗余展示)', @@ -26,7 +27,8 @@ CREATE TABLE `cms_site` ( `update_by` VARCHAR(64) NULL, `update_time` DATETIME NULL, 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_parent` (`parent_site_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 站点';