feat(portal): 门户左侧栏目导航+首页视觉升级+主题正名批次-PortalSideNav模型+attachCategoryNavigation注入doCategory/doArticle三件套categoryPath/navTopId/sideNav(站点快照树推导零新增查询,home/search不注入);7主题侧栏两栏markup+窄屏横滚+顶导航祖先高亮+二级下拉当前项高亮+面包屑全路径;cms_category.home_style全链路(news_grid 16条8x2日期前缀/media_focus形态)+PortalListData.Item补source来源徽标四处装配;Banner机制gallery展示位→galleryBanners四列大图轮播(自由字符串零DDL);页脚config 12新键(footer_addresses多校区/qr_*/social_*含URL校验,footerAddressLines由PortalSiteSnapshot.of拆行派生);新增展示型皮肤包campus-glow;fresh-mint2正名fresh-mint(theme.json/头注释同步);AttachmentService scene白名单加category(单页富文本贴图免清孤儿);新增PortalSideNavIT 8+PortalHomeBlocksIT 3,CategoryServiceIT/SiteServiceIT/PortalSsrIT/ThemeContractIT等单类独跑全绿;附sql/clear_cms_data.sql全量清库脚本(清cms_*六表留平台账号/主题包/上传文件,测试数据重走用)
build / build-push (push) Successful in 1m47s

This commit is contained in:
chenxing
2026-09-10 11:10:48 +08:00
parent f4b1749377
commit 98d640faba
77 changed files with 2564 additions and 175 deletions
@@ -26,7 +26,7 @@ public class AttachmentController {
private final AttachmentService attachmentService;
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site/category(category=单页栏目贴图),B2 初步标记) */
@PostMapping("/upload")
public AttachmentVo upload(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@@ -133,6 +133,8 @@ public class PortalController {
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", categoryId);
model.addAttribute("category", category);
// 栏目导航三件套(docs/11):面包屑全路径 + 顶导航祖先高亮 + 左侧兄弟侧栏
pageRenderer.attachCategoryNavigation(site, categoryId, model);
model.addAttribute("pageTitle", seoTitle(category, site));
model.addAttribute("seoDescription", category.getSeoDescription());
model.addAttribute("canonical", url.category(categoryId));
@@ -198,6 +200,8 @@ public class PortalController {
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
// 栏目导航三件套(docs/11):详情页同栏目页规则;栏目已删时 path 空,模板按缺省形态渲染
pageRenderer.attachCategoryNavigation(site, detail.getCategoryId(), model);
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
model.addAttribute("article", detail.withContent(
@@ -356,6 +360,7 @@ public class PortalController {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -5,6 +5,7 @@ import com.yunhai.cms.enums.LinkTypeEnum;
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.PortalSideNav;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.utils.PortalUrlHelper;
@@ -28,8 +29,9 @@ import java.util.Map;
@RequiredArgsConstructor
public class PortalPageRenderer {
/** 首页每个一级栏目取 N 条 */
/** 首页每个一级栏目取 N 条(docs/12:双栏新闻卡形态需 16 条铺满 8 行 × 2 列) */
private static final int HOME_BLOCK_SIZE = 8;
private static final int HOME_BLOCK_GRID_SIZE = 16;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
@@ -46,6 +48,7 @@ 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));
@@ -63,6 +66,71 @@ public class PortalPageRenderer {
// ===== 内部装配(自 PortalController 原样迁移)=====
/**
* 栏目导航装配(docs/11):栏目页/详情页共用,三条模型属性——
* categoryPath(顶级→当前路径,面包屑全路径)、navTopId(顶导航祖先高亮)、
* sideNav(模块有二级子栏目时的左侧兄弟导航,无则不挂,模板 th:if 兜底)。
* 全部基于站点快照树推导,零新增查询;搜索/首页等无栏目上下文的路由不调用。
*/
public void attachCategoryNavigation(PortalSiteSnapshot site, Integer currentCategoryId, Model model) {
if (currentCategoryId == null) {
return;
}
List<PortalCategory> path = pathFromRoot(site.getCategories(), currentCategoryId);
model.addAttribute("categoryPath", path);
if (path.isEmpty()) {
return; // 栏目已删等兜底(详情页可能挂已删栏目文章),模板按缺省形态渲染
}
PortalCategory top = path.get(0);
model.addAttribute("navTopId", top.getId());
if (!top.getChildren().isEmpty()) {
PortalSideNav nav = new PortalSideNav();
nav.setTop(top);
nav.setItems(top.getChildren());
nav.setActiveId(resolveActiveId(path, currentCategoryId));
model.addAttribute("sideNav", nav);
}
}
/**
* 侧栏高亮位:当前为二级=当前栏目;一级直点=null(侧栏头即模块名,无高亮项);
* 三级及更深=所在二级分支(决策点②:固定列二级层,高亮当前所属分支)。
*/
private Integer resolveActiveId(List<PortalCategory> path, Integer currentCategoryId) {
if (path.size() == 2) {
return currentCategoryId;
}
return path.size() > 2 ? path.get(1).getId() : null;
}
/** 自栏目树求 根→目标 路径(含两端);目标不存在返回空表 */
private List<PortalCategory> pathFromRoot(List<PortalCategory> roots, Integer categoryId) {
for (PortalCategory root : roots) {
List<PortalCategory> hit = pathFromRootDeep(root, categoryId);
if (hit != null) {
return hit;
}
}
return new ArrayList<>();
}
private List<PortalCategory> pathFromRootDeep(PortalCategory node, Integer categoryId) {
if (node.getId().equals(categoryId)) {
List<PortalCategory> path = new ArrayList<>();
path.add(node);
return path;
}
for (PortalCategory child : node.getChildren()) {
List<PortalCategory> sub = pathFromRootDeep(child, categoryId);
if (sub != null) {
sub.add(0, node);
return sub;
}
}
return null;
}
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
PortalUrlHelper url) {
@@ -137,6 +205,7 @@ public class PortalPageRenderer {
/**
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
* docs/12:区块带出栏目 homeStyle,双栏新闻卡(news_grid)取 16 条铺满双栏,其余取 8 条。
*/
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
PortalUrlHelper url) {
@@ -145,12 +214,14 @@ public class PortalPageRenderer {
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
continue;
}
int blockSize = "news_grid".equals(category.getHomeStyle())
? HOME_BLOCK_GRID_SIZE : HOME_BLOCK_SIZE;
List<Integer> categoryIds = new ArrayList<>();
collectListDescendantIds(category, categoryIds);
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, HOME_BLOCK_SIZE,
PageRequest.of(0, blockSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
@@ -159,6 +230,7 @@ public class PortalPageRenderer {
}
Map<String, Object> block = new HashMap<>();
block.put("category", category);
block.put("homeStyle", category.getHomeStyle());
block.put("items", items);
block.put("moreUrl", url.category(category.getId()));
blocks.add(block);
@@ -182,6 +254,7 @@ public class PortalPageRenderer {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -42,6 +42,13 @@ public class CmsCategory extends BaseEntity {
@Column(name = "template", length = 64)
private String template;
/**
* 首页展示形态(docs/11,空=主题默认卡):news_grid=双栏新闻卡、media_focus=来源标签列表。
* 仅一级列表型栏目在首页生效;旧主题不消费该字段(仅条数外溢 16)。
*/
@Column(name = "home_style", length = 32)
private String homeStyle;
/**
* 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。
* 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。
@@ -38,6 +38,10 @@ public class CategorySaveRequest {
@Size(max = 64)
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus,服务端白名单校验) */
@Size(max = 32)
private String homeStyle;
/** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */
private String content;
@@ -25,6 +25,9 @@ public class PortalCategory {
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
private String homeStyle;
private String seoTitle;
private String seoDescription;
@@ -42,6 +45,7 @@ public class PortalCategory {
node.setType(c.getType());
node.setLinkUrl(c.getLinkUrl());
node.setTemplate(c.getTemplate());
node.setHomeStyle(c.getHomeStyle());
node.setSeoTitle(c.getSeoTitle());
node.setSeoDescription(c.getSeoDescription());
node.setContent(c.getContent());
@@ -60,6 +64,7 @@ public class PortalCategory {
copy.type = type;
copy.linkUrl = linkUrl;
copy.template = template;
copy.homeStyle = homeStyle;
copy.seoTitle = seoTitle;
copy.seoDescription = seoDescription;
copy.content = content;
@@ -37,6 +37,9 @@ public class PortalListData {
private String coverImage;
/** 来源(转载出处,docs/11:媒体聚焦来源徽标用;可空) */
private String source;
private LocalDateTime publishTime;
private Integer topmost;
@@ -0,0 +1,23 @@
package com.yunhai.cms.model.portal;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 门户左侧栏目导航(docs/11):模块存在二级子栏目时,栏目页/详情页左侧
* 展示"模块名 + 全部二级子栏目",当前位高亮。纯展示模型,由站点快照树推导。
*/
@Data
public class PortalSideNav {
/** 顶级模块(侧栏头) */
private PortalCategory top;
/** 全部二级子栏目(快照序,列表/单页/外链型均列出) */
private List<PortalCategory> items = new ArrayList<>();
/** 当前高亮栏目 id:当前为二级时=当前栏目;三级及更深时=所在二级分支;一级直点=null */
private Integer activeId;
}
@@ -63,6 +63,12 @@ public class PortalSiteSnapshot {
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
private String customCss = "";
/**
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
* 规避模板内拆分的表达式平台差异)。
*/
private List<String> footerAddressLines = new ArrayList<>();
public static PortalSiteSnapshot of(CmsSite site) {
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
snapshot.setId(site.getId());
@@ -78,9 +84,25 @@ 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.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
return snapshot;
}
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
private static List<String> splitAddressLines(String raw) {
List<String> lines = new ArrayList<>();
if (raw == null || raw.trim().isEmpty()) {
return lines;
}
for (String line : raw.split("\r?\n")) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
lines.add(trimmed);
}
}
return lines;
}
/**
* 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss
* 的操作由预览方在拷贝上进行,不影响缓存中的原快照。
@@ -104,6 +126,7 @@ public class PortalSiteSnapshot {
copy.setThemeParams(themeParams);
copy.setThemeVarsCss(themeVarsCss);
copy.setCustomCss(customCss);
copy.setFooterAddressLines(footerAddressLines);
return copy;
}
}
@@ -27,6 +27,9 @@ public class CategoryTreeNode {
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
private String homeStyle;
/** 单页栏目内容(type=2 时编辑/渲染用) */
private String content;
@@ -50,6 +53,7 @@ public class CategoryTreeNode {
node.setType(c.getType());
node.setLinkUrl(c.getLinkUrl());
node.setTemplate(c.getTemplate());
node.setHomeStyle(c.getHomeStyle());
node.setContent(c.getContent());
node.setSortNo(c.getSortNo());
node.setSeoTitle(c.getSeoTitle());
@@ -56,8 +56,8 @@ public class AttachmentService {
private static final List<String> IMAGE_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 */
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site");
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 / category=单页栏目贴图 */
private static final List<String> ALLOWED_SCENES = Arrays.asList("banner", "link", "site", "category");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@@ -69,12 +69,12 @@ public class AttachmentService {
@Value("${FILE_WEB_PATH:/cms/upload/}")
private String fileWebPath;
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site,B2 初步标记) */
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category,B2 初步标记) */
@Transactional
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
CmsSite site = siteService.requireManageable(user, siteId);
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site)");
throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site/category)");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
@@ -33,6 +33,10 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class CategoryService {
/** 首页展示形态白名单(docs/11;空=主题默认卡) */
private static final java.util.Set<String> HOME_STYLE_VALUES =
new java.util.HashSet<>(java.util.Arrays.asList("news_grid", "media_focus"));
private final CmsCategoryRepository categoryRepository;
private final CmsArticleRepository articleRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
@@ -70,6 +74,10 @@ public class CategoryService {
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
}
if (StringUtils.hasText(req.getHomeStyle()) && !HOME_STYLE_VALUES.contains(req.getHomeStyle())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"首页展示形态非法:" + req.getHomeStyle() + "(仅支持 news_grid / media_focus)");
}
CmsCategory category;
if (req.getId() == null) {
category = new CmsCategory();
@@ -87,6 +95,7 @@ public class CategoryService {
category.setType(type.getCode());
category.setLinkUrl(req.getLinkUrl());
category.setTemplate(req.getTemplate());
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : null);
category.setContent(HtmlSanitizer.clean(req.getContent()));
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
category.setSeoTitle(req.getSeoTitle());
@@ -194,6 +194,7 @@ public class PortalQueryService {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
data.getItems().add(item);
@@ -246,6 +247,7 @@ public class PortalQueryService {
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
@@ -287,7 +287,8 @@ public class SiteService {
/**
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
* ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。
* ③ 已知键按类型校验(domain 域名 / public_base_url、social_* 完整 URL /
* logo、favicon、qr_wechat、qr_weibo、news_bg_image 资产路径,docs/12)。
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
*/
@@ -337,6 +338,10 @@ public class SiteService {
}
break;
case "public_base_url":
case "social_wechat":
case "social_weibo":
case "social_douyin":
case "social_bilibili":
if (!URL_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"对外基地址必须是完整的 http(s):// 地址:" + text);
@@ -344,6 +349,9 @@ public class SiteService {
break;
case "logo":
case "favicon":
case "qr_wechat":
case "qr_weibo":
case "news_bg_image":
if (!ASSET_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");