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; private final AttachmentService attachmentService; public List list(BoaUser user, Integer siteId, String position) { siteService.requireManageable(user, siteId); List banners = StringUtils.hasText(position) ? bannerRepository.findBySiteIdAndPositionOrderBySortNoAscIdAsc(siteId, position) : bannerRepository.findBySiteIdOrderByPositionAscSortNoAscIdAsc(siteId); List 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()); // docs/20 D-3/D-6:topic/event 位扩展字段透传(其余位一并落库,渲染端按位消费) banner.setDescription(req.getDescription()); banner.setCtaText(req.getCtaText()); banner.setCtaUrl(req.getCtaUrl()); banner.setEventDate(req.getEventDate()); banner.setLocation(req.getLocation()); banner = bannerRepository.save(banner); // B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等 attachmentService.release("banner", banner.getId()); attachmentService.markUsed(site.getId(), req.getImageUrl(), "banner", banner.getId()); 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); attachmentService.release("banner", banner.getId()); 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, "跳转栏目不存在")); } } }