diff --git a/src/main/java/com/yunhai/cms/controller/api/PersonController.java b/src/main/java/com/yunhai/cms/controller/api/PersonController.java new file mode 100644 index 0000000..9f99dde --- /dev/null +++ b/src/main/java/com/yunhai/cms/controller/api/PersonController.java @@ -0,0 +1,43 @@ +package com.yunhai.cms.controller.api; + +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.service.impl.PersonService; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +/** + * 名师风采人物管理(docs/20 D-7,people 区块数据源)。 + */ +@RestController +@RequestMapping("/api/person") +@RequiredArgsConstructor +public class PersonController { + + private final PersonService personService; + + @GetMapping("/list") + public List list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) { + return personService.list(authUserWithDept, siteId); + } + + @PostMapping("/save") + public PersonVo save(BoaUser authUserWithDept, @Valid @RequestBody PersonSaveRequest request) { + return personService.save(authUserWithDept, request); + } + + @PostMapping("/delete") + public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) { + personService.delete(authUserWithDept, request); + } +} 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 ced5540..07fcd69 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java @@ -42,6 +42,8 @@ public class PortalPageRenderer { private final CmsArticleRepository articleRepository; private final ThemeRegistry themeRegistry; + /** docs/20 D-7:people 区块数据源 */ + private final com.yunhai.cms.repository.CmsPersonRepository personRepository; /** * M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html @@ -348,13 +350,27 @@ public class PortalPageRenderer { return buildQuickLinksBlock(spec); case "notice_bar": return buildNoticeBarBlock(spec); + // ===== docs/20 D-1~D-9 新组件(空数据返回 null = 整块不渲染,R-44)===== + case "stats_band": + return buildStatsBandBlock(spec); + case "featured_topics": + return buildFeaturedTopicsBlock(site, spec, url); + case "video_tour": + return buildVideoTourBlock(spec, url); + case "events": + return buildEventsBlock(site, spec, url); + case "people": + return buildPeopleBlock(site, spec, treeIds); + case "badges": + return buildBadgesBlock(spec); default: return null; // 白名单外的脏数据跳过,不打挂渲染 } } /** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题)。 - * F2(docs/20):透传区块 id(编辑器实时预览 postMessage 重排的 DOM 锚点 data-plb-id)。 */ + * F2(docs/20):透传区块 id(编辑器实时预览 postMessage 重排的 DOM 锚点 data-plb-id)。 + * D-4(docs/20):subtitle 全类型通用(中英双语栏目标题——主题 titleStyle 参数控制展示开关)。 */ private Map baseBlock(Map spec, String type, String fallbackTitle) { Map block = new HashMap<>(); block.put("type", type); @@ -366,6 +382,10 @@ public class PortalPageRenderer { title = fallbackTitle; } block.put("title", title); + String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim(); + if (!subtitle.isEmpty()) { + block.put("subtitle", subtitle); + } Map category = new HashMap<>(); category.put("name", title); block.put("category", category); @@ -454,7 +474,11 @@ public class PortalPageRenderer { if (subtitle.isEmpty()) { subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall"); } - block.put("subtitle", subtitle); + if (!subtitle.isEmpty()) { + block.put("subtitle", subtitle); + } + // D-8(docs/20):campus_wall 布局开关(grid 规整网格 / masonry 瀑布流) + block.put("layout", "masonry".equals(str(spec.get("layout"), "")) ? "masonry" : "grid"); block.put("items", activeBanners(site, "gallery", url)); return block; } @@ -475,8 +499,8 @@ public class PortalPageRenderer { } /** - * 快捷入口宫格(v2 手册 §2.8 类型 A):数据是区块内联数组,不读任何外部表。 - * 保存期已校验 url 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。 + * 快捷入口宫格(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):数据是区块内联数组,不读任何外部表。 + * 保存期已校验 url/icon 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。 */ private Map buildQuickLinksBlock(Map spec) { List> links = new ArrayList<>(); @@ -495,6 +519,11 @@ public class PortalPageRenderer { Map link = new HashMap<>(); link.put("name", name); link.put("url", linkUrl); + // D-2:可选图标标识符(未选时骨架渲染首字徽章兜底) + String icon = str(item.get("icon"), "").trim(); + if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) { + link.put("icon", icon); + } links.add(link); } } @@ -516,8 +545,214 @@ public class PortalPageRenderer { return block; } + // ===== docs/20 D 新组件装配(空数据返回 null → buildLayoutBlocks 跳过 = 整块不渲染,R-44)===== + + /** stats_band(D-1 数字计数带):stats 内联数组透传(骨架共享 count-up 动画);空/无有效项 → 不渲染 */ + private Map buildStatsBandBlock(Map spec) { + List> stats = new ArrayList<>(); + Object raw = spec.get("stats"); + if (raw instanceof List) { + for (Object o : (List) raw) { + if (!(o instanceof Map)) { + continue; + } + Map item = (Map) o; + String label = str(item.get("label"), "").trim(); + String value = str(item.get("value"), "").trim(); + if (label.isEmpty() || value.isEmpty()) { + continue; + } + Map stat = new HashMap<>(); + stat.put("label", label); + stat.put("value", value); + String unit = str(item.get("unit"), "").trim(); + if (!unit.isEmpty()) { + stat.put("unit", unit); + } + String icon = str(item.get("icon"), "").trim(); + if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) { + stat.put("icon", icon); + } + stats.add(stat); + } + } + if (stats.isEmpty()) { + return null; + } + Map block = baseBlock(spec, "stats_band", "数说学校"); + block.put("stats", stats); + return block; + } + + /** featured_topics(D-3 专题大卡):数据 = banner 表 topic 展示位(描述/CTA 扩展列);无在展数据 → 不渲染 */ + private Map buildFeaturedTopicsBlock(PortalSiteSnapshot site, Map spec, + PortalUrlHelper url) { + List> items = new ArrayList<>(); + for (PortalBanner b : site.getBanners()) { + if (!"topic".equals(b.getPosition()) || !isActiveNow(b)) { + continue; + } + Map item = new HashMap<>(); + item.put("title", b.getTitle()); + item.put("imageUrl", url.upload(b.getImageUrl())); + item.put("linkUrl", resolveBannerLink(b, url)); + if (hasText(b.getDescription())) { + item.put("description", b.getDescription()); + } + if (hasText(b.getCtaText())) { + item.put("ctaText", b.getCtaText()); + } + if (hasText(b.getCtaUrl())) { + item.put("ctaUrl", b.getCtaUrl()); + } + items.add(item); + } + if (items.isEmpty()) { + return null; + } + Map block = baseBlock(spec, "featured_topics", "专题推荐"); + block.put("items", items); + return block; + } + + /** video_tour(D-5 视频/VR 校园):cover 资产补基准 + url + title;脏数据(缺 cover/url)→ 不渲染 */ + private Map buildVideoTourBlock(Map spec, PortalUrlHelper url) { + String cover = str(spec.get("cover"), "").trim(); + String videoUrl = str(spec.get("url"), "").trim(); + if (!ASSET_URL_PATTERN.matcher(cover).matches() || !LAYOUT_LINK_URL.matcher(videoUrl).matches()) { + return null; + } + Map block = baseBlock(spec, "video_tour", "云端校园"); + block.put("cover", url.upload(cover)); + block.put("videoUrl", videoUrl); + return block; + } + + /** events(D-6 学术日历/活动):数据 = banner 表 event 展示位(event_date/location 扩展列);无在展数据 → 不渲染 */ + private Map buildEventsBlock(PortalSiteSnapshot site, Map spec, + PortalUrlHelper url) { + List events = new ArrayList<>(); + for (PortalBanner b : site.getBanners()) { + if ("event".equals(b.getPosition()) && isActiveNow(b)) { + events.add(b); + } + } + // 活动日历语义:按活动日期升序(无日期的排后按 sortNo),最多 6 条 + events.sort(java.util.Comparator + .comparing((PortalBanner b) -> b.getEventDate() == null ? java.time.LocalDateTime.MAX : b.getEventDate()) + .thenComparing(PortalBanner::getSortNo, java.util.Comparator.nullsLast(Integer::compareTo))); + if (events.size() > 6) { + events = events.subList(0, 6); + } + if (events.isEmpty()) { + return null; + } + List> items = new ArrayList<>(); + for (PortalBanner b : events) { + Map item = new HashMap<>(); + item.put("title", b.getTitle()); + item.put("linkUrl", resolveBannerLink(b, url)); + if (b.getEventDate() != null) { + item.put("eventDate", b.getEventDate()); + } + if (hasText(b.getLocation())) { + item.put("location", b.getLocation()); + } + items.add(item); + } + Map block = baseBlock(spec, "events", "学术活动"); + block.put("items", items); + return block; + } + + /** people(D-7 名师风采):cms_person 启用数据(categoryId 可选过滤,须在本站栏目树内);无数据 → 不渲染 */ + private Map buildPeopleBlock(PortalSiteSnapshot site, Map spec, + Set treeIds) { + Integer categoryId = null; + Object rawCategory = spec.get("categoryId"); + if (rawCategory instanceof Number && ((Number) rawCategory).intValue() > 0 + && treeIds.contains(((Number) rawCategory).intValue())) { + categoryId = ((Number) rawCategory).intValue(); + } + List people = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsPerson p + : personRepository.findBySiteIdAndStatusOrderBySortNoAscIdAsc(site.getId(), 1)) { + if (categoryId != null && !categoryId.equals(p.getCategoryId())) { + continue; + } + people.add(p); + if (people.size() >= 12) { + break; + } + } + if (people.isEmpty()) { + return null; + } + List> items = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsPerson p : people) { + Map item = new HashMap<>(); + item.put("name", p.getName()); + if (hasText(p.getTitle())) { + item.put("title", p.getTitle()); + } + if (hasText(p.getBio())) { + item.put("bio", p.getBio()); + } + if (hasText(p.getAvatar())) { + item.put("avatar", p.getAvatar()); + } + items.add(item); + } + Map block = baseBlock(spec, "people", "名师风采"); + block.put("items", items); + return block; + } + + /** badges(D-9 排名/学科评估徽章带):badgeItems 内联数组透传;空 → 不渲染 */ + private Map buildBadgesBlock(Map spec) { + List> items = new ArrayList<>(); + Object raw = spec.get("badgeItems"); + if (raw instanceof List) { + for (Object o : (List) raw) { + if (!(o instanceof Map)) { + continue; + } + Map m = (Map) o; + String label = str(m.get("label"), "").trim(); + String image = str(m.get("image"), "").trim(); + if (label.isEmpty() || !ASSET_URL_PATTERN.matcher(image).matches()) { + continue; + } + Map item = new HashMap<>(); + item.put("label", label); + item.put("image", image); + String desc = str(m.get("desc"), "").trim(); + if (!desc.isEmpty()) { + item.put("desc", desc); + } + items.add(item); + } + } + if (items.isEmpty()) { + return null; + } + Map block = baseBlock(spec, "badges", "实力声誉"); + block.put("items", items); + return block; + } + + /** banner 时间窗过滤(activeBanners 同语义,供 topic/event 位复用;快照内 banner 已过滤启用态) */ + private boolean isActiveNow(PortalBanner b) { + java.time.LocalDateTime now = java.time.LocalDateTime.now(); + return (b.getStartTime() == null || !now.isBefore(b.getStartTime())) + && (b.getEndTime() == null || now.isBefore(b.getEndTime())); + } + // ===== 布局装配工具 ===== + /** 区块内图片资产 URL 形态(与 SiteService ASSET_PATTERN 同语义) */ + private static final Pattern ASSET_URL_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$"); + private static String str(Object v, String fallback) { return v == null ? fallback : String.valueOf(v); } diff --git a/src/main/java/com/yunhai/cms/entity/CmsBanner.java b/src/main/java/com/yunhai/cms/entity/CmsBanner.java index 43300d8..f3936b8 100644 --- a/src/main/java/com/yunhai/cms/entity/CmsBanner.java +++ b/src/main/java/com/yunhai/cms/entity/CmsBanner.java @@ -59,4 +59,27 @@ public class CmsBanner extends BaseEntity { /** 状态:0=停用 1=启用 */ @Column(name = "status", nullable = false) private Integer status = 1; + + // ===== docs/20 D-3/D-6:banner 表扩展(topic=专题大卡位 / event=学术活动位)===== + // 仅对应展示位消费;home/gallery 位忽略。可空列,ddl-auto=update 自动补列(SQL 脚本另附 resources/sql)。 + + /** 专题大卡描述文案(position=topic) */ + @Column(name = "description", length = 500) + private String description; + + /** 专题大卡 CTA 按钮文字(position=topic,如「进入专题」) */ + @Column(name = "cta_text", length = 100) + private String ctaText; + + /** 专题大卡 CTA 跳转地址(position=topic) */ + @Column(name = "cta_url", length = 500) + private String ctaUrl; + + /** 活动日期(position=event,学术日历大日期块) */ + @Column(name = "event_date") + private LocalDateTime eventDate; + + /** 活动地点(position=event) */ + @Column(name = "location", length = 200) + private String location; } diff --git a/src/main/java/com/yunhai/cms/entity/CmsPerson.java b/src/main/java/com/yunhai/cms/entity/CmsPerson.java new file mode 100644 index 0000000..af8db34 --- /dev/null +++ b/src/main/java/com/yunhai/cms/entity/CmsPerson.java @@ -0,0 +1,56 @@ +package com.yunhai.cms.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Index; +import javax.persistence.Table; + +/** + * 名师风采人物(docs/20 D-7:首页 people 区块数据源,新表)。 + * 人物按站点隔离、可挂栏目分组(category_id 可空 = 未分组);头像走附件上传通道。 + * DDL:hibernate ddl-auto=update 自动建表;DBA 审阅脚本见 resources/sql/cms_person.sql(不自动执行)。 + */ +@Getter +@Setter +@Entity +@Table(name = "cms_person", indexes = { + @Index(name = "idx_person_site", columnList = "site_id, status, sort_no"), + @Index(name = "idx_person_category", columnList = "category_id") +}) +public class CmsPerson extends BaseEntity { + + /** 所属站点 */ + @Column(name = "site_id", nullable = false) + private Integer siteId; + + /** 姓名 */ + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** 头衔/职称(如 教授、长江学者) */ + @Column(name = "title", length = 100) + private String title; + + /** 一句话简介 */ + @Column(name = "bio", length = 500) + private String bio; + + /** 头像 URL(附件上传,/cms/upload/** 或 http(s)) */ + @Column(name = "avatar", length = 500) + private String avatar; + + /** 所属栏目外键(可空 = 未分组;people 区块按此过滤) */ + @Column(name = "category_id") + private Integer categoryId; + + /** 同级排序(升序) */ + @Column(name = "sort_no", nullable = false) + private Integer sortNo = 0; + + /** 状态:0=停用 1=启用 */ + @Column(name = "status", nullable = false) + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java index 71252d6..be609cf 100644 --- a/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java +++ b/src/main/java/com/yunhai/cms/model/dto/BannerSaveRequest.java @@ -8,7 +8,7 @@ import javax.validation.constraints.Size; import java.time.LocalDateTime; /** - * 轮播保存(校验 link_type/link_value,方案 §9.2)。 + * 轮播保存(校验 link_type/link_value,方案 §9.2;docs/20 D-3/D-6 扩展 topic/event 位字段)。 */ @Data public class BannerSaveRequest { @@ -49,4 +49,25 @@ public class BannerSaveRequest { /** 0=停用 1=启用 */ private Integer status = 1; + + // ===== docs/20 D-3/D-6:topic/event 展示位扩展(仅对应位消费,其余位忽略)===== + + /** 专题大卡描述(position=topic) */ + @Size(max = 500, message = "专题描述过长") + private String description; + + /** 专题大卡 CTA 按钮文字(position=topic) */ + @Size(max = 100, message = "按钮文字过长") + private String ctaText; + + /** 专题大卡 CTA 跳转地址(position=topic) */ + @Size(max = 500, message = "按钮地址过长") + private String ctaUrl; + + /** 活动日期(position=event) */ + private LocalDateTime eventDate; + + /** 活动地点(position=event) */ + @Size(max = 200, message = "活动地点过长") + private String location; } diff --git a/src/main/java/com/yunhai/cms/model/dto/PersonSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/PersonSaveRequest.java new file mode 100644 index 0000000..1c5eaa7 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/PersonSaveRequest.java @@ -0,0 +1,42 @@ +package com.yunhai.cms.model.dto; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +/** + * 名师风采人物保存(docs/20 D-7)。 + */ +@Data +public class PersonSaveRequest { + + /** 修改时必填 */ + private Integer id; + + @NotNull(message = "siteId 不得为空") + private Integer siteId; + + @NotBlank(message = "姓名不得为空") + @Size(max = 100, message = "姓名过长") + private String name; + + @Size(max = 100, message = "头衔过长") + private String title; + + @Size(max = 500, message = "简介过长") + private String bio; + + /** 头像(/cms/upload/** 或 http(s);可空 = 首字徽章兜底) */ + @Size(max = 500) + private String avatar; + + /** 所属栏目(可空 = 未分组;须属于本站点) */ + private Integer categoryId; + + private Integer sortNo = 0; + + /** 0=停用 1=启用 */ + private Integer status = 1; +} diff --git a/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java b/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java index 8a2ed11..594e739 100644 --- a/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java +++ b/src/main/java/com/yunhai/cms/model/portal/PortalBanner.java @@ -34,6 +34,22 @@ public class PortalBanner { /** 下线时间(渲染方过滤) */ private LocalDateTime endTime; + // ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 ===== + /** 专题大卡描述(position=topic) */ + private String description; + + /** 专题大卡 CTA 按钮文字(position=topic) */ + private String ctaText; + + /** 专题大卡 CTA 跳转地址(position=topic) */ + private String ctaUrl; + + /** 活动日期(position=event,学术日历大日期块) */ + private LocalDateTime eventDate; + + /** 活动地点(position=event) */ + private String location; + public static PortalBanner of(CmsBanner b) { PortalBanner item = new PortalBanner(); item.setId(b.getId()); @@ -45,6 +61,11 @@ public class PortalBanner { item.setSortNo(b.getSortNo()); item.setStartTime(b.getStartTime()); item.setEndTime(b.getEndTime()); + item.setDescription(b.getDescription()); + item.setCtaText(b.getCtaText()); + item.setCtaUrl(b.getCtaUrl()); + item.setEventDate(b.getEventDate()); + item.setLocation(b.getLocation()); return item; } } diff --git a/src/main/java/com/yunhai/cms/model/vo/BannerVo.java b/src/main/java/com/yunhai/cms/model/vo/BannerVo.java index 1b91291..fc40d6e 100644 --- a/src/main/java/com/yunhai/cms/model/vo/BannerVo.java +++ b/src/main/java/com/yunhai/cms/model/vo/BannerVo.java @@ -34,6 +34,13 @@ public class BannerVo { private Integer status; + // ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 ===== + private String description; + private String ctaText; + private String ctaUrl; + private java.time.LocalDateTime eventDate; + private String location; + public static BannerVo of(CmsBanner b) { BannerVo vo = new BannerVo(); vo.setId(b.getId()); @@ -47,6 +54,11 @@ public class BannerVo { vo.setStartTime(b.getStartTime()); vo.setEndTime(b.getEndTime()); vo.setStatus(b.getStatus()); + vo.setDescription(b.getDescription()); + vo.setCtaText(b.getCtaText()); + vo.setCtaUrl(b.getCtaUrl()); + vo.setEventDate(b.getEventDate()); + vo.setLocation(b.getLocation()); return vo; } } diff --git a/src/main/java/com/yunhai/cms/model/vo/PersonVo.java b/src/main/java/com/yunhai/cms/model/vo/PersonVo.java new file mode 100644 index 0000000..a6dcd38 --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/vo/PersonVo.java @@ -0,0 +1,39 @@ +package com.yunhai.cms.model.vo; + +import com.yunhai.cms.entity.CmsPerson; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 名师风采人物(docs/20 D-7) */ +@Data +public class PersonVo { + + private Integer id; + private Integer siteId; + private String name; + private String title; + private String bio; + private String avatar; + private Integer categoryId; + private Integer sortNo; + private Integer status; + private LocalDateTime createTime; + private LocalDateTime updateTime; + + public static PersonVo of(CmsPerson p) { + PersonVo vo = new PersonVo(); + vo.setId(p.getId()); + vo.setSiteId(p.getSiteId()); + vo.setName(p.getName()); + vo.setTitle(p.getTitle()); + vo.setBio(p.getBio()); + vo.setAvatar(p.getAvatar()); + vo.setCategoryId(p.getCategoryId()); + vo.setSortNo(p.getSortNo()); + vo.setStatus(p.getStatus()); + vo.setCreateTime(p.getCreateTime()); + vo.setUpdateTime(p.getUpdateTime()); + return vo; + } +} diff --git a/src/main/java/com/yunhai/cms/repository/CmsPersonRepository.java b/src/main/java/com/yunhai/cms/repository/CmsPersonRepository.java new file mode 100644 index 0000000..b4405b6 --- /dev/null +++ b/src/main/java/com/yunhai/cms/repository/CmsPersonRepository.java @@ -0,0 +1,16 @@ +package com.yunhai.cms.repository; + +import com.yunhai.cms.entity.CmsPerson; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** 名师风采(docs/20 D-7) */ +public interface CmsPersonRepository extends JpaRepository { + + /** 管理端:站点全量(含停用,按排序) */ + List findBySiteIdOrderBySortNoAscIdAsc(Integer siteId); + + /** 门户:启用人物(可选栏目过滤),按排序 */ + List findBySiteIdAndStatusOrderBySortNoAscIdAsc(Integer siteId, Integer status); +} diff --git a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java index ddf5687..cd24a26 100644 --- a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java +++ b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java @@ -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 ALLOWED_SCENES = - Arrays.asList("banner", "link", "site", "category"); + Arrays.asList("banner", "link", "site", "category", "person"); private final CmsAttachmentRepository attachmentRepository; private final SiteService siteService; diff --git a/src/main/java/com/yunhai/cms/service/impl/BannerService.java b/src/main/java/com/yunhai/cms/service/impl/BannerService.java index 902e410..7dc2244 100644 --- a/src/main/java/com/yunhai/cms/service/impl/BannerService.java +++ b/src/main/java/com/yunhai/cms/service/impl/BannerService.java @@ -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()); diff --git a/src/main/java/com/yunhai/cms/service/impl/PersonService.java b/src/main/java/com/yunhai/cms/service/impl/PersonService.java new file mode 100644 index 0000000..eca3e6b --- /dev/null +++ b/src/main/java/com/yunhai/cms/service/impl/PersonService.java @@ -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 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")); + } +} 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 eb15552..36d3255 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -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 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 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 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 站点名称 -