feat(B2): 附件引用模型-cms_attachment 增 ref_type/ref_id,消灭孤儿误报
- 实体/双 SQL 加列+idx_att_ref;upload 增 scene 白名单(banner/link/site) - AttachmentService 新增 markUsed/release/markSiteConfigUsed(webUrl 反查,幂等) - 未使用=articleId 与 refType 均空;page 返回 unusedCount(前后端同步改名无兼容别名) - Banner/Link 保存先解引用再回写(换图旧图回未使用);Site 配置 logo/favicon 引用回写(@Lazy 解循环依赖) - 新增13例(Attachment6/Banner3/Link2/Site2)+既有语义断言更新;mvn -o test 全绿
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> orphans = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10);
|
||||
assertThat(orphans.get("total")).isEqualTo(1L);
|
||||
List<AttachmentVo> list = (List<AttachmentVo>) orphans.get("list");
|
||||
Map<String, Object> unused = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10);
|
||||
assertThat(unused.get("total")).isEqualTo(1L);
|
||||
List<AttachmentVo> list = (List<AttachmentVo>) 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<String, Object> 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<String, Object> afterRelease = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10);
|
||||
assertThat(afterRelease.get("total")).isEqualTo(1L);
|
||||
assertThat(afterRelease.get("unusedCount")).isEqualTo(1L);
|
||||
List<AttachmentVo> list = (List<AttachmentVo>) 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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` (
|
||||
|
||||
Reference in New Issue
Block a user