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:
chenxing
2026-09-07 19:40:14 +08:00
parent 52676f592a
commit b4822a7f38
15 changed files with 466 additions and 42 deletions
@@ -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 仅孤儿) */
@@ -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;
@@ -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;
}
}
@@ -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<CmsAttachment, Integer> {
@@ -19,10 +20,15 @@ public interface CmsAttachmentRepository extends JpaRepository<CmsAttachment, In
/** 编辑器图片上传时先按路径去重绑定,避免重复行 */
long countBySiteIdAndFilePath(Integer siteId, String filePath);
/** 孤儿附件(文章已删除:article_id is null) */
Page<CmsAttachment> findBySiteIdAndArticleIdIsNull(Integer siteId, Pageable pageable);
/** 未使用附件(B2 语义:无文章引用且无场景引用) */
Page<CmsAttachment> findBySiteIdAndArticleIdIsNullAndRefTypeIsNull(Integer siteId, Pageable pageable);
long countBySiteIdAndArticleIdIsNull(Integer siteId);
long countBySiteIdAndArticleIdIsNullAndRefTypeIsNull(Integer siteId);
/** 按 webUrl 反查(B2 保存点引用回写;webUrl 全站唯一由 uuid32 文件名保证,查询仍带 siteId 防御) */
Optional<CmsAttachment> findBySiteIdAndWebUrl(Integer siteId, String webUrl);
List<CmsAttachment> findByRefTypeAndRefId(String refType, Integer refId);
@Modifying
@Query("update CmsAttachment a set a.articleId = :articleId where a.id in :ids")
@@ -56,6 +56,9 @@ public class AttachmentService {
private static final List<String> IMAGE_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
/** 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 */
private static final List<String> 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<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,
? 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<String, Object> 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<CmsAttachment> 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<String, Object> 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) {
@@ -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<BannerVo> 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"));
}
@@ -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<LinkVo> 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"));
}
}
@@ -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);
}
+4 -1
View File
@@ -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、图片新闻位等)
@@ -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();
}
}
+4 -1
View File
@@ -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` (