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
@@ -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 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——