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:
@@ -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());
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
* 仍未命中 404。
|
||||||
|
*/
|
||||||
|
private PortalSiteSnapshot requireSiteByPath(String sitePath, String subPath,
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response) throws Exception {
|
||||||
|
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
|
||||||
|
if (site != null) {
|
||||||
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
return site;
|
}
|
||||||
|
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 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
|
||||||
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
|
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
|
||||||
|
|||||||
@@ -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/");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 站点';
|
||||||
|
|||||||
Reference in New Issue
Block a user