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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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、图片新闻位等)
|
||||
|
||||
Reference in New Issue
Block a user