diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java index 2f2bcdf..3ac7f2b 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java @@ -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> 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> 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 topArticles(Integer siteId) { + private List topArticles(Integer siteId, int size) { List 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 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> buildLayoutBlocks(PortalSiteSnapshot site, Map homeLayout, + PortalUrlHelper url) { + if (homeLayout == null) { + return null; + } + Object raw = homeLayout.get("blocks"); + if (!(raw instanceof List)) { + return null; // 容错:blocks 非数组视为无布局 + } + Set treeIds = new java.util.HashSet<>(); + collectTreeIds(site.getCategories(), treeIds); + List> result = new ArrayList<>(); + for (Object o : (List) raw) { + if (!(o instanceof Map)) { + continue; + } + Map spec = (Map) o; + if (Boolean.FALSE.equals(spec.get("visible"))) { + continue; // 编辑器「隐藏」:保序下线 + } + Map block = buildLayoutBlock(site, spec, treeIds, url); + if (block != null) { + result.add(block); + } + } + return result; + } + + private void collectTreeIds(List categories, Set ids) { + for (PortalCategory c : categories) { + ids.add(c.getId()); + collectTreeIds(c.getChildren(), ids); + } + } + + private Map buildLayoutBlock(PortalSiteSnapshot site, Map spec, + Set 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 baseBlock(Map spec, String type, String fallbackTitle) { + Map 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 category = new HashMap<>(); + category.put("name", title); + block.put("category", category); + return block; + } + + /** + * 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入), + * 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。 + */ + private Map buildArticleListBlock(PortalSiteSnapshot site, Map spec, + Set treeIds, PortalUrlHelper url) { + List 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 items = categoryIds.isEmpty() + ? new ArrayList<>() + : articlesIn(site.getId(), categoryIds, size); + String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style")); + Map 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 buildTickerBlock(PortalSiteSnapshot site, Map spec, + Set treeIds) { + String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source")); + int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT); + List items; + if ("category".equals(source)) { + List 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 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 buildGalleryBlock(PortalSiteSnapshot site, Map spec, + PortalUrlHelper url) { + Map config = site.getConfig() == null ? new HashMap<>() : site.getConfig(); + Map 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 buildLinksBlock(PortalSiteSnapshot site, Map spec) { + String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim(); + List groups = new ArrayList<>(); + for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) { + if (group.isEmpty() || group.equals(g.getGroupName())) { + groups.add(g); + } + } + Map 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 intList(Object v) { + List 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 articlesIn(Integer siteId, List categoryIds, int size) { + List 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 items = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsArticle a : articles) { + items.add(toItem(a)); + } + return items; + } + + /** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */ + private PortalCategory findInTree(List 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()); diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java index 1bdb2e6..ad4f287 100644 --- a/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java +++ b/src/main/java/com/yunhai/cms/model/portal/PortalSiteSnapshot.java @@ -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 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 normalizeHomeLayout(Object raw) { + if (!(raw instanceof Map)) { + return null; + } + Object blocks = ((Map) raw).get("blocks"); + return blocks instanceof List ? (Map) raw : null; + } + /** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */ private static List splitAddressLines(String raw) { List 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; } diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index 41bc697..bcd44a3 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -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 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 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 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 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 + + + + + + + + + +

头条推荐

@@ -49,6 +59,7 @@

暂无内容

+
diff --git a/src/main/resources/templates/themes/campus-glow/fragments/home-blocks.html b/src/main/resources/templates/themes/campus-glow/fragments/home-blocks.html new file mode 100644 index 0000000..7a3132f --- /dev/null +++ b/src/main/resources/templates/themes/campus-glow/fragments/home-blocks.html @@ -0,0 +1,150 @@ + + +
+
+
+
+
+

校园时讯

+ + + +
+ +
+

栏目暂无内容

+

内容发布后将在此展示

+
+
+
+
+ + +
+
+
+
+

媒体聚焦

+ + + +
+ +
+

栏目暂无内容

+

内容发布后将在此展示

+
+
+
+
+ + +
+
+
+
+

栏目名

+ 更多 > +
+ +
+

栏目暂无内容

+

内容发布后将在此展示

+
+
+
+
+ + +
+
+ 要闻速递 + +
+
+ + + diff --git a/src/main/resources/templates/themes/campus-glow/home.html b/src/main/resources/templates/themes/campus-glow/home.html index ac7aa4c..b3b96a6 100644 --- a/src/main/resources/templates/themes/campus-glow/home.html +++ b/src/main/resources/templates/themes/campus-glow/home.html @@ -5,7 +5,7 @@
- +
- -
- -
+ + + + + + + + + + + + + + + + + + - - - -
-
-
-
-
-

校园时讯

- - + + + +
+
+ 要闻速递 +
+ - -
-

栏目暂无内容

-

内容发布后将在此展示

- -
-
-
-
-

媒体聚焦

- - - -
- -
-

栏目暂无内容

-

内容发布后将在此展示

-
-
-
-
+ + + + + + + + - -
+ + - - -
diff --git a/src/main/resources/templates/themes/classic-red/home.html b/src/main/resources/templates/themes/classic-red/home.html index 21bfb84..8d4f072 100644 --- a/src/main/resources/templates/themes/classic-red/home.html +++ b/src/main/resources/templates/themes/classic-red/home.html @@ -21,6 +21,16 @@ + + + + + + + + + +

头条推荐

@@ -49,6 +59,7 @@

暂无内容

+
diff --git a/src/main/resources/templates/themes/common/fragments/home-blocks.html b/src/main/resources/templates/themes/common/fragments/home-blocks.html new file mode 100644 index 0000000..952b001 --- /dev/null +++ b/src/main/resources/templates/themes/common/fragments/home-blocks.html @@ -0,0 +1,245 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

区块标题

+

副题

+
+ 更多 > +
+ + +
+

该区块暂无内容

+
+ + +
+ +
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+ 要闻速递 + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +

分组名

+ +
+
+
+
diff --git a/src/main/resources/templates/themes/default/home.html b/src/main/resources/templates/themes/default/home.html index 92a17b6..e8d41de 100644 --- a/src/main/resources/templates/themes/default/home.html +++ b/src/main/resources/templates/themes/default/home.html @@ -23,6 +23,16 @@ + + + + + + + + + +
@@ -110,6 +120,7 @@
+
diff --git a/src/main/resources/templates/themes/fresh-mint/home.html b/src/main/resources/templates/themes/fresh-mint/home.html index 4da07a1..aa4e0dc 100644 --- a/src/main/resources/templates/themes/fresh-mint/home.html +++ b/src/main/resources/templates/themes/fresh-mint/home.html @@ -21,6 +21,16 @@ + + + + + + + + + +

头条推荐

@@ -49,6 +59,7 @@

暂无内容

+
diff --git a/src/main/resources/templates/themes/mint-breeze/home.html b/src/main/resources/templates/themes/mint-breeze/home.html index 21bfb84..8d4f072 100644 --- a/src/main/resources/templates/themes/mint-breeze/home.html +++ b/src/main/resources/templates/themes/mint-breeze/home.html @@ -21,6 +21,16 @@ + + + + + + + + + +

头条推荐

@@ -49,6 +59,7 @@

暂无内容

+
diff --git a/src/main/resources/templates/themes/scholar-blue/home.html b/src/main/resources/templates/themes/scholar-blue/home.html index e5d9eaa..459e495 100644 --- a/src/main/resources/templates/themes/scholar-blue/home.html +++ b/src/main/resources/templates/themes/scholar-blue/home.html @@ -23,7 +23,16 @@ - + + + + + + + + + +
@@ -116,6 +125,7 @@
+
diff --git a/src/main/resources/templates/themes/warm-kinder/home.html b/src/main/resources/templates/themes/warm-kinder/home.html index 21bfb84..8d4f072 100644 --- a/src/main/resources/templates/themes/warm-kinder/home.html +++ b/src/main/resources/templates/themes/warm-kinder/home.html @@ -21,6 +21,16 @@ + + + + + + + + + +

头条推荐

@@ -49,6 +59,7 @@

暂无内容

+
diff --git a/src/test/java/com/yunhai/cms/portal/PortalHomeLayoutIT.java b/src/test/java/com/yunhai/cms/portal/PortalHomeLayoutIT.java new file mode 100644 index 0000000..b55deec --- /dev/null +++ b/src/test/java/com/yunhai/cms/portal/PortalHomeLayoutIT.java @@ -0,0 +1,183 @@ +package com.yunhai.cms.portal; + +import com.yunhai.cms.entity.CmsArticle; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.ArticleStatusEnum; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 首页布局渲染契约(docs/16 主线B §4.4/§4.5): + * ① 无 home_layout = 回落现状(categoryBlocks 原样,零迁移); + * ② 布局模式按 blocks 序列渲染(common 兜底骨架 plb-*)——顺序、多栏目聚合、size 上限、 + * visible:false 保序下线、ticker 双数据源、gallery 旧键默认值、links 分组; + * ③ campus-glow 主题自有 fragment(news_grid)在布局模式下生效; + * ④ 非法 home_layout 容错回落(脏数据不打挂门户)。 + */ +@DisplayName("Portal HomeLayout - 首页布局渲染(docs/16)") +class PortalHomeLayoutIT extends CmsIntegrationTestBase { + + @Autowired + private MockMvc mockMvc; + + private Integer catNews; + private Integer catNotice; + + @BeforeEach + void seed() { + purgeTables(); + CmsSite site = persistSite("layhome", 1, "布局测试门户"); + Integer siteId = site.getId(); + catNews = persistCategory(siteId, 0, "校园时讯", CategoryTypeEnum.LIST).getId(); + catNotice = persistCategory(siteId, 0, "通知公告", CategoryTypeEnum.LIST).getId(); + persistCategory(siteId, 0, "学校概况", CategoryTypeEnum.SINGLE); + + for (int i = 1; i <= 10; i++) { + persistArticle(siteId, catNews, "时讯文章" + i, ArticleStatusEnum.PUBLISHED, nowMinusSeconds(100L * i)); + } + // 通知文章带封面(focus-card 仅渲染有封面的已发布文章);时间不改变 ticker/compact 的最新排序 + CmsArticle n1 = persistArticle(siteId, catNotice, "通知一", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(50)); + n1.setCoverImage("/upload/cms/cover-n1.png"); + articleRepository.saveAndFlush(n1); + CmsArticle n2 = persistArticle(siteId, catNotice, "通知二", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(40)); + n2.setCoverImage("/upload/cms/cover-n2.png"); + articleRepository.saveAndFlush(n2); + persistArticle(siteId, catNotice, "通知三", ArticleStatusEnum.PUBLISHED, nowMinusSeconds(30)); + + persistBanner(siteId, "home", "首页轮播", null, null); + persistBanner(siteId, "gallery", "展厅作品一", null, null); + persistLink(siteId, "常用链接", "教育部"); + persistLink(siteId, "校内导航", "图书馆"); + clearCaches(); + } + + @AfterEach + void clean() { + purgeTables(); + } + + private String body(MvcResult result) throws java.io.UnsupportedEncodingException { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + private void setConfig(String code, String configJson) { + siteRepository.findByCode(code).ifPresent(s -> { + s.setConfigJson(configJson); + siteRepository.saveAndFlush(s); + }); + clearCaches(); + } + + private static int count(String html, String marker) { + int idx = 0, hits = 0; + while ((idx = html.indexOf(marker, idx)) >= 0) { + hits++; + idx += marker.length(); + } + return hits; + } + + @Test + @DisplayName("无 home_layout:回落现状渲染(categoryBlocks 原样,无布局骨架 markup)") + void fallback_withoutLayout() throws Exception { + String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn()); + assertThat(html).contains("category-block").contains("校园时讯").contains("通知公告"); + assertThat(html).contains("headline-list").contains("focus-card"); // 头条/图片新闻段保留 + assertThat(html).doesNotContain("plb-section"); + } + + @Test + @DisplayName("布局模式:按序列渲染(ticker置顶 → 双栏目聚合size2 → gallery旧键默认题 → links分组),隐藏块跳过") + void layoutMode_rendersSequence() throws Exception { + setConfig("layhome", "{\"home_layout\":{\"version\":1,\"blocks\":[" + + "{\"id\":\"b1\",\"type\":\"ticker\",\"title\":\"要闻速递\",\"size\":2}," + + "{\"id\":\"b2\",\"type\":\"article_list\",\"title\":\"要闻公告\",\"categoryIds\":[" + catNews + "," + catNotice + "],\"style\":\"compact\",\"size\":2}," + + "{\"id\":\"b3\",\"type\":\"article_list\",\"title\":\"隐藏块\",\"categoryIds\":[" + catNews + "],\"visible\":false}," + + "{\"id\":\"b4\",\"type\":\"gallery\"}," + + "{\"id\":\"b5\",\"type\":\"links\",\"group\":\"常用链接\"}" + + "]}}"); + + String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn()); + + // 顶部轮播(页面级固定槽位)保留 + assertThat(html).contains("首页轮播"); + // 回落段清空:头条/图片新闻/栏目区块不渲染 + assertThat(html).doesNotContain("headline-list").doesNotContain("category-block"); + // ticker:置顶优先+发布时间倒序取 size=2 条(无置顶 → 最新两篇) + assertThat(html).contains("plb-ticker").contains("要闻速递"); + assertThat(count(html, "plb-ticker-item")).as("ticker 取 size=2 条").isEqualTo(2); + // article_list:多栏目聚合 + compact 形态 + size=2;标题用区块 title 而非栏目名 + assertThat(html).contains("plb-section").contains("plb-compact"); + assertThat(html).contains("要闻公告"); + assertThat(html).doesNotContain("隐藏块"); + assertThat(count(html, "plb-row-title")).as("聚合双栏目取 size=2 条").isEqualTo(2); + assertThat(html).contains("通知三").contains("通知二"); + // gallery:区块未设标题 → 旧键缺省「线上展厅」;副题缺省英文 + assertThat(html).contains("plb-gallery").contains("线上展厅").contains("Online Exhibition Hall"); + assertThat(html).contains("展厅作品一"); + // links:仅指定分组(页脚友情链接为页面级内容,不受区块过滤影响——按区块列表计数断言) + assertThat(html).contains("plb-links").contains("教育部"); + assertThat(count(html, "
    ")).as("links 区块仅渲染指定分组").isEqualTo(1); + } + + @Test + @DisplayName("布局模式:article_list 未设标题时回退第一个栏目名;gallery 区块标题覆盖旧键") + void layoutMode_titleFallbacks() throws Exception { + setConfig("layhome", "{\"gallery_title\":\"旧展厅标题\",\"home_layout\":{\"version\":1,\"blocks\":[" + + "{\"id\":\"b1\",\"type\":\"article_list\",\"categoryIds\":[" + catNews + "],\"size\":1}," + + "{\"id\":\"b2\",\"type\":\"gallery\",\"title\":\"新展厅\",\"subtitle\":\"New Hall\"}" + + "]}}"); + String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn()); + // 区块无标题 → 第一个栏目名 + assertThat(html).contains("校园时讯"); + // 区块标题覆盖旧键 + assertThat(html).contains("新展厅").contains("New Hall"); + assertThat(html).doesNotContain("旧展厅标题"); + } + + @Test + @DisplayName("campus-glow 布局模式:article_list style=news_grid 走主题自有 fragment(背景+双栏)") + void glowTheme_layoutWithNewsGrid() throws Exception { + setConfig("layhome", "{\"news_bg_image\":\"/cms/upload/campus.jpg\",\"home_layout\":{\"version\":1,\"blocks\":[" + + "{\"id\":\"b1\",\"type\":\"article_list\",\"title\":\"校园要闻\",\"categoryIds\":[" + catNews + "],\"style\":\"news_grid\",\"size\":16,\"background\":true}" + + "]}}"); + siteRepository.findByCode("layhome").ifPresent(s -> { + s.setTheme("campus-glow"); + siteRepository.saveAndFlush(s); + }); + clearCaches(); + + String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn()); + // 主题自有 fragment:cg-newshot 形态 + 区块标题覆盖 + 背景图(开关生效) + assertThat(html).contains("cg-newshot").contains("cg-news-grid"); + assertThat(html).contains("校园要闻"); + assertThat(html).contains("campus.jpg"); + assertThat(count(html, "cg-news-title")).isEqualTo(10); + } + + @Test + @DisplayName("非法 home_layout(blocks 非数组/type 白名单外):容错回落现状,门户不打挂") + void invalidLayout_fallsBack() throws Exception { + setConfig("layhome", "{\"home_layout\":{\"blocks\":\"bad\"},\"logo\":\"/a.png\"}"); + String html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn()); + assertThat(html).contains("category-block").doesNotContain("plb-section"); + + setConfig("layhome", "{\"home_layout\":{\"blocks\":[{\"type\":\"richtext\"}]}}"); + html = body(mockMvc.perform(get("/portal/layhome/")).andExpect(status().isOk()).andReturn()); + // 未知 type 块被跳过;其余仍为布局模式(空 blocks 有效,但该配置 blocks=[未知] → 序列为空 → 仅骨架) + assertThat(html).doesNotContain("richtext"); + } +} diff --git a/src/test/java/com/yunhai/cms/service/impl/SiteServiceHomeLayoutIT.java b/src/test/java/com/yunhai/cms/service/impl/SiteServiceHomeLayoutIT.java new file mode 100644 index 0000000..a1f966c --- /dev/null +++ b/src/test/java/com/yunhai/cms/service/impl/SiteServiceHomeLayoutIT.java @@ -0,0 +1,143 @@ +package com.yunhai.cms.service.impl; + +import com.yunhai.cms.entity.CmsCategory; +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.enums.CategoryTypeEnum; +import com.yunhai.cms.model.dto.SiteSaveRequest; +import com.yunhai.cms.model.vo.SiteVo; +import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * home_layout 结构校验(docs/16 §4.6):validateConfigJson 对该键收紧—— + * 对象结构 / blocks 数组 ≤30 / type 白名单 / size 1~20 / 文本 ≤100 / + * categoryIds 正整数且属于本站栏目树(防跨站注入);其余非字符串键维持原样放行不回归。 + */ +@DisplayName("SiteService HomeLayout - home_layout 结构校验(docs/16 §4.6)") +class SiteServiceHomeLayoutIT extends CmsIntegrationTestBase { + + @Autowired + private SiteService siteService; + + @AfterEach + void clean() { + purgeTables(); + } + + private SiteSaveRequest baseReq(String code) { + SiteSaveRequest req = new SiteSaveRequest(); + req.setCode(code); + req.setSchId(1); + req.setSchName("第一中学"); + req.setName(code + "门户"); + return req; + } + + @Test + @DisplayName("合法 home_layout 保存成功并原样落库") + void save_validHomeLayout() { + SiteSaveRequest req = baseReq("lay1"); + req.setConfigJson("{\"home_layout\":{\"version\":1,\"blocks\":[" + + "{\"id\":\"b1\",\"type\":\"ticker\",\"title\":\"要闻速递\",\"size\":6}," + + "{\"id\":\"b2\",\"type\":\"article_list\",\"title\":\"校园时讯\",\"categoryIds\":[],\"style\":\"news_grid\",\"size\":16}" + + "]}}"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), req); + assertThat(siteService.configValue(siteService.getByCode("lay1"), "home_layout")).isNotNull(); + assertThat(vo.getId()).isNotNull(); + } + + @Test + @DisplayName("结构非法被拒:非对象 / blocks 非数组 / 未知 type / size 越界 / 标题超长 / 区块超量") + void save_invalidStructuresRejected() { + CmsSite site = persistSite("lay2", 1, "校验站"); + + assertReject(site.getId(), "{\"home_layout\":\"bad\"}", "JSON 对象"); + assertReject(site.getId(), "{\"home_layout\":{\"blocks\":\"bad\"}}", "数组"); + assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"richtext\"}]}}", "类型不合法"); + assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"ticker\",\"size\":0}]}}", "条数"); + assertReject(site.getId(), "{\"home_layout\":{\"blocks\":[{\"type\":\"ticker\",\"size\":21}]}}", "条数"); + StringBuilder longTitle = new StringBuilder(); + for (int i = 0; i < 101; i++) { + longTitle.append("长"); + } + assertReject(site.getId(), + "{\"home_layout\":{\"blocks\":[{\"type\":\"gallery\",\"title\":\"" + longTitle + "\"}]}}", "100"); + StringBuilder many = new StringBuilder("{\"home_layout\":{\"blocks\":["); + for (int i = 0; i < 31; i++) { + many.append("{\"type\":\"links\"}").append(i < 30 ? "," : ""); + } + many.append("]}}"); + assertReject(site.getId(), many.toString(), "30"); + + // 其余非字符串键(wechat 预留对象)仍原样放行,不回归 + SiteSaveRequest ok = new SiteSaveRequest(); + ok.setId(site.getId()); + ok.setCode("lay2"); + ok.setName("校验站"); + ok.setSchId(1); + ok.setConfigJson("{\"wechat\":{\"appid\":\"x\"}}"); + assertThat(siteService.save(TestUsers.superAdmin(), ok)).isNotNull(); + } + + @Test + @DisplayName("categoryIds:新建站拒绝引用;跨站栏目拒绝;本站合法栏目通过") + void save_categoryIdsOwnership() { + CmsSite mine = persistSite("lay3", 1, "本校站"); + persistSite("lay4", 2, "他校站"); + CmsCategory mineCat = persistCategory(mine.getId(), 0, "本校栏目", CategoryTypeEnum.LIST); + CmsCategory otherCat = persistCategory(siteRepository.findByCode("lay4").orElseThrow(IllegalStateException::new).getId(), + 0, "他站栏目", CategoryTypeEnum.LIST); + Integer mineCatId = mineCat.getId(); + Integer otherCatId = otherCat.getId(); + + // 新建站点带 categoryIds 拒绝(尚无栏目可引) + SiteSaveRequest create = baseReq("lay5"); + create.setConfigJson("{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[" + mineCatId + "]}]}}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), create)) + .isInstanceOf(BizException.class) + .hasMessageContaining("尚未创建"); + + // 跨站栏目注入拒绝 + assertReject(mine.getId(), + "{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[999999]}]}}", "不存在"); + SiteSaveRequest cross = new SiteSaveRequest(); + cross.setId(mine.getId()); + cross.setCode("lay3"); + cross.setName("本校站"); + cross.setSchId(1); + cross.setConfigJson("{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[" + + otherCatId + "]}]}}"); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), cross)) + .isInstanceOf(BizException.class) + .hasMessageContaining("不属于本站点"); + + // 本站合法栏目通过 + SiteSaveRequest own = new SiteSaveRequest(); + own.setId(mine.getId()); + own.setCode("lay3"); + own.setName("本校站"); + own.setSchId(1); + own.setConfigJson("{\"home_layout\":{\"blocks\":[{\"type\":\"article_list\",\"categoryIds\":[" + mineCatId + "],\"size\":8}]}}"); + assertThat(siteService.save(TestUsers.superAdmin(), own)).isNotNull(); + } + + private void assertReject(Integer siteId, String configJson, String messagePart) { + SiteSaveRequest req = new SiteSaveRequest(); + req.setId(siteId); + req.setCode(siteService.requireManageable(TestUsers.superAdmin(), siteId).getCode()); + req.setName("校验站"); + req.setSchId(1); + req.setConfigJson(configJson); + assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), req)) + .isInstanceOf(BizException.class) + .hasMessageContaining(messagePart); + } +}