[CMS-VISUAL] D1-D10 后端: 新组件数据槽(banner 扩 topic/event 位 5 列 + cms_person 新表+CRUD+防跨站)+ 6 区块类型校验与装配(空数据整块不渲染 R-44)+ 骨架新组件 fragment/内置 22 图标集/共享动效 JS(reveal+count-up R-42)+ searchStyle 搜索浮层(D-10,含坑④条件包裹修复)+ 三保留主题 4 新参数

This commit is contained in:
chenxing
2026-09-28 14:04:19 +08:00
parent 089cb54011
commit 4cb435b715
23 changed files with 1673 additions and 52 deletions
@@ -68,11 +68,11 @@ public class AttachmentService {
/**
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
* category=单页栏目贴图。svc_item/svc_item_asset 两个事项场景已随事项库迁出办事大厅
* (V2.0,见 docs/19 §九),CMS 侧下线。
* category=单页栏目贴图 / person=名师头像(docs/20 D-7)。svc_item/svc_item_asset 两个事项场景
* 已随事项库迁出办事大厅(V2.0,见 docs/19 §九),CMS 侧下线。
*/
private static final List<String> ALLOWED_SCENES =
Arrays.asList("banner", "link", "site", "category");
Arrays.asList("banner", "link", "site", "category", "person");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@@ -81,6 +81,12 @@ public class BannerService {
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
}
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
// docs/20 D-3/D-6:topic/event 位扩展字段透传(其余位一并落库,渲染端按位消费)
banner.setDescription(req.getDescription());
banner.setCtaText(req.getCtaText());
banner.setCtaUrl(req.getCtaUrl());
banner.setEventDate(req.getEventDate());
banner.setLocation(req.getLocation());
banner = bannerRepository.save(banner);
// B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等
attachmentService.release("banner", banner.getId());
@@ -0,0 +1,94 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsPerson;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.PersonSaveRequest;
import com.yunhai.cms.model.vo.PersonVo;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsPersonRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
/**
* 名师风采人物管理(docs/20 D-7):站点级 CRUD + people 区块数据源。
* 头像附件引用走 scene=person(B2 模型);categoryId 须属于本站点(防跨站注入)。
*/
@Service
@RequiredArgsConstructor
public class PersonService {
private final CmsPersonRepository personRepository;
private final CmsCategoryRepository categoryRepository;
private final SiteService siteService;
private final AttachmentService attachmentService;
private final ApplicationEventPublisher eventPublisher;
public List<PersonVo> list(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
return personRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId).stream()
.map(PersonVo::of)
.collect(Collectors.toList());
}
@Transactional
public PersonVo save(BoaUser user, PersonSaveRequest req) {
siteService.requireManageable(user, req.getSiteId());
if (req.getCategoryId() != null) {
CmsCategory category = categoryRepository.findById(req.getCategoryId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "所属栏目不存在"));
if (!category.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "所属栏目不属于本站点");
}
}
CmsPerson person;
if (req.getId() == null) {
person = new CmsPerson();
person.setSiteId(req.getSiteId());
} else {
person = personRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "人物不存在"));
if (!person.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该人物");
}
}
person.setName(req.getName());
person.setTitle(req.getTitle());
person.setBio(req.getBio());
person.setAvatar(req.getAvatar());
person.setCategoryId(req.getCategoryId());
person.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
person.setStatus(req.getStatus() == null ? 1 : req.getStatus());
person = personRepository.save(person);
// B2 附件引用:先解引用再回写新头像(编辑换图时旧图回未使用);幂等
attachmentService.release("person", person.getId());
if (StringUtils.hasText(req.getAvatar())) {
attachmentService.markUsed(req.getSiteId(), req.getAvatar(), "person", person.getId());
}
eventPublisher.publishEvent(new SiteContentChangedEvent(req.getSiteId(), "person"));
return PersonVo.of(person);
}
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsPerson person = personRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "人物不存在"));
siteService.requireManageable(user, req.getSiteId());
if (!person.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该人物");
}
personRepository.delete(person);
attachmentService.release("person", person.getId());
eventPublisher.publishEvent(new SiteContentChangedEvent(req.getSiteId(), "person"));
}
}
@@ -60,10 +60,12 @@ 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 + v2 手册 §2.8)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
/** home_layout(docs/16 §4.6 + v2 手册 §2.8 + docs/20 D 组件库扩展)结构约束:类型白名单与规模上限(与前端 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", "quick_links", "notice_bar")));
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar",
// docs/20 D-1~D-9 新组件
"stats_band", "featured_topics", "video_tour", "events", "people", "badges")));
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;
@@ -79,6 +81,18 @@ public class SiteService {
/** notice_bar 提示级别白名单(缺省 info) */
private static final Set<String> HOME_LAYOUT_NOTICE_LEVELS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("info", "warn")));
// ===== docs/20 D 组件库新增约束 =====
/** stats_band.stats 项数上限 */
private static final int HOME_LAYOUT_MAX_STATS = 8;
/** featured_topics.items 项数上限 */
private static final int HOME_LAYOUT_MAX_TOPICS = 6;
/** badges.items 项数上限 */
private static final int HOME_LAYOUT_MAX_BADGES = 8;
/** 区块内图标标识符(D-2 内置图标集 key) */
private static final Pattern HOME_LAYOUT_ICON_PATTERN = Pattern.compile("^[a-z0-9-]{1,30}$");
/** gallery 布局形态(D-8 campus_wall) */
private static final Set<String> HOME_LAYOUT_GALLERY_LAYOUTS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("grid", "masonry")));
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
@@ -405,6 +419,20 @@ public class SiteService {
validateQuickLinksBlock(block);
} else if ("notice_bar".equals(type.asText())) {
validateNoticeBarBlock(block);
} else if ("stats_band".equals(type.asText())) {
validateStatsBandBlock(block);
} else if ("featured_topics".equals(type.asText())) {
validateFeaturedTopicsBlock(block);
} else if ("video_tour".equals(type.asText())) {
validateVideoTourBlock(block);
} else if ("badges".equals(type.asText())) {
validateBadgesBlock(block);
} else if ("gallery".equals(type.asText())) {
JsonNode layout = block.get("layout");
if (layout != null && (!layout.isTextual()
|| !HOME_LAYOUT_GALLERY_LAYOUTS.contains(layout.asText()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "gallery layout 只能是 grid 或 masonry");
}
}
JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
@@ -429,6 +457,14 @@ public class SiteService {
referencedIds.add(id.intValue());
}
}
// people 区块(docs/20 D-7):categoryId 单值,同走跨站注入防护
JsonNode categoryId = block.get("categoryId");
if (categoryId != null) {
if (!categoryId.isNumber() || categoryId.intValue() <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局 categoryId 必须为正整数");
}
referencedIds.add(categoryId.intValue());
}
}
if (!referencedIds.isEmpty()) {
if (siteId == null) {
@@ -447,7 +483,7 @@ public class SiteService {
}
}
/** quick_links(v2 手册 §2.8 类型 A):items 必填、1~12 项,每项 {name ≤20 字, url 合法} */
/** quick_links(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):items 必填、1~12 项,每项 {name ≤20 字, url 合法, icon 可选标识符} */
private void validateQuickLinksBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null) {
@@ -474,6 +510,12 @@ public class SiteService {
"quick_links 项 name 须为不超过 " + HOME_LAYOUT_MAX_LINK_NAME + " 字的文本");
}
requireLayoutLink(item.get("url"), "quick_links 项");
JsonNode icon = item.get("icon");
if (icon != null && (!icon.isTextual() || icon.asText().isEmpty()
|| !HOME_LAYOUT_ICON_PATTERN.matcher(icon.asText()).matches())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links 项 icon 须为内置图标标识符(小写字母/数字/短横线,如 zhaosheng)");
}
}
}
@@ -496,7 +538,7 @@ public class SiteService {
}
}
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url) */
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url / featured_topics ctaUrl / video_tour url) */
private void requireLayoutLink(JsonNode url, String what) {
if (url == null || !url.isTextual() || !HOME_LAYOUT_LINK_PATTERN.matcher(url.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
@@ -504,6 +546,118 @@ public class SiteService {
}
}
/** 区块内图片资产校验(站点上传路径 / 开头或 http(s) 地址) */
private void requireLayoutAsset(JsonNode asset, String what) {
if (asset == null || !asset.isTextual() || !ASSET_PATTERN.matcher(asset.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " 须为站点上传路径(/ 开头)或 http(s) 地址");
}
}
/** stats_band(docs/20 D-1):stats 数组 1~8 项,每项 {label ≤20, value ≤10, unit ≤10, icon 可选} */
private void validateStatsBandBlock(JsonNode block) {
JsonNode stats = block.get("stats");
if (stats == null || !stats.isArray() || stats.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 区块必须填写 stats(统计项数组)");
}
if (stats.size() > HOME_LAYOUT_MAX_STATS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"stats_band stats 不能超过 " + HOME_LAYOUT_MAX_STATS + " 项");
}
for (JsonNode item : stats) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band stats 每项必须是对象({label, value, unit})");
}
JsonNode label = item.get("label");
if (label == null || !label.isTextual() || label.asText().trim().isEmpty()
|| label.asText().trim().length() > 20) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 label 须为不超过 20 字的文本");
}
JsonNode value = item.get("value");
if (value == null || !value.isTextual() || value.asText().trim().isEmpty()
|| value.asText().trim().length() > 10) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 value 须为不超过 10 字的数字文本(如 3.2 万)");
}
JsonNode unit = item.get("unit");
if (unit != null && (!unit.isTextual() || unit.asText().length() > 10)) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 unit 须为不超过 10 字的文本");
}
JsonNode icon = item.get("icon");
if (icon != null && (!icon.isTextual() || icon.asText().isEmpty()
|| !HOME_LAYOUT_ICON_PATTERN.matcher(icon.asText()).matches())) {
throw new BizException(ErrorCode.PARAM_ERROR, "stats_band 项 icon 须为内置图标标识符");
}
}
}
/** featured_topics(docs/20 D-3):items 1~6 项,每项 {title ≤50, description ≤200, image 资产, ctaText ≤20, ctaUrl 链接} */
private void validateFeaturedTopicsBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null || !items.isArray() || items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 区块必须填写 items(专题卡数组)");
}
if (items.size() > HOME_LAYOUT_MAX_TOPICS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"featured_topics items 不能超过 " + HOME_LAYOUT_MAX_TOPICS + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics items 每项必须是对象");
}
JsonNode title = item.get("title");
if (title == null || !title.isTextual() || title.asText().trim().isEmpty()
|| title.asText().trim().length() > 50) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 title 须为不超过 50 字的文本");
}
JsonNode description = item.get("description");
if (description != null && (!description.isTextual() || description.asText().length() > 200)) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 description 不能超过 200 字");
}
requireLayoutAsset(item.get("image"), "featured_topics 项 image");
JsonNode ctaText = item.get("ctaText");
if (ctaText != null && (!ctaText.isTextual() || ctaText.asText().length() > 20)) {
throw new BizException(ErrorCode.PARAM_ERROR, "featured_topics 项 ctaText 不能超过 20 字");
}
if (item.has("ctaUrl")) {
requireLayoutLink(item.get("ctaUrl"), "featured_topics 项");
}
}
}
/** video_tour(docs/20 D-5):cover 资产 + url 链接 + title ≤100 */
private void validateVideoTourBlock(JsonNode block) {
requireLayoutAsset(block.get("cover"), "video_tour cover");
requireLayoutLink(block.get("url"), "video_tour");
}
/** badges(docs/20 D-9):badgeItems 1~8 项,每项 {label ≤20, desc ≤50, image 资产}
* (键名与前端 cleanBlock 序列化一致——避免与 quick_links/featured_topics 的 items 语义混淆) */
private void validateBadgesBlock(JsonNode block) {
JsonNode items = block.get("badgeItems");
if (items == null || !items.isArray() || items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 区块必须填写 items(徽章数组)");
}
if (items.size() > HOME_LAYOUT_MAX_BADGES) {
throw new BizException(ErrorCode.PARAM_ERROR,
"badges items 不能超过 " + HOME_LAYOUT_MAX_BADGES + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges items 每项必须是对象");
}
JsonNode label = item.get("label");
if (label == null || !label.isTextual() || label.asText().trim().isEmpty()
|| label.asText().trim().length() > 20) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 项 label 须为不超过 20 字的文本");
}
JsonNode desc = item.get("desc");
if (desc != null && (!desc.isTextual() || desc.asText().length() > 50)) {
throw new BizException(ErrorCode.PARAM_ERROR, "badges 项 desc 不能超过 50 字");
}
requireLayoutAsset(item.get("image"), "badges 项 image");
}
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、