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
@@ -20,6 +20,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
@@ -37,7 +38,9 @@ public class PortalPageRenderer {
private final CmsArticleRepository articleRepository;
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载) */
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
* 原样回落现状装配(D2 兼容,老站一个像素都不变)。 */
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
model.addAttribute("url", url);
model.addAttribute("site", site);
@@ -48,10 +51,20 @@ public class PortalPageRenderer {
model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url);
model.addAttribute("banners", banners);
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId()));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
if (layoutBlocks != null) {
// 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
model.addAttribute("layoutBlocks", layoutBlocks);
model.addAttribute("galleryBanners", new ArrayList<>());
model.addAttribute("topArticles", new ArrayList<>());
model.addAttribute("focusArticles", new ArrayList<>());
model.addAttribute("categoryBlocks", new ArrayList<>());
} else {
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId(), HOME_TOP_SIZE));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
}
model.addAttribute("pageTitle", site.getName());
model.addAttribute("seoDescription", site.getConfig() == null ? null
: site.getConfig().get("seo_description"));
@@ -169,10 +182,10 @@ public class PortalPageRenderer {
}
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
private List<PortalListData.Item> topArticles(Integer siteId) {
private List<PortalListData.Item> topArticles(Integer siteId, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_TOP_SIZE,
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
@@ -248,6 +261,250 @@ public class PortalPageRenderer {
}
}
// ===== 首页布局装配(docs/16 主线B §4.4)=====
/** 布局区块条数上限与缺省(与编辑器/服务端校验同约束) */
private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
* 每块输出与 categoryBlocks 同构的 Map(category.name/homeStyle 供主题 fragment 兼容消费),
* 另带 type/style/title/subtitle/background/source 布局属性;visible=false 的块保序跳过。
*/
private List<Map<String, Object>> buildLayoutBlocks(PortalSiteSnapshot site, Map<String, Object> homeLayout,
PortalUrlHelper url) {
if (homeLayout == null) {
return null;
}
Object raw = homeLayout.get("blocks");
if (!(raw instanceof List)) {
return null; // 容错:blocks 非数组视为无布局
}
Set<Integer> treeIds = new java.util.HashSet<>();
collectTreeIds(site.getCategories(), treeIds);
List<Map<String, Object>> result = new ArrayList<>();
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<String, Object> spec = (Map<String, Object>) o;
if (Boolean.FALSE.equals(spec.get("visible"))) {
continue; // 编辑器「隐藏」:保序下线
}
Map<String, Object> block = buildLayoutBlock(site, spec, treeIds, url);
if (block != null) {
result.add(block);
}
}
return result;
}
private void collectTreeIds(List<PortalCategory> categories, Set<Integer> ids) {
for (PortalCategory c : categories) {
ids.add(c.getId());
collectTreeIds(c.getChildren(), ids);
}
}
private Map<String, Object> buildLayoutBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
String type = spec.get("type") == null ? "" : String.valueOf(spec.get("type"));
switch (type) {
case "article_list":
return buildArticleListBlock(site, spec, treeIds, url);
case "ticker":
return buildTickerBlock(site, spec, treeIds);
case "gallery":
return buildGalleryBlock(site, spec, url);
case "links":
return buildLinksBlock(site, spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
}
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题) */
private Map<String, Object> baseBlock(Map<String, Object> spec, String type, String fallbackTitle) {
Map<String, Object> block = new HashMap<>();
block.put("type", type);
String title = spec.get("title") == null ? "" : String.valueOf(spec.get("title")).trim();
if (title.isEmpty()) {
title = fallbackTitle;
}
block.put("title", title);
Map<String, Object> category = new HashMap<>();
category.put("name", title);
block.put("category", category);
return block;
}
/**
* 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入),
* 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。
*/
private Map<String, Object> buildArticleListBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
List<Integer> categoryIds = new ArrayList<>();
PortalCategory first = null;
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node == null) {
continue;
}
collectListDescendantIds(node, categoryIds);
if (first == null) {
first = node;
}
}
int size = clampSize(spec.get("size"), LAYOUT_LIST_SIZE_DEFAULT);
List<PortalListData.Item> items = categoryIds.isEmpty()
? new ArrayList<>()
: articlesIn(site.getId(), categoryIds, size);
String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style"));
Map<String, Object> block = baseBlock(spec, "article_list",
first != null ? first.getName() : "栏目文章");
block.put("style", style);
block.put("homeStyle", style); // 主题 fragment 兼容:cg-newshot 等按 homeStyle 分支
block.put("items", items);
block.put("size", size);
block.put("moreUrl", first != null ? url.category(first.getId()) : url.home());
// 背景大图(§4.5):区块未设 background 时以旧键 news_bg_image 为默认值(存量配置无损生效)
boolean background = spec.get("background") != null
? Boolean.parseBoolean(String.valueOf(spec.get("background")))
: hasText(configValue(site, "news_bg_image"));
block.put("background", background);
return block;
}
/** 滚动速递:source=top 走全站置顶;source=category 走栏目 IN 查询(树外栏目跳过) */
private Map<String, Object> buildTickerBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source"));
int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT);
List<PortalListData.Item> items;
if ("category".equals(source)) {
List<Integer> categoryIds = new ArrayList<>();
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node != null) {
collectListDescendantIds(node, categoryIds);
}
}
items = categoryIds.isEmpty() ? new ArrayList<>() : articlesIn(site.getId(), categoryIds, size);
} else {
items = topArticles(site.getId(), size);
}
Map<String, Object> block = baseBlock(spec, "ticker", "要闻速递");
block.put("source", source);
block.put("items", items);
block.put("size", size);
return block;
}
/**
* 图片画廊:数据通道不变(轮播位 gallery,轮播管理维护)。
* 标题/副题(§4.5):区块未设时以旧键 gallery_title/gallery_subtitle 为默认值;
* size 未设 = 全部展示(历史站点画廊数量不定,不加隐式上限)。
*/
private Map<String, Object> buildGalleryBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
Map<String, Object> config = site.getConfig() == null ? new HashMap<>() : site.getConfig();
Map<String, Object> block = baseBlock(spec, "gallery", str(config.get("gallery_title"), "线上展厅"));
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (subtitle.isEmpty()) {
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
}
block.put("subtitle", subtitle);
block.put("items", activeBanners(site, "gallery", url));
return block;
}
/** 链接组:友情链接分组(group 未设 = 全部分组;首页此前未消费该数据,现有通道直出) */
private Map<String, Object> buildLinksBlock(PortalSiteSnapshot site, Map<String, Object> spec) {
String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim();
List<com.yunhai.cms.model.portal.PortalLinkGroup> groups = new ArrayList<>();
for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) {
if (group.isEmpty() || group.equals(g.getGroupName())) {
groups.add(g);
}
}
Map<String, Object> block = baseBlock(spec, "links", "友情链接");
block.put("group", group);
block.put("linkGroups", groups);
return block;
}
// ===== 布局装配工具 =====
private static String str(Object v, String fallback) {
return v == null ? fallback : String.valueOf(v);
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private static String configValue(PortalSiteSnapshot site, String key) {
Object v = site.getConfig() == null ? null : site.getConfig().get(key);
return v == null ? null : String.valueOf(v);
}
private static List<Integer> intList(Object v) {
List<Integer> ids = new ArrayList<>();
if (v instanceof List) {
for (Object o : (List<?>) v) {
if (o instanceof Number && ((Number) o).intValue() > 0) {
ids.add(((Number) o).intValue());
}
}
}
return ids;
}
private static int clampSize(Object v, int fallback) {
if (!(v instanceof Number)) {
return fallback;
}
int n = ((Number) v).intValue();
return Math.max(1, Math.min(LAYOUT_SIZE_MAX, n));
}
private List<PortalListData.Item> articlesIn(Integer siteId, List<Integer> categoryIds, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */
private PortalCategory findInTree(List<PortalCategory> categories, Integer id) {
for (PortalCategory c : categories) {
if (c.getId().equals(id)) {
return c;
}
PortalCategory hit = findInTree(c.getChildren(), id);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
@@ -63,6 +63,13 @@ public class PortalSiteSnapshot {
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
private String customCss = "";
/**
* 首页布局(docs/16 主线B:config_json.home_layout,{version, blocks:[…]})。
* null = 未编排布局(渲染端回落「全部栏目自动渲染」现状,老站零迁移);
* 解析容错:键缺失/非法结构一律 null,绝不让脏布局打挂门户。
*/
private Map<String, Object> homeLayout;
/**
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
* 规避模板内拆分的表达式平台差异)。
@@ -84,10 +91,20 @@ public class PortalSiteSnapshot {
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
return snapshot;
}
/** home_layout 容错归一:对象且 blocks 为数组才生效,否则视为无布局(回落态) */
private static Map<String, Object> normalizeHomeLayout(Object raw) {
if (!(raw instanceof Map)) {
return null;
}
Object blocks = ((Map<?, ?>) raw).get("blocks");
return blocks instanceof List ? (Map<String, Object>) raw : null;
}
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
private static List<String> splitAddressLines(String raw) {
List<String> lines = new ArrayList<>();
@@ -126,6 +143,7 @@ public class PortalSiteSnapshot {
copy.setThemeParams(themeParams);
copy.setThemeVarsCss(themeVarsCss);
copy.setCustomCss(customCss);
copy.setHomeLayout(homeLayout);
copy.setFooterAddressLines(footerAddressLines);
return copy;
}
@@ -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}(外链样式注入)、