feat(portal): docs/16 首页布局后端- home_layout零DDL消费与主题fragment化-PortalSiteSnapshot.homeLayout+normalizeHomeLayout脏数据容错;PortalPageRenderer.buildLayoutBlocks按区块序列渲染(article_list多栏目聚合+homeStyle别名+category合成+background回落news_bg_image/ticker top|category双源/gallery标题副题缺省旧键/links分组过滤/visible跳过/未知type跳过/size 1~20夹取);布局模式清空topArticles/focusArticles/categoryBlocks/galleryBanners回落段;SiteService.validateConfigJson带siteId收紧home_layout(blocks<=30/type白名单/size 1~20/文本<=100/categoryIds归属校验:新建拒跨站拒不存在拒);themes/common/fragments/home-blocks.html plb-*兜底骨架(不注册皮肤包,Thymeleaf classpath可解析);campus-glow自有fragment(news_grid/media_focus/card/ticker/gallery)原home.html抽出;default+blue-white+classic-red+fresh-mint+mint-breeze+warm-kinder+scholar-blue+campus-glow八套home.html接入布局分支(th:case父元素+th:replace子元素嵌套,replace优先级100先于case执行);新增PortalHomeLayoutIT 5+SiteServiceHomeLayoutIT 3,全量330绿
build / build-push (push) Successful in 52s

This commit is contained in:
chenxing
2026-09-10 15:08:40 +08:00
parent 98d640faba
commit 39454e4efb
15 changed files with 1233 additions and 143 deletions
@@ -39,6 +39,7 @@ public class SiteService {
private final CmsAuthProperties authProperties;
private final ApplicationEventPublisher eventPublisher;
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
private final com.yunhai.cms.repository.CmsCategoryRepository categoryRepository;
/**
* B2 附件引用:站点配置 logo/favicon 回写场景引用。
@@ -59,6 +60,16 @@ public class SiteService {
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** home_layout(docs/16 §4.6)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
"article_list", "ticker", "gallery", "links")));
private static final int HOME_LAYOUT_MAX_BLOCKS = 30;
private static final int HOME_LAYOUT_MAX_BLOCK_KEYS = 32;
private static final int HOME_LAYOUT_MAX_TEXT = 100;
private static final int HOME_LAYOUT_SIZE_MIN = 1;
private static final int HOME_LAYOUT_SIZE_MAX = 20;
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
private static final List<String> CUSTOM_CSS_BLACKLIST =
@@ -162,7 +173,7 @@ public class SiteService {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
}
site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson());
validateConfigJson(req.getConfigJson(), site.getId());
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site);
@@ -289,10 +300,10 @@ public class SiteService {
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
* 系统托管键 theme_params 直通;home_layout 走结构校验(docs/16 §4.6)——
* 此前「非字符串值原样放行」的口子收紧到该键;其余非字符串值(wechat 等预留对象)不校验原样放行。
*/
private void validateConfigJson(String configJson) {
private void validateConfigJson(String configJson, Integer siteId) {
if (!StringUtils.hasText(configJson)) {
return;
}
@@ -315,6 +326,10 @@ public class SiteService {
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
}
JsonNode value = entry.getValue();
if ("home_layout".equals(key)) {
validateHomeLayout(value, siteId);
return;
}
if (!value.isTextual()) {
return;
}
@@ -363,6 +378,79 @@ public class SiteService {
});
}
/**
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键;
* type 白名单(article_list/ticker/gallery/links)、size 1~20、字符串字段(title/subtitle)≤100;
* categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
*/
private void validateHomeLayout(JsonNode node, Integer siteId) {
if (!node.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局必须是 JSON 对象({version, blocks})");
}
if (node.has("version") && !node.get("version").isInt()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 version 必须是整数");
}
JsonNode blocks = node.get("blocks");
if (blocks == null || !blocks.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 blocks 必须是数组");
}
if (blocks.size() > HOME_LAYOUT_MAX_BLOCKS) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块数量不能超过 " + HOME_LAYOUT_MAX_BLOCKS + " 个");
}
Set<Integer> referencedIds = new java.util.HashSet<>();
for (JsonNode block : blocks) {
if (!block.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块必须是对象");
}
if (block.size() > HOME_LAYOUT_MAX_BLOCK_KEYS) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局单个区块键数不能超过 " + HOME_LAYOUT_MAX_BLOCK_KEYS + " 个");
}
JsonNode type = block.get("type");
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText()));
}
JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
|| size.intValue() > HOME_LAYOUT_SIZE_MAX)) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块条数须在 " + HOME_LAYOUT_SIZE_MIN + "~" + HOME_LAYOUT_SIZE_MAX + " 之间");
}
for (String textKey : new String[]{"title", "subtitle"}) {
JsonNode text = block.get(textKey);
if (text != null && (!text.isTextual() || text.asText().length() > HOME_LAYOUT_MAX_TEXT)) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块 " + textKey + " 须为不超过 " + HOME_LAYOUT_MAX_TEXT + " 字的文本");
}
}
JsonNode categoryIds = block.get("categoryIds");
if (categoryIds != null) {
if (!categoryIds.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须是数组");
}
for (JsonNode id : categoryIds) {
if (!id.isNumber() || id.intValue() <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryIds 必须为正整数");
}
referencedIds.add(id.intValue());
}
}
}
if (!referencedIds.isEmpty()) {
if (siteId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点尚未创建,首页布局不能引用栏目");
}
// 防跨站注入:引用的栏目必须属于本站点
List<com.yunhai.cms.entity.CmsCategory> cats = categoryRepository.findAllById(referencedIds);
for (com.yunhai.cms.entity.CmsCategory c : cats) {
if (!siteId.equals(c.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不属于本站点的栏目(id=" + c.getId() + ")");
}
}
if (cats.size() < referencedIds.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局引用了不存在的栏目");
}
}
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、