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
build / build-push (push) Successful in 52s
This commit is contained in:
@@ -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}(外链样式注入)、
|
||||
|
||||
Reference in New Issue
Block a user