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

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