init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -0,0 +1,35 @@
package com.yunhai.cms.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 定时发布/下架触发器(方案 §13 风险表:MVP 用 @Scheduled 每分钟扫到期记录,无重调度器)。
* 扫描语义在 {@link ArticleScheduleWorker}(D5-5 拆分:触发与逻辑分离,IT 直接调 Worker
* 消除自动调度与手动调用的并发竞态)。cms.schedule.auto-enabled=false 可关自动触发
* (仅集成测试用,生产默认开)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(value = "cms.schedule.auto-enabled", havingValue = "true", matchIfMissing = true)
public class ArticleScheduleTask {
private final ArticleScheduleWorker worker;
/** 定时发布:草稿且发布时间已到 */
@Scheduled(initialDelay = 30_000, fixedDelay = 60_000)
public void publishDue() {
worker.publishDue();
}
/** 定时下架:已发布且下架时间已到 */
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
public void offlineDue() {
worker.offlineDue();
}
}
@@ -0,0 +1,71 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.event.ArticleChangedEvent;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.repository.CmsArticleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 定时发布/下架扫描逻辑(方案 §13 风险表:MVP 每分钟扫到期记录,无重调度器)。
* 从 ArticleScheduleTask 拆出纯逻辑体(D5-5):@Scheduled 触发器与扫描语义分离,
* 集成测试直接调 Worker 消除"上下文自动调度与测试手动调用并发处理同一记录"的竞态
* (表现为事件数翻倍断言失败)。每条记录独立事务;到期即发对应事件
* (缓存失效 + 微信挂点照常生效)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ArticleScheduleWorker {
private final CmsArticleRepository articleRepository;
private final TransactionTemplate transactionTemplate;
private final ApplicationEventPublisher eventPublisher;
/** 定时发布:草稿且发布时间已到 */
public void publishDue() {
List<CmsArticle> dueList =
articleRepository.findByStatusAndPublishTimeNotNullAndPublishTimeLessThanEqual(
ArticleStatusEnum.DRAFT.getCode(), LocalDateTime.now());
for (CmsArticle due : dueList) {
transactionTemplate.executeWithoutResult(status -> {
CmsArticle article = articleRepository.findById(due.getId()).orElse(null);
if (article == null || article.getStatus() != ArticleStatusEnum.DRAFT.getCode()) {
return;
}
article.setStatus(ArticleStatusEnum.PUBLISHED.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticlePublishedEvent(article.getId(),
article.getSiteId(), article.getTitle()));
log.info("定时发布文章 articleId={} siteId={}", article.getId(), article.getSiteId());
});
}
}
/** 定时下架:已发布且下架时间已到 */
public void offlineDue() {
List<CmsArticle> dueList =
articleRepository.findByStatusAndOfflineTimeNotNullAndOfflineTimeLessThanEqual(
ArticleStatusEnum.PUBLISHED.getCode(), LocalDateTime.now());
for (CmsArticle due : dueList) {
transactionTemplate.executeWithoutResult(status -> {
CmsArticle article = articleRepository.findById(due.getId()).orElse(null);
if (article == null || article.getStatus() != ArticleStatusEnum.PUBLISHED.getCode()) {
return;
}
article.setStatus(ArticleStatusEnum.OFFLINE.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), article.getSiteId()));
log.info("定时下架文章 articleId={} siteId={}", article.getId(), article.getSiteId());
});
}
}
}
@@ -0,0 +1,237 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.event.ArticleChangedEvent;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.model.dto.ArticleSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.query.ArticlePageQuery;
import com.yunhai.cms.model.vo.ArticleEditVo;
import com.yunhai.cms.model.vo.ArticlePageItem;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsAttachmentRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.utils.HtmlSanitizer;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 文章管理(方案 §9.1/§9.2):草稿/发布/下架/置顶/定时发布下架 + jsoup 保存时清洗。
* 发布动作发布 {@link ArticlePublishedEvent}(AFTER_COMMIT:缓存失效 + 微信 Noop 挂点)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ArticleService {
private final CmsArticleRepository articleRepository;
private final CmsCategoryRepository categoryRepository;
private final CmsAttachmentRepository attachmentRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
/** 文章分页(管理端,全状态可见);pageSize 上限 100 防全表拖库(体检 H-6) */
public Map<String, Object> page(BoaUser user, ArticlePageQuery query) {
siteService.requireManageable(user, query.getSiteId());
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
pageSize = Math.min(pageSize, 100);
Pageable pageable = PageRequest.of(pageIndex, pageSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("createTime")));
Specification<CmsArticle> spec = (root, cq, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("siteId"), query.getSiteId()));
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
predicates.add(root.get("categoryId").in(query.getCategoryIds()));
}
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
if (StringUtils.hasText(query.getKeyword())) {
predicates.add(cb.like(root.get("title"), "%" + query.getKeyword().trim() + "%"));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Map<Integer, String> categoryNames = categoryNameMap(query.getSiteId());
Page<CmsArticle> page = articleRepository.findAll(spec, pageable);
List<ArticlePageItem> items = new ArrayList<>();
for (CmsArticle article : page.getContent()) {
items.add(ArticlePageItem.of(article, categoryNames.get(article.getCategoryId())));
}
Map<String, Object> result = new HashMap<>();
result.put("total", page.getTotalElements());
result.put("list", items);
return result;
}
private Map<Integer, String> categoryNameMap(Integer siteId) {
Map<Integer, String> names = new HashMap<>();
for (CmsCategory c : categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId)) {
names.put(c.getId(), c.getName());
}
return names;
}
/** 编辑回显 */
public ArticleEditVo getEdit(BoaUser user, Integer siteId, Integer id) {
siteService.requireManageable(user, siteId);
CmsArticle article = articleRepository.findByIdAndSiteId(id, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在"));
return ArticleEditVo.of(article);
}
/**
* 保存(草稿)。content 过 jsoup 白名单;摘要空则截取正文;
* 请求携带 attachmentIds 时回填编辑器图片归属。已发布文章被修改后发变更事件保持门户新鲜。
*/
@Transactional
public Integer save(BoaUser user, ArticleSaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsCategory category = categoryRepository.findById(req.getCategoryId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "所属栏目不存在"));
if (!category.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "栏目不属于该站点");
}
CmsArticle article;
if (req.getId() == null) {
article = new CmsArticle();
article.setSiteId(site.getId());
article.setStatus(ArticleStatusEnum.DRAFT.getCode());
} else {
article = articleRepository.findByIdAndSiteId(req.getId(), site.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在"));
}
validateTimeWindow(req.getPublishTime(), req.getOfflineTime());
article.setCategoryId(category.getId());
article.setTitle(req.getTitle());
article.setContent(HtmlSanitizer.clean(req.getContent()));
String summary = req.getSummary();
if (!StringUtils.hasText(summary)) {
summary = HtmlSanitizer.toText(article.getContent(), 120);
}
article.setSummary(summary);
article.setCoverImage(req.getCoverImage());
article.setAuthorName(req.getAuthorName());
article.setSource(req.getSource());
article.setTopmost(req.getTopmost() == null ? 0 : req.getTopmost());
article.setPublishTime(req.getPublishTime());
article.setOfflineTime(req.getOfflineTime());
article = articleRepository.save(article);
if (req.getAttachmentIds() != null && !req.getAttachmentIds().isEmpty()) {
attachmentRepository.bindArticle(article.getId(), req.getAttachmentIds());
}
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
return article.getId();
}
/**
* 发布:填发布时间与作者。publishTime 为空或已过 → 立即发布;
* 未来时间 → 保持草稿(定时发布,@Scheduled 扫描触发)。
*/
@Transactional
public void publish(BoaUser user, Integer siteId, Integer id) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsArticle article = requireArticle(site.getId(), id);
LocalDateTime now = LocalDateTime.now();
LocalDateTime effective = article.getPublishTime() != null && article.getPublishTime().isAfter(now)
? article.getPublishTime() : now;
// 重新发布即开启新生命周期:过期的定时下架时间若不清掉,发布后会被调度器立刻下架
// (旧数据/边界操作产生的矛盾态在此自愈,save 入口已拦截新产生的矛盾时间窗)
if (article.getOfflineTime() != null && !article.getOfflineTime().isAfter(effective)) {
article.setOfflineTime(null);
}
article.setPublishTime(effective);
if (!StringUtils.hasText(article.getAuthorAccount()) && user != null) {
article.setAuthorAccount(user.getAccount());
if (!StringUtils.hasText(article.getAuthorName())) {
article.setAuthorName(user.getName());
}
}
if (effective.isAfter(now)) {
article.setStatus(ArticleStatusEnum.DRAFT.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
} else {
article.setStatus(ArticleStatusEnum.PUBLISHED.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticlePublishedEvent(article.getId(), site.getId(), article.getTitle()));
}
}
/** 下架 */
@Transactional
public void offline(BoaUser user, Integer siteId, Integer id) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsArticle article = requireArticle(site.getId(), id);
article.setStatus(ArticleStatusEnum.OFFLINE.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
}
/** 置顶开关 */
@Transactional
public void topmost(BoaUser user, Integer siteId, Integer id, boolean enable) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsArticle article = requireArticle(site.getId(), id);
article.setTopmost(enable ? 1 : 0);
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
}
/**
* 删除:附件转孤儿(article_id 置空,由附件管理页清理,方案 §9.1 附件管理)。
* 轮播仍引用该文章时拦截删除(引用完整性统一规则:被引用即拦,与栏目删除一致)。
*/
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsArticle article = requireArticle(site.getId(), req.getId());
if (bannerRepository.existsByLinkTypeAndLinkValue(
com.yunhai.cms.enums.LinkTypeEnum.ARTICLE.getCode(), String.valueOf(article.getId()))) {
throw new BizException(ErrorCode.PARAM_ERROR,
"存在轮播图跳转到该文章,请先在轮播管理中删除或修改对应轮播");
}
attachmentRepository.unbindArticle(article.getId());
articleRepository.delete(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
}
/** 定时时间窗校验:offlineTime 必须晚于 publishTime(否则调度器会发布后立刻下架,状态机自相矛盾) */
private void validateTimeWindow(LocalDateTime publishTime, LocalDateTime offlineTime) {
if (publishTime != null && offlineTime != null && !offlineTime.isAfter(publishTime)) {
throw new BizException(ErrorCode.PARAM_ERROR, "定时下架时间必须晚于发布时间");
}
}
private CmsArticle requireArticle(Integer siteId, Integer id) {
return articleRepository.findByIdAndSiteId(id, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在"));
}
}
@@ -0,0 +1,217 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsAttachment;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.AttachmentVo;
import com.yunhai.cms.repository.CmsAttachmentRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
* 存储 {FILE_PATH}/cms/{siteCode}/{yyyyMM}/{uuid32}.{ext};类型白名单 + 重命名存储,
* 禁止可执行后缀(方案 §8.5);图片类叠加 Content-Type + 魔数双校验(A-3)。
* 轮播图/友链 Logo 走同一接口。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AttachmentService {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
private static final List<String> ALLOWED_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md",
"zip", "rar", "7z", "mp4", "mp3", "wav");
/** 图片类扩展名:在白名单之上叠加 Content-Type + 魔数双校验,防伪装后缀(A-3) */
private static final List<String> IMAGE_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@Value("${FILE_PATH:./upload/}")
private String filePath;
/** 只接受相对路径(/cms/upload/)或公网可达绝对地址;绝对内网地址(localhost:8072 等)入库即断图(B-1/C-5) */
@Value("${FILE_WEB_PATH:/cms/upload/}")
private String fileWebPath;
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空 */
@Transactional
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, MultipartFile file) {
CmsSite site = siteService.requireManageable(user, siteId);
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
}
String originalName = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
String ext = extOf(originalName);
if (!ALLOWED_EXTS.contains(ext)) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的文件类型:" + ext);
}
if (IMAGE_EXTS.contains(ext)) {
requireRealImage(file, ext);
}
String relativeDir = "cms/" + site.getCode() + "/" + MONTH_DIR.format(LocalDateTime.now());
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
File dir = new File(filePath, relativeDir);
if (!dir.exists() && !dir.mkdirs()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传目录创建失败");
}
try {
file.transferTo(new File(dir, storedName));
} catch (IOException e) {
log.error("附件保存失败 siteId={}", siteId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "文件保存失败");
}
String relativePath = relativeDir + "/" + storedName;
String webUrl = joinUrl(fileWebPath, relativePath);
CmsAttachment attachment = new CmsAttachment();
attachment.setSiteId(site.getId());
attachment.setArticleId(articleId);
attachment.setFileName(originalName);
attachment.setFilePath(relativePath);
attachment.setWebUrl(webUrl);
attachment.setFileSize(file.getSize());
attachment.setMimeType(file.getContentType());
attachment = attachmentRepository.save(attachment);
return AttachmentVo.of(attachment);
}
/** 附件列表(orphanOnly=true 时仅孤儿,附件管理页清理用) */
public Map<String, Object> page(BoaUser user, Integer siteId, boolean orphanOnly,
Integer page, Integer pageSize) {
siteService.requireManageable(user, siteId);
int pageIndex = page == null || page < 1 ? 0 : page - 1;
int size = pageSize == null || pageSize < 1 ? 10 : pageSize;
Page<CmsAttachment> result = orphanOnly
? attachmentRepository.findBySiteIdAndArticleIdIsNull(siteId,
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")))
: attachmentRepository.findBySiteId(siteId,
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")));
List<AttachmentVo> list = new ArrayList<>();
for (CmsAttachment a : result.getContent()) {
list.add(AttachmentVo.of(a));
}
Map<String, Object> map = new HashMap<>();
map.put("total", result.getTotalElements());
map.put("list", list);
map.put("orphanCount", attachmentRepository.countBySiteIdAndArticleIdIsNull(siteId));
return map;
}
/** 删除附件记录(不删物理文件,运维定期清理磁盘,避免误删被引用文件) */
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
siteService.requireManageable(user, req.getSiteId());
CmsAttachment attachment = attachmentRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "附件不存在"));
if (!attachment.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该附件");
}
attachmentRepository.delete(attachment);
}
/**
* 图片类双校验(A-3):Content-Type 必须 image/*,且内容必须真实可解。
* 防 .exe 改名 .jpg 之类伪装后缀入库后经门户以图片身份对外分发。
*/
private void requireRealImage(MultipartFile file, String ext) {
String contentType = file.getContentType();
if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
throw new BizException(ErrorCode.PARAM_ERROR, "图片文件类型非法(Content-Type=" + contentType + ")");
}
if (!isDecodableImage(file, ext)) {
throw new BizException(ErrorCode.PARAM_ERROR, "文件内容不是有效图片(疑似伪装后缀):" + file.getOriginalFilename());
}
}
/**
* 魔数校验:jpg/jpeg/png/gif/bmp 用 ImageIO 真实解码(可读才算图片);
* webp/ico JDK8 无内置 reader,退化为魔数头校验(RIFF....WEBP / 00 00 01 00)。
*/
private boolean isDecodableImage(MultipartFile file, String ext) {
if ("webp".equals(ext) || "ico".equals(ext)) {
byte[] head = readHead(file, 12);
if (head.length < 4) {
return false;
}
if ("webp".equals(ext)) {
return head.length >= 12
&& head[0] == 'R' && head[1] == 'I' && head[2] == 'F' && head[3] == 'F'
&& head[8] == 'W' && head[9] == 'E' && head[10] == 'B' && head[11] == 'P';
}
return head[0] == 0 && head[1] == 0 && head[2] == 1 && head[3] == 0;
}
try (InputStream in = file.getInputStream()) {
return ImageIO.read(in) != null;
} catch (IOException e) {
log.warn("图片魔数校验读取失败:{}", file.getOriginalFilename());
return false;
}
}
private byte[] readHead(MultipartFile file, int length) {
byte[] head = new byte[length];
try (InputStream in = file.getInputStream()) {
int read = 0;
while (read < length) {
int n = in.read(head, read, length - read);
if (n < 0) {
break;
}
read += n;
}
return Arrays.copyOf(head, read);
} catch (IOException e) {
return new byte[0];
}
}
private String extOf(String name) {
int dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length() - 1) {
return "";
}
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private String joinUrl(String base, String relative) {
if (!StringUtils.hasText(base)) {
return "/" + relative;
}
return base.endsWith("/") ? base + relative : base + "/" + relative;
}
}
@@ -0,0 +1,130 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsBanner;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.BannerSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.BannerVo;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsBannerRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
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.ArrayList;
import java.util.List;
/**
* 轮播/推荐位管理(方案 §9.1 轮播管理)。保存时校验 link_type/link_value 匹配。
*/
@Service
@RequiredArgsConstructor
public class BannerService {
private final CmsBannerRepository bannerRepository;
private final CmsArticleRepository articleRepository;
private final CmsCategoryRepository categoryRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
public List<BannerVo> list(BoaUser user, Integer siteId, String position) {
siteService.requireManageable(user, siteId);
List<CmsBanner> banners = StringUtils.hasText(position)
? bannerRepository.findBySiteIdAndPositionOrderBySortNoAscIdAsc(siteId, position)
: bannerRepository.findBySiteIdOrderByPositionAscSortNoAscIdAsc(siteId);
List<BannerVo> result = new ArrayList<>();
for (CmsBanner b : banners) {
result.add(BannerVo.of(b));
}
return result;
}
@Transactional
public BannerVo save(BoaUser user, BannerSaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
LinkTypeEnum linkType = LinkTypeEnum.ofCode(req.getLinkType());
if (linkType == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "跳转类型非法:" + req.getLinkType() + "(仅 1=文章 2=栏目 3=外链)");
}
validateLink(site.getId(), linkType, req.getLinkValue());
CmsBanner banner;
if (req.getId() == null) {
banner = new CmsBanner();
banner.setSiteId(site.getId());
} else {
banner = bannerRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "轮播不存在"));
if (!banner.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该轮播");
}
}
banner.setPosition(StringUtils.hasText(req.getPosition()) ? req.getPosition() : "home");
banner.setTitle(req.getTitle());
banner.setImageUrl(req.getImageUrl());
banner.setLinkType(linkType.getCode());
banner.setLinkValue(req.getLinkValue());
banner.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
banner.setStartTime(req.getStartTime());
banner.setEndTime(req.getEndTime());
if (banner.getStartTime() != null && banner.getEndTime() != null
&& banner.getEndTime().isBefore(banner.getStartTime())) {
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
}
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
banner = bannerRepository.save(banner);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
return BannerVo.of(banner);
}
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsBanner banner = bannerRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "轮播不存在"));
if (!banner.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该轮播");
}
bannerRepository.delete(banner);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
}
/**
* link_type/link_value 匹配校验:1=文章 2=栏目(须存在且属于本站,禁止跨站引用)
* 3=外链(必须 http/https 开头)。
*/
private void validateLink(Integer siteId, LinkTypeEnum linkType, String linkValue) {
if (linkType == LinkTypeEnum.EXTERNAL) {
if (!StringUtils.hasText(linkValue)
|| !(linkValue.startsWith("http://") || linkValue.startsWith("https://"))) {
throw new BizException(ErrorCode.PARAM_ERROR, "外链地址必须以 http(s):// 开头");
}
return;
}
if (!StringUtils.hasText(linkValue)) {
throw new BizException(ErrorCode.PARAM_ERROR, "跳转目标不能为空");
}
Integer targetId;
try {
targetId = Integer.valueOf(linkValue.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "跳转目标必须是数字 ID");
}
if (linkType == LinkTypeEnum.ARTICLE) {
articleRepository.findByIdAndSiteId(targetId, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "跳转文章不存在"));
} else if (linkType == LinkTypeEnum.CATEGORY) {
categoryRepository.findById(targetId)
.filter(c -> c.getSiteId().equals(siteId))
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "跳转栏目不存在"));
}
}
}
@@ -0,0 +1,173 @@
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.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.CategorySaveRequest;
import com.yunhai.cms.model.dto.CategorySortRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.CategoryTreeNode;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.utils.HtmlSanitizer;
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.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 栏目树管理(方案 §9.1 栏目管理)。删除校验空栏目(无子栏目且无文章)。
*/
@Service
@RequiredArgsConstructor
public class CategoryService {
private final CmsCategoryRepository categoryRepository;
private final CmsArticleRepository articleRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
/** 栏目树(管理端全量含隐藏;门户侧由 PortalQueryService 另行过滤) */
public List<CategoryTreeNode> tree(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
List<CmsCategory> all = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId);
Map<Integer, CategoryTreeNode> nodeMap = new HashMap<>();
for (CmsCategory c : all) {
nodeMap.put(c.getId(), CategoryTreeNode.of(c));
}
List<CategoryTreeNode> roots = new ArrayList<>();
for (CmsCategory c : all) {
CategoryTreeNode node = nodeMap.get(c.getId());
CategoryTreeNode parent = c.getParentId() == null ? null : nodeMap.get(c.getParentId());
if (parent == null) {
roots.add(node);
} else {
parent.getChildren().add(node);
}
}
return roots;
}
@Transactional
public CategoryTreeNode save(BoaUser user, CategorySaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CategoryTypeEnum type = CategoryTypeEnum.ofCode(req.getType());
if (type == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "栏目类型非法:" + req.getType() + "(仅 1=列表 2=单页 3=外链)");
}
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
}
CmsCategory category;
if (req.getId() == null) {
category = new CmsCategory();
category.setSiteId(site.getId());
} else {
category = categoryRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "栏目不存在"));
if (!category.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该栏目");
}
}
validateParent(category, req);
category.setParentId(req.getParentId() == null ? 0 : req.getParentId());
category.setName(req.getName());
category.setType(type.getCode());
category.setLinkUrl(req.getLinkUrl());
category.setTemplate(req.getTemplate());
category.setContent(HtmlSanitizer.clean(req.getContent()));
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
category.setSeoTitle(req.getSeoTitle());
category.setSeoDescription(req.getSeoDescription());
category.setStatus(req.getStatus() == null ? 1 : req.getStatus());
category = categoryRepository.save(category);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category"));
return CategoryTreeNode.of(category);
}
private void validateParent(CmsCategory category, CategorySaveRequest req) {
Integer parentId = req.getParentId() == null ? 0 : req.getParentId();
if (parentId == 0) {
return;
}
if (category.getId() != null && parentId.equals(category.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不能是自身");
}
CmsCategory parent = categoryRepository.findById(parentId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "父栏目不存在"));
if (!parent.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点");
}
if (category.getId() != null && parent.getParentId() != null
&& parent.getParentId().equals(category.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持把子栏目设为父栏目(成环)");
}
}
/** 删除(校验空栏目 + 轮播引用,统一"被引用即拦"规则) */
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsCategory category = categoryRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "栏目不存在"));
if (!category.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该栏目");
}
if (categoryRepository.countByParentId(category.getId()) > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "存在子栏目,请先删除子栏目");
}
if (articleRepository.countByCategoryId(category.getId()) > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "栏目下存在文章,请先处理文章");
}
if (bannerRepository.existsByLinkTypeAndLinkValue(
com.yunhai.cms.enums.LinkTypeEnum.CATEGORY.getCode(), String.valueOf(category.getId()))) {
throw new BizException(ErrorCode.PARAM_ERROR,
"存在轮播图跳转到该栏目,请先在轮播管理中删除或修改对应轮播");
}
categoryRepository.delete(category);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category"));
}
/**
* 子栏目排序(UserBase SortChildren 同语义):ids 顺序即 sortNo 1..n。
*/
@Transactional
public void sort(BoaUser user, CategorySortRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
List<Integer> ids = req.getIds();
if (ids.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "子栏目ID列表不能为空");
}
if (ids.stream().distinct().count() != ids.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "子栏目ID列表存在重复");
}
List<CmsCategory> children = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(site.getId()).stream()
.filter(c -> c.getParentId() != null && c.getParentId().equals(req.getParentId()))
.collect(Collectors.toList());
Map<Integer, CmsCategory> childMap = children.stream()
.collect(Collectors.toMap(CmsCategory::getId, c -> c, (a, b) -> a));
for (Integer id : ids) {
if (!childMap.containsKey(id)) {
throw new BizException(ErrorCode.PARAM_ERROR, "ids 中存在不属于该栏目的直接子栏目");
}
}
// 按传入顺序落库排序(1 起)
for (int i = 0; i < ids.size(); i++) {
childMap.get(ids.get(i)).setSortNo(i + 1);
}
categoryRepository.saveAll(children);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category"));
}
}
@@ -0,0 +1,84 @@
package com.yunhai.cms.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaModule;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 用户中心代理(Notice UserCenterService 同款逻辑平移):
* 模块菜单树来自基础平台 SDK(YschBaseOpenApi.getUserModule),扁平转树对齐前端 layout 契约。
*/
@Slf4j
@Service
public class CmsUserCenterService {
/**
* 当前用户已授权模块树(左侧菜单)。
*/
public List<Map<String, Object>> getUserModuleTree(Integer userId, Integer schId) {
if (userId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "用户ID不得为空");
}
List<BoaModule> modules;
try {
modules = YschBaseOpenApi.getUserModule(userId, schId);
} catch (Exception e) {
// 平台不可达时菜单降级为空(登录/站点能力不受影响),避免整页不可用
log.warn("[cms] 获取用户模块失败, schId={}: {}", schId, e.getMessage());
return Collections.emptyList();
}
if (modules == null || modules.isEmpty()) {
return Collections.emptyList();
}
return buildModuleTree(modules);
}
/**
* 扁平 BoaModule → 树结构(modId/page/children),对齐前端 layout 期望。
* 顶层取 parentId=0 或父节点不在列表中的节点,保持 SDK 返回顺序。
*/
private List<Map<String, Object>> buildModuleTree(List<BoaModule> modules) {
Map<Integer, BoaModule> moduleMap = modules.stream()
.collect(Collectors.toMap(BoaModule::getId, m -> m, (a, b) -> a));
Map<Integer, Map<String, Object>> nodeMap = new LinkedHashMap<>();
for (BoaModule m : modules) {
Map<String, Object> node = new LinkedHashMap<>();
node.put("id", m.getId());
node.put("modId", m.getId());
node.put("name", m.getName());
node.put("parentId", m.getParentId() != null ? m.getParentId() : 0);
node.put("page", m.getPath() != null ? m.getPath() : "");
node.put("level", m.getLevel() != null ? m.getLevel() : 0);
node.put("icon", m.getIcon() != null ? m.getIcon() : "");
node.put("orderNumber", m.getSortNo() != null ? m.getSortNo() : 0);
node.put("usable", true);
node.put("children", new ArrayList<>());
nodeMap.put(m.getId(), node);
}
List<Map<String, Object>> roots = new ArrayList<>();
for (BoaModule m : modules) {
Map<String, Object> node = nodeMap.get(m.getId());
int parentId = m.getParentId() != null ? m.getParentId() : 0;
if (parentId == 0 || !moduleMap.containsKey(parentId)) {
roots.add(node);
} else {
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) nodeMap.get(parentId).get("children");
children.add(node);
}
}
return roots;
}
}
@@ -0,0 +1,84 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsLink;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.LinkSaveRequest;
import com.yunhai.cms.model.vo.LinkVo;
import com.yunhai.cms.repository.CmsLinkRepository;
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.ArrayList;
import java.util.List;
/**
* 友情链接管理(方案 §9.1 友情链接管理)。
*/
@Service
@RequiredArgsConstructor
public class LinkService {
private final CmsLinkRepository linkRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
public List<LinkVo> list(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
List<CmsLink> links = linkRepository.findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(siteId);
List<LinkVo> result = new ArrayList<>();
for (CmsLink l : links) {
result.add(LinkVo.of(l));
}
return result;
}
@Transactional
public LinkVo save(BoaUser user, LinkSaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
if (!StringUtils.hasText(req.getLinkUrl())
|| !(req.getLinkUrl().startsWith("http://") || req.getLinkUrl().startsWith("https://")
|| req.getLinkUrl().startsWith("/"))) {
throw new BizException(ErrorCode.PARAM_ERROR, "链接地址必须以 http(s):// 或 / 开头");
}
CmsLink link;
if (req.getId() == null) {
link = new CmsLink();
link.setSiteId(site.getId());
} else {
link = linkRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "友链不存在"));
if (!link.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该友链");
}
}
link.setGroupName(StringUtils.hasText(req.getGroupName()) ? req.getGroupName() : "default");
link.setName(req.getName());
link.setLogoUrl(req.getLogoUrl());
link.setLinkUrl(req.getLinkUrl());
link.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
link.setStatus(req.getStatus() == null ? 1 : req.getStatus());
link = linkRepository.save(link);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link"));
return LinkVo.of(link);
}
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsLink link = linkRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "友链不存在"));
if (!link.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该友链");
}
linkRepository.delete(link);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link"));
}
}
@@ -0,0 +1,22 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.service.WeChatPushService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* 微信推送空实现(方案 §7.4):仅日志留痕,不产生任何外联请求。
* 真实实现启用前,管理端"同步到公众号"开关保持灰显。
*/
@Slf4j
@Service
public class NoopWeChatPushService implements WeChatPushService {
@Override
public boolean onArticlePublished(ArticlePublishedEvent event) {
log.debug("微信推送未启用,忽略文章发布事件:articleId={}, title={}(规划见《微信平台对接方案.md》)",
event.getArticleId(), event.getTitle());
return false;
}
}
@@ -0,0 +1,251 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.config.CacheNames;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.entity.CmsBanner;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsLink;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.model.portal.PortalArticleDetail;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalLinkGroup;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSiteBrief;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsBannerRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.repository.CmsLinkRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 门户只读查询(缓存入口,方案 §8.4)。
* 缓存体为不可变 POJO 快照;失效由 AFTER_COMMIT 事件监听(PortalCacheEvictListener)。
* 皮肤包不存在时回退 default,避免缺模板 500。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PortalQueryService {
private final CmsSiteRepository siteRepository;
private final CmsCategoryRepository categoryRepository;
private final CmsArticleRepository articleRepository;
private final CmsBannerRepository bannerRepository;
private final CmsLinkRepository linkRepository;
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
/** 站点快照(siteCode → 站点+栏目树+轮播+友链);站点不存在/停用缓存 null 哨兵 */
@Cacheable(cacheNames = CacheNames.SITE, key = "#siteCode")
public PortalSiteSnapshot siteSnapshot(String siteCode) {
CmsSite site = siteRepository.findByCode(siteCode).orElse(null);
if (site == null || site.getStatus() == null || site.getStatus() != 1) {
return null;
}
PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site);
snapshot.setTheme(resolveTheme(snapshot.getTheme()));
// 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致
com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme());
java.util.Map<String, String> themeParams =
themeInfo.filterValidValues(rawParamsMap(snapshot));
snapshot.setThemeParams(themeParams);
snapshot.setThemeVarsCss(com.yunhai.cms.theme.ThemeVarsCss.build(themeInfo, themeParams));
// 栏目树(仅启用):一次查询复用两遍(建节点 + 组树),省一次 DB 往返
List<CmsCategory> categories = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(site.getId());
Map<Integer, PortalCategory> nodeMap = new HashMap<>();
List<PortalCategory> roots = new ArrayList<>();
for (CmsCategory c : categories) {
if (c.getStatus() == null || c.getStatus() != 1) {
continue;
}
PortalCategory node = PortalCategory.of(c);
nodeMap.put(node.getId(), node);
}
for (CmsCategory c : categories) {
PortalCategory node = nodeMap.get(c.getId());
if (node == null) {
continue;
}
PortalCategory parent = c.getParentId() == null ? null : nodeMap.get(c.getParentId());
if (parent == null) {
roots.add(node);
} else {
parent.getChildren().add(node);
}
}
snapshot.setCategories(roots);
// 轮播(仅启用;时间窗过滤留给渲染)
List<PortalBanner> banners = new ArrayList<>();
for (CmsBanner b : bannerRepository.findBySiteIdOrderByPositionAscSortNoAscIdAsc(site.getId())) {
if (b.getStatus() != null && b.getStatus() == 1) {
banners.add(PortalBanner.of(b));
}
}
snapshot.setBanners(banners);
// 友链分组(仅启用)
Map<String, PortalLinkGroup> groupMap = new HashMap<>();
List<PortalLinkGroup> groups = new ArrayList<>();
for (CmsLink l : linkRepository.findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(site.getId())) {
if (l.getStatus() == null || l.getStatus() != 1) {
continue;
}
PortalLinkGroup group = groupMap.computeIfAbsent(l.getGroupName(), k -> {
PortalLinkGroup g = new PortalLinkGroup();
g.setGroupName(k);
groups.add(g);
return g;
});
PortalLinkGroup.PortalLink link = new PortalLinkGroup.PortalLink();
link.setName(l.getName());
link.setLogoUrl(l.getLogoUrl());
link.setLinkUrl(l.getLinkUrl());
group.getLinks().add(link);
}
snapshot.setLinkGroups(groups);
// 同校院系子站("专业院系"下拉,《院系子站方案》):顶层站取其子站,
// 院系子站取兄弟站(含自身,对齐真实高校门户);跨校防御过滤
Integer anchorId = snapshot.getParentSiteId() == 0 ? site.getId() : snapshot.getParentSiteId();
List<PortalSiteBrief> colleges = new ArrayList<>();
for (CmsSite child : siteRepository.findByParentSiteIdAndStatusOrderByIdAsc(anchorId, 1)) {
if (site.getSchId() != null && !site.getSchId().equals(child.getSchId())) {
continue;
}
colleges.add(PortalSiteBrief.of(child));
}
snapshot.setColleges(colleges);
return snapshot;
}
/** 文章详情(已发布才可读;不存在/未发布缓存 null 哨兵 30s 防穿透) */
@Cacheable(cacheNames = CacheNames.ARTICLE, key = "#articleId")
public PortalArticleDetail articleDetail(Integer articleId) {
CmsArticle article = articleRepository.findById(articleId).orElse(null);
if (article == null || article.getStatus() == null
|| article.getStatus() != ArticleStatusEnum.PUBLISHED.getCode()) {
return null;
}
return PortalArticleDetail.of(article);
}
/**
* 栏目分页(已发布,置顶优先 + 发布时间倒序;key=categoryId:page)。
* D1-3:父栏目聚合其列表型子孙栏目的文章(高校惯例:文章挂叶子栏目,父栏目页展示汇总)。
*/
@Cacheable(cacheNames = CacheNames.LIST, key = "#categoryId + ':' + #page")
public PortalListData categoryPage(Integer categoryId, Integer page, Integer pageSize) {
CmsCategory category = categoryRepository.findById(categoryId).orElse(null);
if (category == null) {
return null;
}
int pageIndex = page == null || page < 1 ? 0 : page - 1;
Pageable pageable = PageRequest.of(pageIndex, pageSize == null || pageSize < 1 ? 10 : pageSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime")));
Page<CmsArticle> result = articleRepository.findBySiteIdAndCategoryIdInAndStatus(
category.getSiteId(), listCategoryIdsAggregatingChildren(category),
ArticleStatusEnum.PUBLISHED.getCode(), pageable);
PortalListData data = new PortalListData();
data.setCategoryId(categoryId);
data.setSiteId(category.getSiteId());
data.setTotal(result.getTotalElements());
data.setPage(pageIndex + 1);
data.setPageSize(pageable.getPageSize());
data.setTotalPages(result.getTotalPages());
for (CmsArticle a : result.getContent()) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
data.getItems().add(item);
}
return data;
}
/**
* 栏目自身 + 其列表型子孙栏目 id 集(D1-3 聚合用)。
* 一次性载入站点栏目(量级小),从目标栏目 BFS;单页/外链型子孙不纳入
* (其文章不属于列表语义,且外链栏目无文章)。
*/
private List<Integer> listCategoryIdsAggregatingChildren(CmsCategory category) {
List<Integer> ids = new ArrayList<>();
ids.add(category.getId());
Map<Integer, List<CmsCategory>> childrenMap = new HashMap<>();
for (CmsCategory c : categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(category.getSiteId())) {
if (c.getParentId() != null) {
childrenMap.computeIfAbsent(c.getParentId(), k -> new ArrayList<>()).add(c);
}
}
java.util.ArrayDeque<Integer> queue = new java.util.ArrayDeque<>(ids);
while (!queue.isEmpty()) {
Integer current = queue.poll();
for (CmsCategory child : childrenMap.getOrDefault(current, java.util.Collections.emptyList())) {
if (child.getType() != null
&& child.getType() == com.yunhai.cms.enums.CategoryTypeEnum.LIST.getCode()) {
ids.add(child.getId());
queue.add(child.getId());
}
}
}
return ids;
}
/** 站内搜索(title/summary LIKE,实时不缓存,MVP 不上 ES,方案 §2) */
public Page<PortalListData.Item> search(Integer siteId, String keyword, Integer page, Integer pageSize) {
int pageIndex = page == null || page < 1 ? 0 : page - 1;
Pageable pageable = PageRequest.of(pageIndex, pageSize == null || pageSize < 1 ? 10 : pageSize,
Sort.by(Sort.Order.desc("publishTime")));
// Containing 自带 %包裹与转义,勿手动再加 %(否则字面 % 参与匹配导致恒不命中)
String like = keyword == null ? "" : keyword.trim();
Page<CmsArticle> result = articleRepository
.findBySiteIdAndStatusAndTitleContainingOrSiteIdAndStatusAndSummaryContaining(
siteId, ArticleStatusEnum.PUBLISHED.getCode(), like,
siteId, ArticleStatusEnum.PUBLISHED.getCode(), like, pageable);
return result.map(a -> {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
});
}
/** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */
private String resolveTheme(String theme) {
if (themeRegistry.contains(theme)) {
return theme;
}
log.warn("皮肤包 {} 未注册,回退 default", theme);
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
}
/** config_json.theme_params 原始值(非对象形态按无参数处理) */
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params");
return raw instanceof Map ? (Map<?, ?>) raw : null;
}
}
@@ -0,0 +1,118 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.model.vo.SchoolVo;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 学校工作台(docs/09 §3.3,方案A 双层壳):
* ① listFor —— 学校卡片 = 顶层站点(主站),院系子站经 parentSiteId 归属;
* 权限语义与 {@link SiteService#listFor} 完全一致(超管全部 / 校级本校 / 无角色空)。
* ② schoolOptions —— 「添加学校」选择器:代理 UserBase oapi getGroupTree 过滤 type=2(学校组),
* 只绑定不跨系统写(D6-4);平台不可达降级空数组(CmsUserCenterService 同款)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SchoolService {
/** UserBase GroupTypeEnum.SCHOOL_GROUP:学校组节点类型 */
private static final int GROUP_TYPE_SCHOOL = 2;
private final SiteService siteService;
private final CmsArticleRepository articleRepository;
/** 学校工作台列表:当前用户可见的顶层站点逐站聚合成学校卡片 */
public List<SchoolVo> listFor(BoaUser user) {
List<SiteVo> sites = siteService.listFor(user);
List<SchoolVo> result = new ArrayList<>();
for (SiteVo top : sites) {
if (top.getParentSiteId() != null && top.getParentSiteId() > 0) {
continue;
}
SchoolVo vo = new SchoolVo();
vo.setSchId(top.getSchId());
vo.setSchName(top.getSchName());
vo.setMainSiteId(top.getId());
vo.setMainSiteCode(top.getCode());
vo.setMainSiteName(top.getName());
vo.setTheme(top.getTheme());
vo.setStatus(top.getStatus());
// 院系子站按 parentSiteId 归属(同权限范围内统计)
long articleCount = 0;
int siteCount = 1;
for (SiteVo s : sites) {
if (s.getParentSiteId() != null && top.getId().equals(s.getParentSiteId())) {
siteCount++;
}
}
for (SiteVo s : sites) {
boolean own = s.getId().equals(top.getId());
boolean child = s.getParentSiteId() != null && top.getId().equals(s.getParentSiteId());
if (own || child) {
articleCount += articleRepository.countBySiteId(s.getId());
}
}
vo.setSiteCount(siteCount);
vo.setArticleCount(articleCount);
result.add(vo);
}
result.sort((a, b) -> Integer.compare(a.getMainSiteId(), b.getMainSiteId()));
return result;
}
/** UserBase 学校选项(仅超管;添加学校=绑定已有学校,不新建) */
public List<SchoolVo.Option> schoolOptions(BoaUser user) {
siteService.requireSuperAdmin(user);
BoaGroup root;
try {
root = YschBaseOpenApi.getGroupTree();
} catch (Exception e) {
log.warn("[cms] 获取 UserBase 学校组树失败,学校选项降级为空: {}", e.getMessage());
return Collections.emptyList();
}
List<SchoolVo.Option> options = new ArrayList<>();
collectSchools(root, options);
return options;
}
private void collectSchools(BoaGroup node, List<SchoolVo.Option> out) {
if (node == null) {
return;
}
if (node.getType() != null && node.getType() == GROUP_TYPE_SCHOOL) {
out.add(new SchoolVo.Option(node.getId(), node.getName()));
}
if (node.getChildren() != null) {
for (BoaGroup child : node.getChildren()) {
collectSchools(child, out);
}
}
}
/** 工作台卡片挂载的站点摘要(供校名下拉/快速切换;权限复用 listFor) */
public Map<Integer, List<SiteVo>> sitesGroupedByMain(BoaUser user) {
List<SiteVo> sites = siteService.listFor(user);
Map<Integer, List<SiteVo>> grouped = new LinkedHashMap<>();
for (SiteVo s : sites) {
Integer mainId = (s.getParentSiteId() == null || s.getParentSiteId() <= 0)
? s.getId() : s.getParentSiteId();
grouped.computeIfAbsent(mainId, k -> new ArrayList<>()).add(s);
}
return grouped;
}
}
@@ -0,0 +1,267 @@
package com.yunhai.cms.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.cms.config.CmsAuthProperties;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.SiteSaveRequest;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.utils.ExtJsonUtils;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 站点与校级权限(方案 §7.2):
* ① 平台超管(BoaUser.type==1)→ 所有站点;② 学校角色(currentRole.schId)→ 仅本校站点(site.sch_id 匹配)。
* 所有管理端写操作入口先过 {@link #requireManageable}。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SiteService {
private final CmsSiteRepository siteRepository;
private final CmsAuthProperties authProperties;
private final ApplicationEventPublisher eventPublisher;
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
/** config_json 已知键约束(与前端 utils/siteConfigForm.ts 保持一致,docs/09 §0 键清单) */
private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper();
private static final Pattern HOST_PATTERN = Pattern.compile(
"^(\\*\\.)?[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$");
private static final Pattern URL_PATTERN = Pattern.compile("^https?://\\S+$");
private static final Pattern ASSET_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$");
private static final Pattern CONFIG_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
private static final Set<String> RESERVED_CONFIG_KEYS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */
public List<SiteVo> listFor(BoaUser user) {
List<CmsSite> sites = authProperties.isSuperAdmin(user)
? siteRepository.findAll()
: (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null
? java.util.Collections.emptyList()
: siteRepository.findBySchId(user.getCurrentRole().getSchId()));
Map<Integer, String> nameById = new java.util.HashMap<>();
for (CmsSite site : sites) {
nameById.put(site.getId(), site.getName());
}
List<SiteVo> result = new ArrayList<>();
for (CmsSite site : sites) {
SiteVo vo = SiteVo.of(site);
if (vo.getParentSiteId() != null && vo.getParentSiteId() > 0) {
vo.setParentName(nameById.get(vo.getParentSiteId()));
}
result.add(vo);
}
return result;
}
/** 校验可管理权限并返回站点;越权抛 FORBIDDEN */
public CmsSite requireManageable(BoaUser user, Integer siteId) {
CmsSite site = siteRepository.findById(siteId == null ? -1 : siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
if (!canManage(user, site)) {
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点");
}
return site;
}
public boolean canManage(BoaUser user, CmsSite site) {
if (authProperties.isSuperAdmin(user)) {
return true;
}
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
return schId != null && site.getSchId() != null && schId.equals(site.getSchId());
}
public void requireSuperAdmin(BoaUser user) {
if (!authProperties.isSuperAdmin(user)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作");
}
}
public CmsSite getByCode(String code) {
return siteRepository.findByCode(code).orElse(null);
}
public String configValue(CmsSite site, String key) {
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
return ExtJsonUtils.getString(config, key);
}
/** 站点保存(仅超管;MVP 1-2 校以初始化脚本为主,本接口为后台兜底) */
@Transactional
public SiteVo save(BoaUser user, SiteSaveRequest req) {
requireSuperAdmin(user);
CmsSite site;
if (req.getId() == null) {
if (siteRepository.findByCode(req.getCode()).isPresent()) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点编码已存在");
}
site = new CmsSite();
site.setCode(req.getCode());
} else {
site = siteRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
}
validateParent(req, site);
// B-8:顶层站 schId 必填——canManage 按 currentRole.schId ↔ site.schId 匹配,
// 顶层站空 schId 建成即"校级角色不可见/不可管"(院系子站已在 validateParent 继承主站 schId,不受影响)
if ((site.getParentSiteId() == null || site.getParentSiteId() == 0) && req.getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "顶层站点必须指定学校ID(schId),否则校级角色无法管理");
}
site.setSchId(req.getSchId());
site.setSchName(req.getSchName());
site.setName(req.getName());
String resolvedTheme = StringUtils.hasText(req.getTheme())
? req.getTheme() : com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
if (!themeRegistry.contains(resolvedTheme)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + resolvedTheme);
}
site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson());
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site"));
return SiteVo.of(site);
}
/**
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
* SPA 不再手拼 configJson 中的该节点。
*/
private String mergeThemeParams(SiteSaveRequest req, String theme) {
if (req.getThemeParams() == null) {
return req.getConfigJson();
}
Map<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
Map<String, String> filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams());
if (filtered.isEmpty()) {
config.remove("theme_params");
} else {
config.put("theme_params", filtered);
}
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
}
}
/**
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
*/
private void validateParent(SiteSaveRequest req, CmsSite site) {
Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId();
if (parentSiteId <= 0) {
site.setParentSiteId(0);
return;
}
if (site.getId() != null && site.getId().equals(parentSiteId)) {
throw new BizException(ErrorCode.PARAM_ERROR, "上级站点不能是自身");
}
CmsSite parent = siteRepository.findById(parentSiteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在"));
if (parent.getParentSiteId() != null && parent.getParentSiteId() > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "院系子站下不允许再挂子站");
}
if (req.getSchId() == null) {
req.setSchId(parent.getSchId());
req.setSchName(parent.getSchName());
} else if (parent.getSchId() != null && !req.getSchId().equals(parent.getSchId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "院系子站与主站不属于同一学校");
}
boolean wasTopLevel = site.getId() == null || site.getParentSiteId() == null
|| site.getParentSiteId() == 0;
if (wasTopLevel && site.getId() != null && siteRepository.existsByParentSiteId(site.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "该站点下已有院系子站,不能改为院系子站");
}
site.setParentSiteId(parentSiteId);
}
/**
* config_json 校验(docs/09 §3.1,替代旧「空 Map 且长度>2」判定):
* ① 必须是 JSON 对象(修复 A-09:带空白空对象误判);② 键名仅字母/数字/下划线;
* ③ 已知键按类型校验(domain 域名 / public_base_url 完整 URL / logo、favicon 资产路径)。
* 系统托管键 theme_params 直通;非字符串值(wechat 等预留对象)不校验原样放行——
* 只拦新脏数据,不做严格 schema 拒绝未知键(D6-2)。
*/
private void validateConfigJson(String configJson) {
if (!StringUtils.hasText(configJson)) {
return;
}
JsonNode node;
try {
node = CONFIG_MAPPER.readTree(configJson);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置不是合法的 JSON");
}
if (!node.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置必须是 JSON 对象");
}
node.fields().forEachRemaining(entry -> {
String key = entry.getKey();
if (RESERVED_CONFIG_KEYS.contains(key)) {
return;
}
if (!CONFIG_KEY_PATTERN.matcher(key).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"站点配置键名不合法:" + key + "(仅限字母/数字/下划线,且以字母或下划线开头)");
}
JsonNode value = entry.getValue();
if (!value.isTextual()) {
return;
}
String text = value.asText();
if (!StringUtils.hasText(text)) {
return;
}
if (text.length() > CONFIG_VALUE_MAX_LEN) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)");
}
switch (key) {
case "domain":
if (!HOST_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"绑定域名格式不正确:" + text + "(示例 www.xxx.edu.cn,不带协议)");
}
break;
case "public_base_url":
if (!URL_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
"对外基地址必须是完整的 http(s):// 地址:" + text);
}
break;
case "logo":
case "favicon":
if (!ASSET_PATTERN.matcher(text).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
key + " 必须是站点上传路径(/ 开头)或 http(s) 地址");
}
break;
default:
break;
}
});
}
}
@@ -0,0 +1,62 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.model.vo.ThemeVo;
import com.yunhai.cms.theme.ThemeInfo;
import com.yunhai.cms.theme.ThemeRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
/**
* 主题管理(方案 §5.1/§5.2):主题列表(含参数 schema)供管理端卡片选择与
* 参数表单动态生成;screenshot 与预览资产经 /api/theme/{name}/asset/** 代理。
*/
@Service
@RequiredArgsConstructor
public class ThemeService {
private final ThemeRegistry themeRegistry;
/** 可用主题(default 优先,其余按名称排序),管理端建站向导与站点编辑共用 */
public List<ThemeVo> list() {
List<ThemeVo> result = new ArrayList<>();
for (ThemeInfo info : themeRegistry.list()) {
result.add(ThemeVo.of(info));
}
return result;
}
/** 未注册主题返回 null(白名单判断用) */
public ThemeInfo get(String name) {
return themeRegistry.get(name);
}
/** 未注册主题抛 PARAM_ERROR(预览接口明确 400 语义,不静默回退,方案 §5.5/T27) */
public ThemeInfo requireRegistered(String name) {
ThemeInfo info = themeRegistry.get(name);
if (info == null) {
throw new com.yunhai.platform.apistd.BizException(
com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
}
return info;
}
/**
* 提取预览 p_* 前缀参数并按主题 schema 过滤(方案 §5.5):
* key 为 theme.json 声明过的,# 由前端 URLSearchParams 自动编码为 %23(容器已解码)。
*/
public java.util.Map<String, String> extractPreviewParams(ThemeInfo info,
java.util.Map<String, String[]> parameterMap) {
java.util.Map<String, Object> raw = new java.util.LinkedHashMap<>();
if (parameterMap != null) {
for (java.util.Map.Entry<String, String[]> e : parameterMap.entrySet()) {
if (e.getKey().startsWith("p_") && e.getValue() != null && e.getValue().length > 0) {
raw.put(e.getKey().substring(2), e.getValue()[0]);
}
}
}
return info.filterValidValues(raw);
}
}
@@ -0,0 +1,58 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.repository.CmsArticleRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import javax.annotation.PreDestroy;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
/**
* 浏览量异步累加(方案 §5.2):详情渲染 fire-and-forget UPDATE,失败忽略;
* 量大后改缓存批量聚合。单线程队列足够 1-2 校门户流量。
*/
@Slf4j
@Service
public class ViewCounterService {
private final CmsArticleRepository articleRepository;
private final TransactionTemplate transactionTemplate;
private final ExecutorService executor = Executors.newSingleThreadExecutor(r -> {
Thread thread = new Thread(r, "cms-view-counter");
thread.setDaemon(true);
return thread;
});
public ViewCounterService(CmsArticleRepository articleRepository,
TransactionTemplate transactionTemplate) {
this.articleRepository = articleRepository;
this.transactionTemplate = transactionTemplate;
}
public void increment(Integer articleId) {
if (articleId == null) {
return;
}
executor.execute(() -> {
try {
transactionTemplate.executeWithoutResult(
status -> articleRepository.incrementViewCount(articleId));
} catch (Exception e) {
log.debug("浏览量累加失败 articleId={}(忽略)", articleId, e);
}
});
}
@PreDestroy
public void shutdown() {
executor.shutdown();
try {
executor.awaitTermination(3, TimeUnit.SECONDS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}