diff --git a/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java b/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java index 4a7d94a..faa5a3a 100644 --- a/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java +++ b/src/main/java/com/yunhai/cms/controller/api/AttachmentController.java @@ -26,13 +26,14 @@ public class AttachmentController { private final AttachmentService attachmentService; - /** 上传(multipart:file 必填;articleId 可空) */ + /** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */ @PostMapping("/upload") public AttachmentVo upload(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId, @RequestParam(value = "articleId", required = false) Integer articleId, + @RequestParam(value = "scene", required = false) String scene, @RequestParam("file") MultipartFile file) { - return attachmentService.upload(authUserWithDept, siteId, articleId, file); + return attachmentService.upload(authUserWithDept, siteId, articleId, scene, file); } /** 附件列表(orphanOnly=true 仅孤儿) */ diff --git a/src/main/java/com/yunhai/cms/entity/CmsAttachment.java b/src/main/java/com/yunhai/cms/entity/CmsAttachment.java index 41e6d1a..02908b6 100644 --- a/src/main/java/com/yunhai/cms/entity/CmsAttachment.java +++ b/src/main/java/com/yunhai/cms/entity/CmsAttachment.java @@ -10,14 +10,17 @@ import javax.persistence.Table; /** * CMS 附件(正文图片 + 文档附件 + 轮播图/友链 Logo 复用同一上传链路,方案 §7.3)。 - * 文章删除时附件记录保留、article_id 置空成"孤儿",由附件管理页清理。 + * 引用模型(B2):articleId 仅服务文章编辑器回填链路;非文章场景(轮播/友链/站点配置) + * 用 refType/refId 标记。未使用 = 无文章引用且无场景引用(articleId 与 refType 均空); + * 文章删除仅 article_id 置空,场景引用由各保存点按 webUrl 反查回写/解引用。 */ @Getter @Setter @Entity @Table(name = "cms_attachment", indexes = { @Index(name = "idx_att_article", columnList = "article_id"), - @Index(name = "idx_att_site", columnList = "site_id")}) + @Index(name = "idx_att_site", columnList = "site_id"), + @Index(name = "idx_att_ref", columnList = "ref_type, ref_id")}) public class CmsAttachment extends BaseEntity { /** 所属站点 */ @@ -28,6 +31,14 @@ public class CmsAttachment extends BaseEntity { @Column(name = "article_id") private Integer articleId; + /** 场景引用类型(B2):banner=轮播 / link=友链 / site=站点配置;空=无场景引用 */ + @Column(name = "ref_type", length = 16) + private String refType; + + /** 场景引用对象 ID(轮播/友链/站点各自业务主键) */ + @Column(name = "ref_id") + private Integer refId; + /** 原始文件名 */ @Column(name = "file_name", nullable = false, length = 255) private String fileName; diff --git a/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java b/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java index 25ba7b6..4eb748b 100644 --- a/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java +++ b/src/main/java/com/yunhai/cms/model/vo/AttachmentVo.java @@ -15,6 +15,9 @@ public class AttachmentVo { private Integer articleId; + /** 场景引用类型(B2):banner/link/site;空=无场景引用 */ + private String refType; + private String fileName; /** Web 访问 URL(编辑器/封面/轮播直接引用) */ @@ -26,7 +29,7 @@ public class AttachmentVo { private String createTime; - /** 是否孤儿(文章已删除/未关联) */ + /** 是否未使用(B2:无文章引用且无场景引用) */ private boolean orphan; public static AttachmentVo of(CmsAttachment a) { @@ -34,12 +37,13 @@ public class AttachmentVo { vo.setId(a.getId()); vo.setSiteId(a.getSiteId()); vo.setArticleId(a.getArticleId()); + vo.setRefType(a.getRefType()); vo.setFileName(a.getFileName()); vo.setUrl(a.getWebUrl()); vo.setFileSize(a.getFileSize()); vo.setMimeType(a.getMimeType()); vo.setCreateTime(a.getCreateTime() == null ? null : a.getCreateTime().toString()); - vo.setOrphan(a.getArticleId() == null); + vo.setOrphan(a.getArticleId() == null && a.getRefType() == null); return vo; } } diff --git a/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java b/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java index 53baedc..93a4367 100644 --- a/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java +++ b/src/main/java/com/yunhai/cms/repository/CmsAttachmentRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface CmsAttachmentRepository extends JpaRepository { @@ -19,10 +20,15 @@ public interface CmsAttachmentRepository extends JpaRepository findBySiteIdAndArticleIdIsNull(Integer siteId, Pageable pageable); + /** 未使用附件(B2 语义:无文章引用且无场景引用) */ + Page findBySiteIdAndArticleIdIsNullAndRefTypeIsNull(Integer siteId, Pageable pageable); - long countBySiteIdAndArticleIdIsNull(Integer siteId); + long countBySiteIdAndArticleIdIsNullAndRefTypeIsNull(Integer siteId); + + /** 按 webUrl 反查(B2 保存点引用回写;webUrl 全站唯一由 uuid32 文件名保证,查询仍带 siteId 防御) */ + Optional findBySiteIdAndWebUrl(Integer siteId, String webUrl); + + List findByRefTypeAndRefId(String refType, Integer refId); @Modifying @Query("update CmsAttachment a set a.articleId = :articleId where a.id in :ids") diff --git a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java index 8a587a2..3ebbda2 100644 --- a/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java +++ b/src/main/java/com/yunhai/cms/service/impl/AttachmentService.java @@ -56,6 +56,9 @@ public class AttachmentService { private static final List IMAGE_EXTS = Arrays.asList( "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"); + /** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 */ + private static final List ALLOWED_SCENES = Arrays.asList("banner", "link", "site"); + private final CmsAttachmentRepository attachmentRepository; private final SiteService siteService; @@ -66,10 +69,13 @@ public class AttachmentService { @Value("${FILE_WEB_PATH:/cms/upload/}") private String fileWebPath; - /** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空 */ + /** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site,B2 初步标记) */ @Transactional - public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, MultipartFile file) { + public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) { CmsSite site = siteService.requireManageable(user, siteId); + if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) { + throw new BizException(ErrorCode.PARAM_ERROR, "scene 非法:" + scene + "(仅 banner/link/site)"); + } if (file == null || file.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空"); } @@ -101,6 +107,7 @@ public class AttachmentService { CmsAttachment attachment = new CmsAttachment(); attachment.setSiteId(site.getId()); attachment.setArticleId(articleId); + attachment.setRefType(StringUtils.hasText(scene) ? scene : null); attachment.setFileName(originalName); attachment.setFilePath(relativePath); attachment.setWebUrl(webUrl); @@ -110,14 +117,14 @@ public class AttachmentService { return AttachmentVo.of(attachment); } - /** 附件列表(orphanOnly=true 时仅孤儿,附件管理页清理用) */ + /** 附件列表(unusedOnly=true 时仅未使用:无文章引用且无场景引用,附件管理页清理用) */ public Map 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 result = orphanOnly - ? attachmentRepository.findBySiteIdAndArticleIdIsNull(siteId, + ? attachmentRepository.findBySiteIdAndArticleIdIsNullAndRefTypeIsNull(siteId, PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime"))) : attachmentRepository.findBySiteId(siteId, PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime"))); @@ -128,10 +135,55 @@ public class AttachmentService { Map map = new HashMap<>(); map.put("total", result.getTotalElements()); map.put("list", list); - map.put("orphanCount", attachmentRepository.countBySiteIdAndArticleIdIsNull(siteId)); + map.put("unusedCount", attachmentRepository.countBySiteIdAndArticleIdIsNullAndRefTypeIsNull(siteId)); return map; } + /** + * 场景引用回写(B2):各保存点按 webUrl 反查并标记(webUrl 全站唯一由 uuid32 文件名保证)。 + * 外链地址/找不到记录时静默跳过;幂等(重复标记同一引用无副作用)。 + */ + @Transactional + public void markUsed(Integer siteId, String webUrl, String refType, Integer refId) { + if (!StringUtils.hasText(webUrl)) { + return; + } + attachmentRepository.findBySiteIdAndWebUrl(siteId, webUrl).ifPresent(a -> { + a.setRefType(refType); + a.setRefId(refId); + attachmentRepository.save(a); + }); + } + + /** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */ + @Transactional + public void release(String refType, Integer refId) { + List refs = attachmentRepository.findByRefTypeAndRefId(refType, refId); + for (CmsAttachment a : refs) { + a.setRefType(null); + a.setRefId(null); + } + attachmentRepository.saveAll(refs); + } + + /** + * 站点配置引用回写(B2):config_json 中 logo/favicon 指向本地上传路径(FILE_WEB_PATH 前缀)时 + * 标记 ref_type=site。先整体解引用再回填当前值,天然覆盖换图/清空两种编辑路径。 + */ + @Transactional + public void markSiteConfigUsed(Integer siteId, Map config) { + release("site", siteId); + if (config == null || !StringUtils.hasText(fileWebPath)) { + return; + } + for (String key : new String[]{"logo", "favicon"}) { + Object value = config.get(key); + if (value instanceof String && ((String) value).startsWith(fileWebPath)) { + markUsed(siteId, (String) value, "site", siteId); + } + } + } + /** 删除附件记录(不删物理文件,运维定期清理磁盘,避免误删被引用文件) */ @Transactional public void delete(BoaUser user, DeleteRequest req) { diff --git a/src/main/java/com/yunhai/cms/service/impl/BannerService.java b/src/main/java/com/yunhai/cms/service/impl/BannerService.java index b8a08d0..902e410 100644 --- a/src/main/java/com/yunhai/cms/service/impl/BannerService.java +++ b/src/main/java/com/yunhai/cms/service/impl/BannerService.java @@ -34,6 +34,7 @@ public class BannerService { 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); @@ -81,6 +82,9 @@ public class BannerService { } banner.setStatus(req.getStatus() == null ? 1 : req.getStatus()); 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); } @@ -94,6 +98,7 @@ public class BannerService { throw new BizException(ErrorCode.FORBIDDEN, "无权删除该轮播"); } bannerRepository.delete(banner); + attachmentService.release("banner", banner.getId()); eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner")); } diff --git a/src/main/java/com/yunhai/cms/service/impl/LinkService.java b/src/main/java/com/yunhai/cms/service/impl/LinkService.java index f21f6d8..a686e22 100644 --- a/src/main/java/com/yunhai/cms/service/impl/LinkService.java +++ b/src/main/java/com/yunhai/cms/service/impl/LinkService.java @@ -29,6 +29,7 @@ public class LinkService { private final CmsLinkRepository linkRepository; private final SiteService siteService; private final ApplicationEventPublisher eventPublisher; + private final AttachmentService attachmentService; public List list(BoaUser user, Integer siteId) { siteService.requireManageable(user, siteId); @@ -66,6 +67,11 @@ public class LinkService { link.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo()); link.setStatus(req.getStatus() == null ? 1 : req.getStatus()); link = linkRepository.save(link); + // B2 附件引用:先解引用,logoUrl 非空才回写(编辑换图/清空 logo 旧图均回未使用);幂等 + attachmentService.release("link", link.getId()); + if (StringUtils.hasText(req.getLogoUrl())) { + attachmentService.markUsed(site.getId(), req.getLogoUrl(), "link", link.getId()); + } eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link")); return LinkVo.of(link); } @@ -79,6 +85,7 @@ public class LinkService { throw new BizException(ErrorCode.FORBIDDEN, "无权删除该友链"); } linkRepository.delete(link); + attachmentService.release("link", link.getId()); eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link")); } } diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index bc8b71f..81efbee 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -40,6 +40,14 @@ public class SiteService { private final ApplicationEventPublisher eventPublisher; private final com.yunhai.cms.theme.ThemeRegistry themeRegistry; + /** + * B2 附件引用:站点配置 logo/favicon 回写场景引用。 + * 字段注入 + @Lazy 打破与 AttachmentService 的构造器循环依赖(AttachmentService → SiteService)。 + */ + @org.springframework.beans.factory.annotation.Autowired + @org.springframework.context.annotation.Lazy + private AttachmentService attachmentService; + /** config_json 已知键约束(与前端 utils/siteConfigForm.ts 保持一致,docs/09 §0 键清单) */ private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper(); private static final Pattern HOST_PATTERN = Pattern.compile( @@ -140,6 +148,8 @@ public class SiteService { site.setConfigJson(mergeThemeParams(req, resolvedTheme)); site.setStatus(req.getStatus() == null ? 1 : req.getStatus()); site = siteRepository.save(site); + // B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值) + attachmentService.markSiteConfigUsed(site.getId(), ExtJsonUtils.parse(site.getConfigJson())); eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site")); return SiteVo.of(site); } diff --git a/src/main/resources/sql/cms_init.sql b/src/main/resources/sql/cms_init.sql index 20f5c85..7ad828a 100644 --- a/src/main/resources/sql/cms_init.sql +++ b/src/main/resources/sql/cms_init.sql @@ -80,6 +80,8 @@ CREATE TABLE IF NOT EXISTS `cms_attachment` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `site_id` INT NOT NULL COMMENT '所属站点', `article_id` INT NULL COMMENT '关联文章(编辑器图片保存时回填)', + `ref_type` VARCHAR(16) NULL COMMENT '场景引用类型:banner/link/site(B2)', + `ref_id` INT NULL COMMENT '场景引用对象ID(B2)', `file_name` VARCHAR(255) NOT NULL COMMENT '原始文件名', `file_path` VARCHAR(500) NOT NULL COMMENT '存储路径(相对 FILE_PATH)', `web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL', @@ -91,7 +93,8 @@ CREATE TABLE IF NOT EXISTS `cms_attachment` ( `update_time` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_att_article` (`article_id`), - KEY `idx_att_site` (`site_id`) + KEY `idx_att_site` (`site_id`), + KEY `idx_att_ref` (`ref_type`, `ref_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 附件'; -- 5. 轮播/推荐位表(首页 banner、图片新闻位等) diff --git a/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java index 59f7f4c..d5b1bde 100644 --- a/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/ArticleServiceIT.java @@ -225,7 +225,7 @@ class ArticleServiceIT extends CmsIntegrationTestBase { } @Test - @DisplayName("删除:文章删除且附件转孤儿(article_id 置空,方案 §9.1 附件管理)") + @DisplayName("删除:文章删除且附件转未使用(article_id 置空且无场景引用,方案 §9.1 / B2 语义)") void delete_orphansAttachments() { Integer id = articleService.save(TestUsers.superAdmin(), saveReq("待删文")); CmsAttachment att = new CmsAttachment(); @@ -244,8 +244,10 @@ class ArticleServiceIT extends CmsIntegrationTestBase { assertThat(articleRepository.findById(id)).isEmpty(); // unbindArticle 为 JPQL 批量更新,清一级缓存后回读库值 entityManager.clear(); - assertThat(attachmentRepository.findById(att.getId()) - .orElseThrow(IllegalStateException::new).getArticleId()).isNull(); + CmsAttachment released = attachmentRepository.findById(att.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(released.getArticleId()).isNull(); + assertThat(released.getRefType()).as("B2:文章删除后无场景引用 → 未使用").isNull(); } @Test diff --git a/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java index e40fc4d..751818d 100644 --- a/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/AttachmentServiceIT.java @@ -89,7 +89,7 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { @DisplayName("上传图片:按 cms/{siteCode}/{yyyyMM}/{uuid32}.ext 落盘并落库,webUrl 拼接") void upload_imageStoresUnderSiteMonthDir() { AttachmentVo vo = attachmentService.upload( - TestUsers.superAdmin(), siteId, null, imageFile("jpg")); + TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")); com.yunhai.cms.entity.CmsAttachment entity = attachmentRepository.findById(vo.getId()) .orElseThrow(IllegalStateException::new); @@ -111,25 +111,25 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { void upload_whitelistedDocumentTypes() { for (String name : new String[]{"通知.pdf", "数据.xlsx", "材料.zip", "讲话.mp4", "说明.txt"}) { assertThat(attachmentService.upload( - TestUsers.superAdmin(), siteId, null, file(name, "application/octet-stream")) + TestUsers.superAdmin(), siteId, null, null, file(name, "application/octet-stream")) .getId()).isNotNull(); } } @Test - @DisplayName("附件列表:orphanOnly 过滤孤儿并返回 orphanCount") + @DisplayName("附件列表:unusedOnly 过滤未使用并返回 unusedCount(B2 语义:无文章引用且无场景引用)") @SuppressWarnings("unchecked") void page_orphanFilterAndCount() { - attachmentService.upload(TestUsers.superAdmin(), siteId, 1, imageFile("jpg")); - attachmentService.upload(TestUsers.superAdmin(), siteId, null, imageFile("png")); + attachmentService.upload(TestUsers.superAdmin(), siteId, 1, null, imageFile("jpg")); + attachmentService.upload(TestUsers.superAdmin(), siteId, null, null, imageFile("png")); Map all = attachmentService.page(TestUsers.superAdmin(), siteId, false, 1, 10); assertThat(all.get("total")).isEqualTo(2L); - assertThat(all.get("orphanCount")).isEqualTo(1L); + assertThat(all.get("unusedCount")).isEqualTo(1L); - Map orphans = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10); - assertThat(orphans.get("total")).isEqualTo(1L); - List list = (List) orphans.get("list"); + Map unused = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10); + assertThat(unused.get("total")).isEqualTo(1L); + List list = (List) unused.get("list"); assertThat(list).extracting(AttachmentVo::getFileName).containsExactly("图.png"); } @@ -140,16 +140,16 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { void upload_dangerousExtensionsRejected() { for (String name : new String[]{"木马.exe", "脚本.bat", "图.svg"}) { assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, file(name, "application/octet-stream"))) + TestUsers.superAdmin(), siteId, null, null, file(name, "application/octet-stream"))) .isInstanceOf(BizException.class) .hasMessageContaining("不支持的文件类型"); } assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, file("noext", "application/octet-stream"))) + TestUsers.superAdmin(), siteId, null, null, file("noext", "application/octet-stream"))) .isInstanceOf(BizException.class) .hasMessageContaining("不支持的文件类型"); assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, file("伪装.jpg.exe", "image/jpeg"))) + TestUsers.superAdmin(), siteId, null, null, file("伪装.jpg.exe", "image/jpeg"))) .isInstanceOf(BizException.class) .hasMessageContaining("不支持的文件类型"); } @@ -162,21 +162,21 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { exeBytes[0] = 'M'; exeBytes[1] = 'Z'; assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, + TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile("file", "照片.jpg", "image/jpeg", exeBytes))) .isInstanceOf(BizException.class) .hasMessageContaining("不是有效图片"); // 真实图片字节 + application/octet-stream → Content-Type 校验拒绝 assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, + TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile("file", "照片.jpg", "application/octet-stream", tinyImage("jpg")))) .isInstanceOf(BizException.class) .hasMessageContaining("Content-Type"); // 纯文本改名 .png(Content-Type 伪装到位)→ 魔数校验拒绝 assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, + TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile("file", "文档.png", "image/png", "not-an-image".getBytes(StandardCharsets.UTF_8)))) .isInstanceOf(BizException.class) @@ -184,7 +184,7 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { // webp 后缀但魔数头错误 → 拒绝 assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, + TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile("file", "动图.webp", "image/webp", "RIFFxxxxGIF8".getBytes(StandardCharsets.UTF_8)))) .isInstanceOf(BizException.class) @@ -196,12 +196,12 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { void upload_realImagesAccepted() { for (String format : new String[]{"jpg", "png", "gif", "bmp"}) { assertThat(attachmentService.upload( - TestUsers.superAdmin(), siteId, null, imageFile(format)).getId()).isNotNull(); + TestUsers.superAdmin(), siteId, null, null, imageFile(format)).getId()).isNotNull(); } - assertThat(attachmentService.upload(TestUsers.superAdmin(), siteId, null, + assertThat(attachmentService.upload(TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile("file", "动图.webp", "image/webp", webpBytes())) .getId()).isNotNull(); - assertThat(attachmentService.upload(TestUsers.superAdmin(), siteId, null, + assertThat(attachmentService.upload(TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile("file", "图标.ico", "image/x-icon", icoBytes())) .getId()).isNotNull(); } @@ -210,19 +210,19 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { @DisplayName("空文件/不存在站点/越权站点上传被拒(异常分支)") void upload_invalidInputsRejected() { assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), siteId, null, new MockMultipartFile( + TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile( "file", "空.jpg", "image/jpeg", new byte[0]))) .isInstanceOf(BizException.class) .hasMessageContaining("不能为空"); assertThatThrownBy(() -> attachmentService.upload( - TestUsers.superAdmin(), 99999, null, file("a.jpg", "image/jpeg"))) + TestUsers.superAdmin(), 99999, null, null, file("a.jpg", "image/jpeg"))) .isInstanceOf(BizException.class) .hasMessageContaining("站点不存在"); // 学校2 编辑不能向学校1 站点上传(多租户) assertThatThrownBy(() -> attachmentService.upload( - TestUsers.schoolUser(2), siteId, null, file("越权.jpg", "image/jpeg"))) + TestUsers.schoolUser(2), siteId, null, null, file("越权.jpg", "image/jpeg"))) .isInstanceOf(BizException.class) .hasMessageContaining("无权"); } @@ -231,7 +231,7 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { @DisplayName("删除附件记录:本站成功(物理文件留待运维清理),他站 FORBIDDEN") void delete_siteScoped() { Integer attId = attachmentService.upload( - TestUsers.superAdmin(), siteId, null, imageFile("jpg")).getId(); + TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")).getId(); DeleteRequest wrongSite = new DeleteRequest(); wrongSite.setSiteId(persistSite("demo2", 2, "演示站点二").getId()); @@ -246,4 +246,101 @@ class AttachmentServiceIT extends CmsIntegrationTestBase { attachmentService.delete(TestUsers.superAdmin(), del); assertThat(attachmentRepository.findById(attId)).isEmpty(); } + + // ===== B2 附件引用模型(ref_type/ref_id 场景引用,消灭孤儿误报)===== + + @Test + @DisplayName("B2: 上传带 scene 落 refType 初步标记(banner/link/site 白名单)") + void upload_withScene_marksRefType() { + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "banner", imageFile("jpg")); + CmsAttachment entity = attachmentRepository.findById(vo.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(entity.getRefType()).isEqualTo("banner"); + assertThat(entity.getRefId()).as("refId 由保存点反查回写,上传时为空").isNull(); + assertThat(vo.getRefType()).isEqualTo("banner"); + + assertThat(attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "link", imageFile("png")).getRefType()) + .isEqualTo("link"); + } + + @Test + @DisplayName("B2: 上传不带 scene 保持未使用(refType/refId 均空)") + void upload_withoutScene_staysUnused() { + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")); + CmsAttachment entity = attachmentRepository.findById(vo.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(entity.getRefType()).isNull(); + assertThat(entity.getRefId()).isNull(); + assertThat(vo.isOrphan()).as("无文章引用且无场景引用 = 未使用").isTrue(); + } + + @Test + @DisplayName("B2: 上传 scene 非白名单值被拒") + void upload_invalidSceneRejected() { + assertThatThrownBy(() -> attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "hack", imageFile("jpg"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("scene"); + } + + @Test + @DisplayName("B2: markUsed 后附件不再是未使用(不出现在 unusedOnly 列表)") + @SuppressWarnings("unchecked") + void markUsed_banner_attachmentNotOrphan() { + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")); + + attachmentService.markUsed(siteId, vo.getUrl(), "banner", 1001); + + Map unused = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10); + assertThat(unused.get("total")).isEqualTo(0L); + assertThat(unused.get("unusedCount")).isEqualTo(0L); + CmsAttachment marked = attachmentRepository.findById(vo.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(marked.getRefType()).isEqualTo("banner"); + assertThat(marked.getRefId()).isEqualTo(1001); + assertThat(AttachmentVo.of(marked).isOrphan()).isFalse(); + } + + @Test + @DisplayName("B2: release 后附件回到未使用(模拟轮播删除解引用)") + @SuppressWarnings("unchecked") + void release_bannerDeleted_backToUnused() { + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")); + attachmentService.markUsed(siteId, vo.getUrl(), "banner", 1001); + + attachmentService.release("banner", 1001); + + Map afterRelease = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10); + assertThat(afterRelease.get("total")).isEqualTo(1L); + assertThat(afterRelease.get("unusedCount")).isEqualTo(1L); + List list = (List) afterRelease.get("list"); + assertThat(list).extracting(AttachmentVo::getId).containsExactly(vo.getId()); + } + + @Test + @DisplayName("B2: markUsed/release 幂等(重复调用不报错不重复标记);外链 URL 静默跳过") + void markUsed_idempotent_andForeignUrlSkipped() { + AttachmentVo vo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")); + + attachmentService.markUsed(siteId, vo.getUrl(), "link", 7); + attachmentService.markUsed(siteId, vo.getUrl(), "link", 7); + CmsAttachment entity = attachmentRepository.findById(vo.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(entity.getRefType()).isEqualTo("link"); + assertThat(attachmentRepository.count()).isEqualTo(1L); + + // 非 CMS 上传的外链地址:无对应记录,静默跳过不报错 + attachmentService.markUsed(siteId, "https://cdn.example.com/x.png", "link", 7); + attachmentService.release("link", 99999); + attachmentService.release("link", 7); + attachmentService.release("link", 7); + assertThat(attachmentRepository.findById(vo.getId()) + .orElseThrow(IllegalStateException::new).getRefType()).isNull(); + } } diff --git a/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java index 5123135..30a7a6a 100644 --- a/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/BannerServiceIT.java @@ -210,4 +210,82 @@ class BannerServiceIT extends CmsIntegrationTestBase { b.setStatus(1); return b; } + + // ===== B2 附件引用模型(保存轮播回写 attachment 引用)===== + + @Autowired + private AttachmentService attachmentService; + + /** 1x1 真实 png(上传需过魔数校验) */ + private org.springframework.mock.web.MockMultipartFile pngFile() { + java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try { + javax.imageio.ImageIO.write(img, "png", out); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + return new org.springframework.mock.web.MockMultipartFile("file", "图.png", "image/png", out.toByteArray()); + } + + @Test + @DisplayName("B2: 保存轮播回写 attachment.ref_type=banner, ref_id=bannerId") + void save_writesAttachmentRef() { + com.yunhai.cms.model.vo.AttachmentVo img = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "banner", pngFile()); + + BannerSaveRequest r = req(3, "https://www.example.com"); + r.setImageUrl(img.getUrl()); + BannerVo vo = bannerService.save(TestUsers.superAdmin(), r); + + com.yunhai.cms.entity.CmsAttachment att = attachmentRepository.findById(img.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(att.getRefType()).isEqualTo("banner"); + assertThat(att.getRefId()).isEqualTo(vo.getId()); + } + + @Test + @DisplayName("B2: 编辑换图旧图解引用、新图标记(旧图回未使用)") + void save_editChangeImage_releasesOldMarksNew() { + com.yunhai.cms.model.vo.AttachmentVo img1 = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "banner", pngFile()); + com.yunhai.cms.model.vo.AttachmentVo img2 = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "banner", pngFile()); + + BannerSaveRequest r1 = req(3, "https://www.example.com"); + r1.setImageUrl(img1.getUrl()); + BannerVo vo = bannerService.save(TestUsers.superAdmin(), r1); + + BannerSaveRequest r2 = req(3, "https://www.example.com"); + r2.setId(vo.getId()); + r2.setImageUrl(img2.getUrl()); + bannerService.save(TestUsers.superAdmin(), r2); + + assertThat(attachmentRepository.findById(img1.getId()) + .orElseThrow(IllegalStateException::new).getRefType()).as("旧图解引用").isNull(); + assertThat(attachmentRepository.findById(img2.getId()) + .orElseThrow(IllegalStateException::new).getRefType()).isEqualTo("banner"); + } + + @Test + @DisplayName("B2: 删除轮播解引用(attachment 回未使用)") + void delete_releasesAttachmentRef() { + com.yunhai.cms.model.vo.AttachmentVo img = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "banner", pngFile()); + BannerSaveRequest r = req(3, "https://www.example.com"); + r.setImageUrl(img.getUrl()); + BannerVo vo = bannerService.save(TestUsers.superAdmin(), r); + assertThat(attachmentRepository.findById(img.getId()) + .orElseThrow(IllegalStateException::new).getRefId()).isEqualTo(vo.getId()); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(vo.getId()); + bannerService.delete(TestUsers.superAdmin(), del); + + com.yunhai.cms.entity.CmsAttachment att = attachmentRepository.findById(img.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(att.getRefType()).isNull(); + assertThat(att.getRefId()).isNull(); + } } diff --git a/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java index 430e280..2c0fa94 100644 --- a/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/LinkServiceIT.java @@ -136,4 +136,72 @@ class LinkServiceIT extends CmsIntegrationTestBase { .isInstanceOf(BizException.class) .hasMessageContaining("不存在"); } + + // ===== B2 附件引用模型(友链 logo 引用回写)===== + + @Autowired + private AttachmentService attachmentService; + + /** 1x1 真实 png(上传需过魔数校验) */ + private org.springframework.mock.web.MockMultipartFile pngFile() { + java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try { + javax.imageio.ImageIO.write(img, "png", out); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + return new org.springframework.mock.web.MockMultipartFile("file", "logo.png", "image/png", out.toByteArray()); + } + + @Test + @DisplayName("B2: 保存友链回写 attachment.ref_type=link, ref_id=linkId;删除解引用") + void save_writesAttachmentRef_deleteReleases() { + com.yunhai.cms.model.vo.AttachmentVo logo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "link", pngFile()); + + LinkSaveRequest r = req("带Logo友链", "https://www.example.com"); + r.setLogoUrl(logo.getUrl()); + LinkVo vo = linkService.save(TestUsers.superAdmin(), r); + + com.yunhai.cms.entity.CmsAttachment att = attachmentRepository.findById(logo.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(att.getRefType()).isEqualTo("link"); + assertThat(att.getRefId()).isEqualTo(vo.getId()); + + DeleteRequest del = new DeleteRequest(); + del.setSiteId(siteId); + del.setId(vo.getId()); + linkService.delete(TestUsers.superAdmin(), del); + + com.yunhai.cms.entity.CmsAttachment released = attachmentRepository.findById(logo.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(released.getRefType()).isNull(); + assertThat(released.getRefId()).isNull(); + } + + @Test + @DisplayName("B2: 友链无 logo 不产生附件引用;logo 清空后引用解除") + void save_withoutLogo_noRef() { + LinkSaveRequest r = req("纯文字友链", "https://www.example.com"); + LinkVo vo = linkService.save(TestUsers.superAdmin(), r); + assertThat(attachmentRepository.findByRefTypeAndRefId("link", vo.getId())).isEmpty(); + + com.yunhai.cms.model.vo.AttachmentVo logo = attachmentService.upload( + TestUsers.superAdmin(), siteId, null, "link", pngFile()); + LinkSaveRequest withLogo = req("先有Logo", "https://www.example.com"); + withLogo.setId(vo.getId()); + withLogo.setLogoUrl(logo.getUrl()); + linkService.save(TestUsers.superAdmin(), withLogo); + assertThat(attachmentRepository.findByRefTypeAndRefId("link", vo.getId())) + .extracting(com.yunhai.cms.entity.CmsAttachment::getId) + .containsExactly(logo.getId()); + + // logo 清空(改回纯文字)→ 引用解除 + LinkSaveRequest clear = req("再去Logo", "https://www.example.com"); + clear.setId(vo.getId()); + clear.setLogoUrl(""); + linkService.save(TestUsers.superAdmin(), clear); + assertThat(attachmentRepository.findByRefTypeAndRefId("link", vo.getId())).isEmpty(); + } } diff --git a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java index 1c6cfc9..ad39c9e 100644 --- a/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java +++ b/src/test/java/com/yunhai/cms/service/impl/SiteServiceIT.java @@ -643,4 +643,81 @@ class SiteServiceIT extends CmsIntegrationTestBase { SiteVo vo = siteService.save(TestUsers.superAdmin(), req); assertThat(vo.getParentSiteId()).isEqualTo(mainBId); } + + // ===== B2 附件引用模型(站点配置 logo/favicon 引用回写)===== + + @org.springframework.beans.factory.annotation.Autowired + private AttachmentService attachmentService; + + /** 1x1 真实 png(上传需过魔数校验) */ + private org.springframework.mock.web.MockMultipartFile pngFile(String name) { + java.awt.image.BufferedImage img = new java.awt.image.BufferedImage(1, 1, java.awt.image.BufferedImage.TYPE_INT_RGB); + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try { + javax.imageio.ImageIO.write(img, "png", out); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + return new org.springframework.mock.web.MockMultipartFile("file", name, "image/png", out.toByteArray()); + } + + @Test + @DisplayName("B2: 站点配置 logo/favicon 指向本站上传文件 → ref_type=site, ref_id=siteId") + void save_siteLogoConfig_marksAttachmentRef() { + // 真实流程:站点已存在 → 本站上传 → 配置粘贴 URL → 保存(webUrl 反查带 siteId 防御) + CmsSite site = persistSite("cfgsite9", 1, "配置引用站"); + com.yunhai.cms.model.vo.AttachmentVo logo = attachmentService.upload( + TestUsers.superAdmin(), site.getId(), null, "site", pngFile("logo.png")); + com.yunhai.cms.model.vo.AttachmentVo favicon = attachmentService.upload( + TestUsers.superAdmin(), site.getId(), null, "site", pngFile("favicon.png")); + + SiteSaveRequest req = baseConfigReq("cfgsite9"); + req.setId(site.getId()); + req.setConfigJson("{\"logo\":\"" + logo.getUrl() + "\",\"favicon\":\"" + favicon.getUrl() + "\"}"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), req); + + com.yunhai.cms.entity.CmsAttachment logoAtt = attachmentRepository.findById(logo.getId()) + .orElseThrow(IllegalStateException::new); + com.yunhai.cms.entity.CmsAttachment favAtt = attachmentRepository.findById(favicon.getId()) + .orElseThrow(IllegalStateException::new); + assertThat(logoAtt.getRefType()).isEqualTo("site"); + assertThat(logoAtt.getRefId()).isEqualTo(vo.getId()); + assertThat(favAtt.getRefType()).isEqualTo("site"); + assertThat(favAtt.getRefId()).isEqualTo(vo.getId()); + } + + @Test + @DisplayName("B2: 站点改 logo 换图 → 旧引用解除、新引用回写;清空 logo → 引用解除") + void save_editSiteLogo_releasesOldMarksNew() { + CmsSite site = persistSite("cfgsite2", 1, "配置换图站"); + com.yunhai.cms.model.vo.AttachmentVo img1 = attachmentService.upload( + TestUsers.superAdmin(), site.getId(), null, null, pngFile("a.png")); + com.yunhai.cms.model.vo.AttachmentVo img2 = attachmentService.upload( + TestUsers.superAdmin(), site.getId(), null, null, pngFile("b.png")); + + SiteSaveRequest setLogo = baseConfigReq("cfgsite2"); + setLogo.setId(site.getId()); + setLogo.setConfigJson("{\"logo\":\"" + img1.getUrl() + "\"}"); + SiteVo vo = siteService.save(TestUsers.superAdmin(), setLogo); + assertThat(attachmentRepository.findById(img1.getId()) + .orElseThrow(IllegalStateException::new).getRefId()).isEqualTo(vo.getId()); + + // 换图 + SiteSaveRequest change = baseConfigReq("cfgsite2"); + change.setId(vo.getId()); + change.setConfigJson("{\"logo\":\"" + img2.getUrl() + "\"}"); + siteService.save(TestUsers.superAdmin(), change); + assertThat(attachmentRepository.findById(img1.getId()) + .orElseThrow(IllegalStateException::new).getRefType()).as("旧图解引用").isNull(); + assertThat(attachmentRepository.findById(img2.getId()) + .orElseThrow(IllegalStateException::new).getRefId()).isEqualTo(vo.getId()); + + // 清空 logo(不再引用任何上传文件) + SiteSaveRequest clear = baseConfigReq("cfgsite2"); + clear.setId(vo.getId()); + clear.setConfigJson("{}"); + siteService.save(TestUsers.superAdmin(), clear); + assertThat(attachmentRepository.findById(img2.getId()) + .orElseThrow(IllegalStateException::new).getRefType()).as("清空后引用解除").isNull(); + } } diff --git a/src/test/resources/schema-cms-test.sql b/src/test/resources/schema-cms-test.sql index 8cf9a7f..d68d5af 100644 --- a/src/test/resources/schema-cms-test.sql +++ b/src/test/resources/schema-cms-test.sql @@ -82,6 +82,8 @@ CREATE TABLE `cms_attachment` ( `id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', `site_id` INT NOT NULL COMMENT '所属站点', `article_id` INT NULL COMMENT '关联文章', + `ref_type` VARCHAR(16) NULL COMMENT '场景引用类型:banner/link/site(B2)', + `ref_id` INT NULL COMMENT '场景引用对象ID(B2)', `file_name` VARCHAR(255) NOT NULL COMMENT '原始文件名', `file_path` VARCHAR(500) NOT NULL COMMENT '存储路径', `web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL', @@ -93,7 +95,8 @@ CREATE TABLE `cms_attachment` ( `update_time` DATETIME NULL, PRIMARY KEY (`id`), KEY `idx_att_article` (`article_id`), - KEY `idx_att_site` (`site_id`) + KEY `idx_att_site` (`site_id`), + KEY `idx_att_ref` (`ref_type`, `ref_id`) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 附件'; CREATE TABLE `cms_banner` (