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