init local code
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user