[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:
@@ -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}(外链样式注入)、
|
||||
|
||||
Reference in New Issue
Block a user