Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32448f097e | ||
|
|
a3bee60b61 | ||
|
|
19e414ee81 | ||
|
|
b4822a7f38 | ||
|
|
52676f592a | ||
|
|
dad5eedaf2 |
@@ -3,6 +3,16 @@
|
|||||||
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
|
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
|
||||||
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
|
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
|
||||||
|
|
||||||
|
## 接口变更声明(2026-09-07 产品优化批次 B1~B5)
|
||||||
|
|
||||||
|
| 接口 | 变更 | 兼容性 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /api/site/save` | 权限语义拆分:原"仅超管"放宽为——校级角色(currentRole.schId 非空)可新建/编辑**本校主站下的院系子站**(parentSiteId>0 且主站属本校);顶层站新建/编辑/子站提升为顶层仍仅超管;请求体显式 schId 与角色不一致直接拒绝(留空由服务端继承主站) | 校级为新增放行面;超管行为不变;越权统一 403 |
|
||||||
|
| `POST /api/attachment/upload` | 新增可选 `scene` 参数(banner/link/site 白名单),落 `ref_type` 作场景引用初步标记;不传保持原行为 | 纯新增可选参数,旧调用方不受影响 |
|
||||||
|
| `GET /api/attachment/page` | "孤儿"语义改为"未使用":`articleId IS NULL AND ref_type IS NULL`;返回键 `orphanCount` 更名 `unusedCount`(无兼容别名,前端已同步);`orphanOnly=true` 过滤同语义 | **破坏性**:消费 orphanCount 的旧前端需同步(本仓库 front 已于同批改造) |
|
||||||
|
| `cms_attachment` 表 | 新增 `ref_type VARCHAR(16)`、`ref_id INT` 与 `idx_att_ref` 索引(B2 附件引用模型);存量数据不回填,旧场景图在重新保存前显示"未使用"为已知差异 | 加列向后兼容;开发库 ddl-auto=update 自动加列,生产执行 `sql/cms_init.sql` 增量 |
|
||||||
|
| `config_json.custom_css` | 新增站点级自定义样式键:服务端强校验(≤10240 字符;黑名单 `</style`/`<script`/`@import`/`expression(` 忽略大小写);门户 5 套主题 head 内联直出 | 新增键,未配置站点不受影响 |
|
||||||
|
|
||||||
## 首次推送本地代码到本仓库
|
## 首次推送本地代码到本仓库
|
||||||
|
|
||||||
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
|
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ public class AttachmentController {
|
|||||||
|
|
||||||
private final AttachmentService attachmentService;
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
/** 上传(multipart:file 必填;articleId 可空) */
|
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site,B2 初步标记) */
|
||||||
@PostMapping("/upload")
|
@PostMapping("/upload")
|
||||||
public AttachmentVo upload(BoaUser authUserWithDept,
|
public AttachmentVo upload(BoaUser authUserWithDept,
|
||||||
@RequestParam("siteId") Integer siteId,
|
@RequestParam("siteId") Integer siteId,
|
||||||
@RequestParam(value = "articleId", required = false) Integer articleId,
|
@RequestParam(value = "articleId", required = false) Integer articleId,
|
||||||
|
@RequestParam(value = "scene", required = false) String scene,
|
||||||
@RequestParam("file") MultipartFile file) {
|
@RequestParam("file") MultipartFile file) {
|
||||||
return attachmentService.upload(authUserWithDept, siteId, articleId, file);
|
return attachmentService.upload(authUserWithDept, siteId, articleId, scene, file);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 附件列表(orphanOnly=true 仅孤儿) */
|
/** 附件列表(orphanOnly=true 仅孤儿) */
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ public class PortalController {
|
|||||||
model.addAttribute("site", site);
|
model.addAttribute("site", site);
|
||||||
model.addAttribute("themeParams", site.getThemeParams());
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
||||||
model.addAttribute("navCategories", site.getCategories());
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
model.addAttribute("currentCategoryId", categoryId);
|
model.addAttribute("currentCategoryId", categoryId);
|
||||||
@@ -142,6 +143,7 @@ public class PortalController {
|
|||||||
model.addAttribute("site", site);
|
model.addAttribute("site", site);
|
||||||
model.addAttribute("themeParams", site.getThemeParams());
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
||||||
model.addAttribute("navCategories", site.getCategories());
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
model.addAttribute("currentCategoryId", detail.getCategoryId());
|
model.addAttribute("currentCategoryId", detail.getCategoryId());
|
||||||
@@ -179,6 +181,7 @@ public class PortalController {
|
|||||||
model.addAttribute("site", site);
|
model.addAttribute("site", site);
|
||||||
model.addAttribute("themeParams", site.getThemeParams());
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
||||||
model.addAttribute("navCategories", site.getCategories());
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
model.addAttribute("q", q);
|
model.addAttribute("q", q);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class PortalPageRenderer {
|
|||||||
model.addAttribute("site", site);
|
model.addAttribute("site", site);
|
||||||
model.addAttribute("themeParams", site.getThemeParams());
|
model.addAttribute("themeParams", site.getThemeParams());
|
||||||
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
|
||||||
|
model.addAttribute("customCss", site.getCustomCss());
|
||||||
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
model.addAttribute("themeBase", "themes/" + site.getTheme());
|
||||||
model.addAttribute("navCategories", site.getCategories());
|
model.addAttribute("navCategories", site.getCategories());
|
||||||
List<Map<String, Object>> banners = activeBanners(site, "home", url);
|
List<Map<String, Object>> banners = activeBanners(site, "home", url);
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import javax.persistence.Table;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CMS 附件(正文图片 + 文档附件 + 轮播图/友链 Logo 复用同一上传链路,方案 §7.3)。
|
* CMS 附件(正文图片 + 文档附件 + 轮播图/友链 Logo 复用同一上传链路,方案 §7.3)。
|
||||||
* 文章删除时附件记录保留、article_id 置空成"孤儿",由附件管理页清理。
|
* 引用模型(B2):articleId 仅服务文章编辑器回填链路;非文章场景(轮播/友链/站点配置)
|
||||||
|
* 用 refType/refId 标记。未使用 = 无文章引用且无场景引用(articleId 与 refType 均空);
|
||||||
|
* 文章删除仅 article_id 置空,场景引用由各保存点按 webUrl 反查回写/解引用。
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "cms_attachment", indexes = {
|
@Table(name = "cms_attachment", indexes = {
|
||||||
@Index(name = "idx_att_article", columnList = "article_id"),
|
@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 {
|
public class CmsAttachment extends BaseEntity {
|
||||||
|
|
||||||
/** 所属站点 */
|
/** 所属站点 */
|
||||||
@@ -28,6 +31,14 @@ public class CmsAttachment extends BaseEntity {
|
|||||||
@Column(name = "article_id")
|
@Column(name = "article_id")
|
||||||
private Integer articleId;
|
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)
|
@Column(name = "file_name", nullable = false, length = 255)
|
||||||
private String fileName;
|
private String fileName;
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ public class PortalSiteSnapshot {
|
|||||||
/** 服务端预构建的 :root{...} CSS 变量覆盖串(无有效参数时为空串,方案 §5.3) */
|
/** 服务端预构建的 :root{...} CSS 变量覆盖串(无有效参数时为空串,方案 §5.3) */
|
||||||
private String themeVarsCss = "";
|
private String themeVarsCss = "";
|
||||||
|
|
||||||
|
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
|
||||||
|
private String customCss = "";
|
||||||
|
|
||||||
public static PortalSiteSnapshot of(CmsSite site) {
|
public static PortalSiteSnapshot of(CmsSite site) {
|
||||||
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
|
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
|
||||||
snapshot.setId(site.getId());
|
snapshot.setId(site.getId());
|
||||||
@@ -70,6 +73,7 @@ public class PortalSiteSnapshot {
|
|||||||
snapshot.setConfig(config);
|
snapshot.setConfig(config);
|
||||||
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
|
||||||
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
|
||||||
|
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +98,7 @@ public class PortalSiteSnapshot {
|
|||||||
copy.setColleges(colleges);
|
copy.setColleges(colleges);
|
||||||
copy.setThemeParams(themeParams);
|
copy.setThemeParams(themeParams);
|
||||||
copy.setThemeVarsCss(themeVarsCss);
|
copy.setThemeVarsCss(themeVarsCss);
|
||||||
|
copy.setCustomCss(customCss);
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ public class AttachmentVo {
|
|||||||
|
|
||||||
private Integer articleId;
|
private Integer articleId;
|
||||||
|
|
||||||
|
/** 场景引用类型(B2):banner/link/site;空=无场景引用 */
|
||||||
|
private String refType;
|
||||||
|
|
||||||
private String fileName;
|
private String fileName;
|
||||||
|
|
||||||
/** Web 访问 URL(编辑器/封面/轮播直接引用) */
|
/** Web 访问 URL(编辑器/封面/轮播直接引用) */
|
||||||
@@ -26,7 +29,7 @@ public class AttachmentVo {
|
|||||||
|
|
||||||
private String createTime;
|
private String createTime;
|
||||||
|
|
||||||
/** 是否孤儿(文章已删除/未关联) */
|
/** 是否未使用(B2:无文章引用且无场景引用) */
|
||||||
private boolean orphan;
|
private boolean orphan;
|
||||||
|
|
||||||
public static AttachmentVo of(CmsAttachment a) {
|
public static AttachmentVo of(CmsAttachment a) {
|
||||||
@@ -34,12 +37,13 @@ public class AttachmentVo {
|
|||||||
vo.setId(a.getId());
|
vo.setId(a.getId());
|
||||||
vo.setSiteId(a.getSiteId());
|
vo.setSiteId(a.getSiteId());
|
||||||
vo.setArticleId(a.getArticleId());
|
vo.setArticleId(a.getArticleId());
|
||||||
|
vo.setRefType(a.getRefType());
|
||||||
vo.setFileName(a.getFileName());
|
vo.setFileName(a.getFileName());
|
||||||
vo.setUrl(a.getWebUrl());
|
vo.setUrl(a.getWebUrl());
|
||||||
vo.setFileSize(a.getFileSize());
|
vo.setFileSize(a.getFileSize());
|
||||||
vo.setMimeType(a.getMimeType());
|
vo.setMimeType(a.getMimeType());
|
||||||
vo.setCreateTime(a.getCreateTime() == null ? null : a.getCreateTime().toString());
|
vo.setCreateTime(a.getCreateTime() == null ? null : a.getCreateTime().toString());
|
||||||
vo.setOrphan(a.getArticleId() == null);
|
vo.setOrphan(a.getArticleId() == null && a.getRefType() == null);
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.springframework.data.jpa.repository.Query;
|
|||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface CmsAttachmentRepository extends JpaRepository<CmsAttachment, Integer> {
|
public interface CmsAttachmentRepository extends JpaRepository<CmsAttachment, Integer> {
|
||||||
|
|
||||||
@@ -19,10 +20,15 @@ public interface CmsAttachmentRepository extends JpaRepository<CmsAttachment, In
|
|||||||
/** 编辑器图片上传时先按路径去重绑定,避免重复行 */
|
/** 编辑器图片上传时先按路径去重绑定,避免重复行 */
|
||||||
long countBySiteIdAndFilePath(Integer siteId, String filePath);
|
long countBySiteIdAndFilePath(Integer siteId, String filePath);
|
||||||
|
|
||||||
/** 孤儿附件(文章已删除:article_id is null) */
|
/** 未使用附件(B2 语义:无文章引用且无场景引用) */
|
||||||
Page<CmsAttachment> findBySiteIdAndArticleIdIsNull(Integer siteId, Pageable pageable);
|
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
|
@Modifying
|
||||||
@Query("update CmsAttachment a set a.articleId = :articleId where a.id in :ids")
|
@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(
|
private static final List<String> IMAGE_EXTS = Arrays.asList(
|
||||||
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
|
"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 CmsAttachmentRepository attachmentRepository;
|
||||||
private final SiteService siteService;
|
private final SiteService siteService;
|
||||||
|
|
||||||
@@ -66,10 +69,13 @@ public class AttachmentService {
|
|||||||
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
@Value("${FILE_WEB_PATH:/cms/upload/}")
|
||||||
private String fileWebPath;
|
private String fileWebPath;
|
||||||
|
|
||||||
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空 */
|
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site,B2 初步标记) */
|
||||||
@Transactional
|
@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);
|
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()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
|
||||||
}
|
}
|
||||||
@@ -101,6 +107,7 @@ public class AttachmentService {
|
|||||||
CmsAttachment attachment = new CmsAttachment();
|
CmsAttachment attachment = new CmsAttachment();
|
||||||
attachment.setSiteId(site.getId());
|
attachment.setSiteId(site.getId());
|
||||||
attachment.setArticleId(articleId);
|
attachment.setArticleId(articleId);
|
||||||
|
attachment.setRefType(StringUtils.hasText(scene) ? scene : null);
|
||||||
attachment.setFileName(originalName);
|
attachment.setFileName(originalName);
|
||||||
attachment.setFilePath(relativePath);
|
attachment.setFilePath(relativePath);
|
||||||
attachment.setWebUrl(webUrl);
|
attachment.setWebUrl(webUrl);
|
||||||
@@ -110,14 +117,14 @@ public class AttachmentService {
|
|||||||
return AttachmentVo.of(attachment);
|
return AttachmentVo.of(attachment);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 附件列表(orphanOnly=true 时仅孤儿,附件管理页清理用) */
|
/** 附件列表(unusedOnly=true 时仅未使用:无文章引用且无场景引用,附件管理页清理用) */
|
||||||
public Map<String, Object> page(BoaUser user, Integer siteId, boolean orphanOnly,
|
public Map<String, Object> page(BoaUser user, Integer siteId, boolean orphanOnly,
|
||||||
Integer page, Integer pageSize) {
|
Integer page, Integer pageSize) {
|
||||||
siteService.requireManageable(user, siteId);
|
siteService.requireManageable(user, siteId);
|
||||||
int pageIndex = page == null || page < 1 ? 0 : page - 1;
|
int pageIndex = page == null || page < 1 ? 0 : page - 1;
|
||||||
int size = pageSize == null || pageSize < 1 ? 10 : pageSize;
|
int size = pageSize == null || pageSize < 1 ? 10 : pageSize;
|
||||||
Page<CmsAttachment> result = orphanOnly
|
Page<CmsAttachment> result = orphanOnly
|
||||||
? attachmentRepository.findBySiteIdAndArticleIdIsNull(siteId,
|
? attachmentRepository.findBySiteIdAndArticleIdIsNullAndRefTypeIsNull(siteId,
|
||||||
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")))
|
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")))
|
||||||
: attachmentRepository.findBySiteId(siteId,
|
: attachmentRepository.findBySiteId(siteId,
|
||||||
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")));
|
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")));
|
||||||
@@ -128,10 +135,55 @@ public class AttachmentService {
|
|||||||
Map<String, Object> map = new HashMap<>();
|
Map<String, Object> map = new HashMap<>();
|
||||||
map.put("total", result.getTotalElements());
|
map.put("total", result.getTotalElements());
|
||||||
map.put("list", list);
|
map.put("list", list);
|
||||||
map.put("orphanCount", attachmentRepository.countBySiteIdAndArticleIdIsNull(siteId));
|
map.put("unusedCount", attachmentRepository.countBySiteIdAndArticleIdIsNullAndRefTypeIsNull(siteId));
|
||||||
return map;
|
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
|
@Transactional
|
||||||
public void delete(BoaUser user, DeleteRequest req) {
|
public void delete(BoaUser user, DeleteRequest req) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public class BannerService {
|
|||||||
private final CmsCategoryRepository categoryRepository;
|
private final CmsCategoryRepository categoryRepository;
|
||||||
private final SiteService siteService;
|
private final SiteService siteService;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
public List<BannerVo> list(BoaUser user, Integer siteId, String position) {
|
public List<BannerVo> list(BoaUser user, Integer siteId, String position) {
|
||||||
siteService.requireManageable(user, siteId);
|
siteService.requireManageable(user, siteId);
|
||||||
@@ -81,6 +82,9 @@ public class BannerService {
|
|||||||
}
|
}
|
||||||
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||||
banner = bannerRepository.save(banner);
|
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"));
|
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
|
||||||
return BannerVo.of(banner);
|
return BannerVo.of(banner);
|
||||||
}
|
}
|
||||||
@@ -94,6 +98,7 @@ public class BannerService {
|
|||||||
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该轮播");
|
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该轮播");
|
||||||
}
|
}
|
||||||
bannerRepository.delete(banner);
|
bannerRepository.delete(banner);
|
||||||
|
attachmentService.release("banner", banner.getId());
|
||||||
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
|
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class LinkService {
|
|||||||
private final CmsLinkRepository linkRepository;
|
private final CmsLinkRepository linkRepository;
|
||||||
private final SiteService siteService;
|
private final SiteService siteService;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final AttachmentService attachmentService;
|
||||||
|
|
||||||
public List<LinkVo> list(BoaUser user, Integer siteId) {
|
public List<LinkVo> list(BoaUser user, Integer siteId) {
|
||||||
siteService.requireManageable(user, siteId);
|
siteService.requireManageable(user, siteId);
|
||||||
@@ -66,6 +67,11 @@ public class LinkService {
|
|||||||
link.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
link.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
|
||||||
link.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
link.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||||
link = linkRepository.save(link);
|
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"));
|
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link"));
|
||||||
return LinkVo.of(link);
|
return LinkVo.of(link);
|
||||||
}
|
}
|
||||||
@@ -79,6 +85,7 @@ public class LinkService {
|
|||||||
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该友链");
|
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该友链");
|
||||||
}
|
}
|
||||||
linkRepository.delete(link);
|
linkRepository.delete(link);
|
||||||
|
attachmentService.release("link", link.getId());
|
||||||
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link"));
|
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "link"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ public class SiteService {
|
|||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
private final com.yunhai.cms.theme.ThemeRegistry themeRegistry;
|
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 键清单) */
|
/** config_json 已知键约束(与前端 utils/siteConfigForm.ts 保持一致,docs/09 §0 键清单) */
|
||||||
private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper();
|
private static final ObjectMapper CONFIG_MAPPER = new ObjectMapper();
|
||||||
private static final Pattern HOST_PATTERN = Pattern.compile(
|
private static final Pattern HOST_PATTERN = Pattern.compile(
|
||||||
@@ -51,6 +59,12 @@ public class SiteService {
|
|||||||
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
|
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
|
||||||
private static final int CONFIG_VALUE_MAX_LEN = 500;
|
private static final int CONFIG_VALUE_MAX_LEN = 500;
|
||||||
|
|
||||||
|
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
|
||||||
|
private static final int CUSTOM_CSS_MAX_LEN = 10240;
|
||||||
|
private static final List<String> CUSTOM_CSS_BLACKLIST =
|
||||||
|
java.util.Collections.unmodifiableList(java.util.Arrays.asList(
|
||||||
|
"</style", "<script", "@import", "expression("));
|
||||||
|
|
||||||
/** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */
|
/** 当前用户可管理的站点列表(含上级站点名回填,展示"主站/院系子站"层级) */
|
||||||
public List<SiteVo> listFor(BoaUser user) {
|
public List<SiteVo> listFor(BoaUser user) {
|
||||||
List<CmsSite> sites = authProperties.isSuperAdmin(user)
|
List<CmsSite> sites = authProperties.isSuperAdmin(user)
|
||||||
@@ -106,10 +120,10 @@ public class SiteService {
|
|||||||
return ExtJsonUtils.getString(config, key);
|
return ExtJsonUtils.getString(config, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站点保存(仅超管;MVP 1-2 校以初始化脚本为主,本接口为后台兜底) */
|
/** 站点保存(B1 权限拆分:校级可建/编辑本院系子站,顶层站仅超管;MVP 1-2 校以初始化脚本为主) */
|
||||||
@Transactional
|
@Transactional
|
||||||
public SiteVo save(BoaUser user, SiteSaveRequest req) {
|
public SiteVo save(BoaUser user, SiteSaveRequest req) {
|
||||||
requireSuperAdmin(user);
|
requireSiteSavePermission(user, req);
|
||||||
CmsSite site;
|
CmsSite site;
|
||||||
if (req.getId() == null) {
|
if (req.getId() == null) {
|
||||||
if (siteRepository.findByCode(req.getCode()).isPresent()) {
|
if (siteRepository.findByCode(req.getCode()).isPresent()) {
|
||||||
@@ -140,10 +154,56 @@ public class SiteService {
|
|||||||
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
|
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
|
||||||
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
|
||||||
site = siteRepository.save(site);
|
site = siteRepository.save(site);
|
||||||
|
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
|
||||||
|
attachmentService.markSiteConfigUsed(site.getId(), ExtJsonUtils.parse(site.getConfigJson()));
|
||||||
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site"));
|
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "site"));
|
||||||
return SiteVo.of(site);
|
return SiteVo.of(site);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 站点保存权限(B1 建站权限拆分,安全边界收敛于本单一方法,前端按钮仅入口显性化):
|
||||||
|
* 超管直通;非超管仅可新建/编辑「本校主站下的院系子站」,具体拒绝项——
|
||||||
|
* ① 新建顶层站(parentSiteId≤0)→ 拒(仅平台超管可创建学校主站);
|
||||||
|
* ② 编辑对象为顶层站(含本校主站)→ 拒;
|
||||||
|
* ③ 编辑子站时 parentSiteId 传 0/null(提升为顶层)→ 拒;
|
||||||
|
* ④ 目标主站 schId 与 currentRole.schId 不一致(跨校/改挂接管)→ 拒;
|
||||||
|
* ⑤ 编辑对象本身跨校 → 拒(复用 canManage 同校语义);
|
||||||
|
* ⑥ 无校级角色(schId 为空)→ 拒;
|
||||||
|
* ⑦ 请求体显式 schId 与本人角色不一致(伪造通道)→ 拒(留空则由 validateParent 继承主站)。
|
||||||
|
*/
|
||||||
|
private void requireSiteSavePermission(BoaUser user, SiteSaveRequest req) {
|
||||||
|
if (authProperties.isSuperAdmin(user)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Integer schId = user.getCurrentRole() == null ? null : user.getCurrentRole().getSchId();
|
||||||
|
if (schId == null) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点(需校级角色)");
|
||||||
|
}
|
||||||
|
if (req.getId() != null) {
|
||||||
|
CmsSite target = siteRepository.findById(req.getId())
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "站点不存在"));
|
||||||
|
boolean targetIsTop = target.getParentSiteId() == null || target.getParentSiteId() == 0;
|
||||||
|
if (targetIsTop) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可管理学校主站(顶层站点)");
|
||||||
|
}
|
||||||
|
if (!canManage(user, target)) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Integer parentSiteId = req.getParentSiteId() == null ? 0 : req.getParentSiteId();
|
||||||
|
if (parentSiteId <= 0) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可创建学校主站(顶层站点)");
|
||||||
|
}
|
||||||
|
CmsSite parent = siteRepository.findById(parentSiteId)
|
||||||
|
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "上级站点不存在"));
|
||||||
|
if (parent.getSchId() == null || !schId.equals(parent.getSchId())) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点");
|
||||||
|
}
|
||||||
|
if (req.getSchId() != null && !schId.equals(req.getSchId())) {
|
||||||
|
throw new BizException(ErrorCode.FORBIDDEN, "无权管理该站点(学校ID与本人角色不符)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
|
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
|
||||||
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
|
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
|
||||||
@@ -236,6 +296,11 @@ public class SiteService {
|
|||||||
if (!StringUtils.hasText(text)) {
|
if (!StringUtils.hasText(text)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// custom_css 独立校验(B3):上限 10KB + 黑名单,不走通用 500 字上限
|
||||||
|
if ("custom_css".equals(key)) {
|
||||||
|
validateCustomCss(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (text.length() > CONFIG_VALUE_MAX_LEN) {
|
if (text.length() > CONFIG_VALUE_MAX_LEN) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)");
|
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置值过长:" + key + "(上限 500 字)");
|
||||||
}
|
}
|
||||||
@@ -264,4 +329,23 @@ public class SiteService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
|
||||||
|
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
|
||||||
|
* {@code expression(}(IE 表达式)。门户 5 套主题 head 以 th:utext 直出,安全性完全依赖本方法先行。
|
||||||
|
*/
|
||||||
|
private void validateCustomCss(String css) {
|
||||||
|
if (css.length() > CUSTOM_CSS_MAX_LEN) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"custom_css 过长(" + css.length() + " 字符,上限 " + CUSTOM_CSS_MAX_LEN + ")");
|
||||||
|
}
|
||||||
|
String lower = css.toLowerCase(java.util.Locale.ROOT);
|
||||||
|
for (String banned : CUSTOM_CSS_BLACKLIST) {
|
||||||
|
if (lower.contains(banned)) {
|
||||||
|
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||||
|
"custom_css 含禁用内容:" + banned + "(不允许 </style、<script、@import、expression())");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<!-- 引入 Spring Boot 默认彩色日志变量 -->
|
||||||
|
<include resource="org/springframework/boot/logging/logback/defaults.xml"/>
|
||||||
|
|
||||||
|
<!-- 控制台:所有环境都有,且带颜色2 -->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>${CONSOLE_LOG_CHARSET}</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 文件日志:只有非 local 环境才启用 -->
|
||||||
|
<springProfile name="!local">
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>/app/logs/app.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log.zip</fileNamePattern>
|
||||||
|
<maxHistory>60</maxHistory>
|
||||||
|
<totalSizeCap>5GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE" />
|
||||||
|
<springProfile name="!local">
|
||||||
|
<appender-ref ref="FILE" />
|
||||||
|
</springProfile>
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
@@ -80,6 +80,8 @@ CREATE TABLE IF NOT EXISTS `cms_attachment` (
|
|||||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
`site_id` INT NOT NULL COMMENT '所属站点',
|
`site_id` INT NOT NULL COMMENT '所属站点',
|
||||||
`article_id` INT 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_name` VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||||
`file_path` VARCHAR(500) NOT NULL COMMENT '存储路径(相对 FILE_PATH)',
|
`file_path` VARCHAR(500) NOT NULL COMMENT '存储路径(相对 FILE_PATH)',
|
||||||
`web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL',
|
`web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL',
|
||||||
@@ -91,7 +93,8 @@ CREATE TABLE IF NOT EXISTS `cms_attachment` (
|
|||||||
`update_time` DATETIME NULL,
|
`update_time` DATETIME NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_att_article` (`article_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 附件';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 附件';
|
||||||
|
|
||||||
-- 5. 轮播/推荐位表(首页 banner、图片新闻位等)
|
-- 5. 轮播/推荐位表(首页 banner、图片新闻位等)
|
||||||
|
|||||||
@@ -330,3 +330,15 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
|
|||||||
.home-main, .list-main, .detail-main { padding-top: 36px; padding-bottom: 56px; }
|
.home-main, .list-main, .detail-main { padding-top: 36px; padding-bottom: 56px; }
|
||||||
.article-list .article-title, .block-head, .page-title { letter-spacing: .02em; }
|
.article-list .article-title, .block-head, .page-title { letter-spacing: .02em; }
|
||||||
.article-list li, .banner, .banner-title { border-radius: 4px; }
|
.article-list li, .banner, .banner-title { border-radius: 4px; }
|
||||||
|
|
||||||
|
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
|
||||||
|
.links-group a .link-logo,
|
||||||
|
.sp-links-row a .link-logo {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||||
<div class="links-group" th:each="group : ${site.linkGroups}">
|
<div class="links-group" th:each="group : ${site.linkGroups}">
|
||||||
<span class="group-name" th:text="${group.groupName}">分组</span>
|
<span class="group-name" th:text="${group.groupName}">分组</span>
|
||||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener"
|
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||||
th:text="${link.name}">链接</a>
|
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||||
|
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||||
|
<span th:text="${link.name}">链接</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-info">
|
<div class="footer-info">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||||
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
||||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||||
|
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||||
|
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
||||||
<link rel="icon" type="image/png"
|
<link rel="icon" type="image/png"
|
||||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||||
|
|||||||
@@ -332,3 +332,15 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
|
|||||||
/* 头部样式参数(theme.json headerStyle=plain → 简洁顶栏) */
|
/* 头部样式参数(theme.json headerStyle=plain → 简洁顶栏) */
|
||||||
.site-header.header-plain { background: #fff; border-bottom: 2px solid var(--primary); }
|
.site-header.header-plain { background: #fff; border-bottom: 2px solid var(--primary); }
|
||||||
.site-header.header-plain .site-name { color: var(--primary-dark); }
|
.site-header.header-plain .site-name { color: var(--primary-dark); }
|
||||||
|
|
||||||
|
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
|
||||||
|
.links-group a .link-logo,
|
||||||
|
.sp-links-row a .link-logo {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||||
<div class="links-group" th:each="group : ${site.linkGroups}">
|
<div class="links-group" th:each="group : ${site.linkGroups}">
|
||||||
<span class="group-name" th:text="${group.groupName}">分组</span>
|
<span class="group-name" th:text="${group.groupName}">分组</span>
|
||||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener"
|
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||||
th:text="${link.name}">链接</a>
|
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||||
|
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||||
|
<span th:text="${link.name}">链接</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-info">
|
<div class="footer-info">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||||
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
||||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||||
|
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||||
|
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
||||||
<link rel="icon" type="image/png"
|
<link rel="icon" type="image/png"
|
||||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||||
|
|||||||
@@ -934,3 +934,15 @@ time, .item-meta { color: var(--text-muted); font-size: var(--fs-sm); }
|
|||||||
.article-content table th { background: var(--surface-variant) !important; }
|
.article-content table th { background: var(--surface-variant) !important; }
|
||||||
a { color: inherit; }
|
a { color: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
|
||||||
|
.links-group a .link-logo,
|
||||||
|
.sp-links-row a .link-logo {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
<div class="links-group" th:each="group : ${site.linkGroups}"
|
<div class="links-group" th:each="group : ${site.linkGroups}"
|
||||||
th:if="${!#lists.isEmpty(group.links)}">
|
th:if="${!#lists.isEmpty(group.links)}">
|
||||||
<span class="group-name" th:text="${group.groupName}">分组</span>
|
<span class="group-name" th:text="${group.groupName}">分组</span>
|
||||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener"
|
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||||
th:text="${link.name}">链接</a>
|
<!-- B4 友链 Logo:有 logo 图片+名称并排(渲染期补基准),无 logo 纯文字 -->
|
||||||
|
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||||
|
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||||
|
<span th:text="${link.name}">链接</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||||
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
||||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||||
|
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||||
|
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
||||||
<link rel="icon" type="image/png"
|
<link rel="icon" type="image/png"
|
||||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||||
|
|||||||
@@ -1293,3 +1293,15 @@ input, button, select, textarea { font-size: inherit; color: inherit; }
|
|||||||
transition-duration: .01ms !important;
|
transition-duration: .01ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
|
||||||
|
.links-group a .link-logo,
|
||||||
|
.sp-links-row a .link-logo {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,8 +41,11 @@
|
|||||||
th:if="${!#lists.isEmpty(group.links)}">
|
th:if="${!#lists.isEmpty(group.links)}">
|
||||||
<span class="sp-group-name" th:text="${group.groupName}">分组</span>
|
<span class="sp-group-name" th:text="${group.groupName}">分组</span>
|
||||||
<p class="sp-links-row">
|
<p class="sp-links-row">
|
||||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener"
|
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||||
th:text="${link.name}">链接</a>
|
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||||
|
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||||
|
<span th:text="${link.name}">链接</span>
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
<meta property="og:image" th:if="${ogImage != null}" th:content="${ogImage}">
|
||||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||||
|
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||||
|
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||||
<link rel="icon" type="image/png"
|
<link rel="icon" type="image/png"
|
||||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -330,3 +330,15 @@ time, .item-meta { color: var(--text-light); font-size: 13px; }
|
|||||||
.banner, .banner-title { border-radius: 20px; overflow: hidden; }
|
.banner, .banner-title { border-radius: 20px; overflow: hidden; }
|
||||||
.site-search input, .site-search button { border-radius: 999px; }
|
.site-search input, .site-search button { border-radius: 999px; }
|
||||||
.footer-info p { color: var(--primary-dark); }
|
.footer-info p { color: var(--primary-dark); }
|
||||||
|
|
||||||
|
/* B4 友链 Logo:与名称并排小图(高度 ~20px,垂直居中;过宽截断不撑破页脚) */
|
||||||
|
.links-group a .link-logo,
|
||||||
|
.sp-links-row a .link-logo {
|
||||||
|
height: 20px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 64px;
|
||||||
|
object-fit: contain;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
<div class="footer-links" th:if="${!#lists.isEmpty(site.linkGroups)}">
|
||||||
<div class="links-group" th:each="group : ${site.linkGroups}">
|
<div class="links-group" th:each="group : ${site.linkGroups}">
|
||||||
<span class="group-name" th:text="${group.groupName}">分组</span>
|
<span class="group-name" th:text="${group.groupName}">分组</span>
|
||||||
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener"
|
<a th:each="link : ${group.links}" th:href="${link.linkUrl}" target="_blank" rel="noopener">
|
||||||
th:text="${link.name}">链接</a>
|
<img th:if="${link.logoUrl != null and !#strings.isEmpty(link.logoUrl)}" class="link-logo"
|
||||||
|
th:src="${url.upload(link.logoUrl)}" th:alt="${link.name}" loading="lazy">
|
||||||
|
<span th:text="${link.name}">链接</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer-info">
|
<div class="footer-info">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
<link rel="stylesheet" th:href="${url.asset('css/main.css')}">
|
||||||
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
<!-- 微调参数 CSS 变量覆盖(方案 §5.3):位于皮肤 CSS 之后层叠覆盖;值由服务端预构建并校验 -->
|
||||||
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
<style th:if="${not #strings.isEmpty(themeVarsCss)}" th:utext="${themeVarsCss}"></style>
|
||||||
|
<!-- 站点级自定义样式(B3):位于皮肤 CSS 与微调变量之后层叠覆盖;内容经服务端黑名单强校验后直出 -->
|
||||||
|
<style th:if="${not #strings.isEmpty(customCss)}" th:utext="${customCss}"></style>
|
||||||
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
<!-- favicon:管理端配置了 favicon 键用上传图,未配置输出主题内置兜底 icon(assets/favicon.png) -->
|
||||||
<link rel="icon" type="image/png"
|
<link rel="icon" type="image/png"
|
||||||
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
th:href="${site.config['favicon'] != null ? url.upload(site.config['favicon']) : url.asset('favicon.png')}">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.yunhai.cms.portal;
|
|||||||
import com.yunhai.cms.enums.ArticleStatusEnum;
|
import com.yunhai.cms.enums.ArticleStatusEnum;
|
||||||
import com.yunhai.cms.enums.CategoryTypeEnum;
|
import com.yunhai.cms.enums.CategoryTypeEnum;
|
||||||
import com.yunhai.cms.support.CmsIntegrationTestBase;
|
import com.yunhai.cms.support.CmsIntegrationTestBase;
|
||||||
|
import com.yunhai.cms.support.TestUsers;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
import org.junit.jupiter.api.DisplayName;
|
||||||
@@ -360,4 +361,91 @@ class PortalSsrIT extends CmsIntegrationTestBase {
|
|||||||
.contains("sso."));
|
.contains("sso."));
|
||||||
mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk());
|
mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== B3 custom_css 站点级自定义样式(head 内联输出)=====
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired
|
||||||
|
private com.yunhai.cms.service.impl.SiteService siteService;
|
||||||
|
|
||||||
|
private void setSiteCss(String cssJson) {
|
||||||
|
siteRepository.findById(siteId).ifPresent(s -> {
|
||||||
|
s.setConfigJson(cssJson);
|
||||||
|
siteRepository.saveAndFlush(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: 配置 custom_css 后 SSR 输出内联 style(head 内、皮肤 main.css 之后)")
|
||||||
|
void customCss_renderedAsInlineStyle() throws Exception {
|
||||||
|
setSiteCss("{\"custom_css\":\".site-header{padding:16px 0}\"}");
|
||||||
|
MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
String html = body(result);
|
||||||
|
assertThat(html).contains(".site-header{padding:16px 0}");
|
||||||
|
assertThat(html).contains("<style>.site-header{padding:16px 0}</style>");
|
||||||
|
assertThat(html.indexOf(".site-header{padding:16px 0}")).as("内联输出须位于 head 内")
|
||||||
|
.isLessThan(html.indexOf("</head>"));
|
||||||
|
assertThat(html.indexOf("css/main.css")).as("覆盖优先级:位于皮肤 main.css 之后")
|
||||||
|
.isLessThan(html.indexOf(".site-header{padding:16px 0}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: 未配置 custom_css 不输出 style 块(其他站点不受影响)")
|
||||||
|
void customCss_absent_whenNotConfigured() throws Exception {
|
||||||
|
MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
assertThat(body(result)).doesNotContain("<style");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: 改 custom_css 后快照缓存随保存事件失效,门户输出新值")
|
||||||
|
void customCss_changeInvalidatesCache() throws Exception {
|
||||||
|
setSiteCss("{\"custom_css\":\".mark-a{color:red}\"}");
|
||||||
|
assertThat(body(mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()).andReturn()))
|
||||||
|
.contains(".mark-a{color:red}");
|
||||||
|
|
||||||
|
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
|
||||||
|
req.setId(siteId);
|
||||||
|
req.setCode("demo1");
|
||||||
|
req.setSchId(1);
|
||||||
|
req.setSchName("学校-1");
|
||||||
|
req.setName("云海第一中学门户");
|
||||||
|
req.setConfigJson("{\"custom_css\":\".mark-b{color:blue}\"}");
|
||||||
|
siteService.save(TestUsers.superAdmin(), req);
|
||||||
|
|
||||||
|
String html = body(mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk()).andReturn());
|
||||||
|
assertThat(html).as("保存事件驱逐 cms:site 后输出新样式").contains(".mark-b{color:blue}");
|
||||||
|
assertThat(html).doesNotContain(".mark-a{color:red}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== B4 友链 Logo 页脚渲染(有 logo 图+名,无 logo 纯文字)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B4: 带 logo 友链页脚渲染图片(补基准 URL + alt=名称 + lazy)")
|
||||||
|
void linkWithLogo_rendersImage() throws Exception {
|
||||||
|
linkRepository.findById(
|
||||||
|
linkRepository.findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(siteId).get(0).getId())
|
||||||
|
.ifPresent(l -> {
|
||||||
|
l.setLogoUrl("/cms/upload/cms/demo1/202609/friend-logo.png");
|
||||||
|
linkRepository.saveAndFlush(l);
|
||||||
|
});
|
||||||
|
MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
String html = body(result);
|
||||||
|
assertThat(html)
|
||||||
|
.contains("class=\"link-logo\"")
|
||||||
|
.contains("loading=\"lazy\"")
|
||||||
|
.contains("src=\"http://localhost/cms/upload/cms/demo1/202609/friend-logo.png\"")
|
||||||
|
.contains("alt=\"教育部\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B4: 无 logo 友链纯文字渲染(页脚无 link-logo 图片)")
|
||||||
|
void linkWithoutLogo_rendersTextOnly() throws Exception {
|
||||||
|
MvcResult result = mockMvc.perform(get("/portal/demo1/")).andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
String html = body(result);
|
||||||
|
assertThat(html).contains("<span>教育部</span>");
|
||||||
|
assertThat(html).doesNotContain("link-logo");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ class ArticleServiceIT extends CmsIntegrationTestBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("删除:文章删除且附件转孤儿(article_id 置空,方案 §9.1 附件管理)")
|
@DisplayName("删除:文章删除且附件转未使用(article_id 置空且无场景引用,方案 §9.1 / B2 语义)")
|
||||||
void delete_orphansAttachments() {
|
void delete_orphansAttachments() {
|
||||||
Integer id = articleService.save(TestUsers.superAdmin(), saveReq("待删文"));
|
Integer id = articleService.save(TestUsers.superAdmin(), saveReq("待删文"));
|
||||||
CmsAttachment att = new CmsAttachment();
|
CmsAttachment att = new CmsAttachment();
|
||||||
@@ -244,8 +244,10 @@ class ArticleServiceIT extends CmsIntegrationTestBase {
|
|||||||
assertThat(articleRepository.findById(id)).isEmpty();
|
assertThat(articleRepository.findById(id)).isEmpty();
|
||||||
// unbindArticle 为 JPQL 批量更新,清一级缓存后回读库值
|
// unbindArticle 为 JPQL 批量更新,清一级缓存后回读库值
|
||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
assertThat(attachmentRepository.findById(att.getId())
|
CmsAttachment released = attachmentRepository.findById(att.getId())
|
||||||
.orElseThrow(IllegalStateException::new).getArticleId()).isNull();
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
assertThat(released.getArticleId()).isNull();
|
||||||
|
assertThat(released.getRefType()).as("B2:文章删除后无场景引用 → 未使用").isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
@DisplayName("上传图片:按 cms/{siteCode}/{yyyyMM}/{uuid32}.ext 落盘并落库,webUrl 拼接")
|
@DisplayName("上传图片:按 cms/{siteCode}/{yyyyMM}/{uuid32}.ext 落盘并落库,webUrl 拼接")
|
||||||
void upload_imageStoresUnderSiteMonthDir() {
|
void upload_imageStoresUnderSiteMonthDir() {
|
||||||
AttachmentVo vo = attachmentService.upload(
|
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())
|
com.yunhai.cms.entity.CmsAttachment entity = attachmentRepository.findById(vo.getId())
|
||||||
.orElseThrow(IllegalStateException::new);
|
.orElseThrow(IllegalStateException::new);
|
||||||
|
|
||||||
@@ -111,25 +111,25 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
void upload_whitelistedDocumentTypes() {
|
void upload_whitelistedDocumentTypes() {
|
||||||
for (String name : new String[]{"通知.pdf", "数据.xlsx", "材料.zip", "讲话.mp4", "说明.txt"}) {
|
for (String name : new String[]{"通知.pdf", "数据.xlsx", "材料.zip", "讲话.mp4", "说明.txt"}) {
|
||||||
assertThat(attachmentService.upload(
|
assertThat(attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null, file(name, "application/octet-stream"))
|
TestUsers.superAdmin(), siteId, null, null, file(name, "application/octet-stream"))
|
||||||
.getId()).isNotNull();
|
.getId()).isNotNull();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("附件列表:orphanOnly 过滤孤儿并返回 orphanCount")
|
@DisplayName("附件列表:unusedOnly 过滤未使用并返回 unusedCount(B2 语义:无文章引用且无场景引用)")
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
void page_orphanFilterAndCount() {
|
void page_orphanFilterAndCount() {
|
||||||
attachmentService.upload(TestUsers.superAdmin(), siteId, 1, imageFile("jpg"));
|
attachmentService.upload(TestUsers.superAdmin(), siteId, 1, null, imageFile("jpg"));
|
||||||
attachmentService.upload(TestUsers.superAdmin(), siteId, null, imageFile("png"));
|
attachmentService.upload(TestUsers.superAdmin(), siteId, null, null, imageFile("png"));
|
||||||
|
|
||||||
Map<String, Object> all = attachmentService.page(TestUsers.superAdmin(), siteId, false, 1, 10);
|
Map<String, Object> all = attachmentService.page(TestUsers.superAdmin(), siteId, false, 1, 10);
|
||||||
assertThat(all.get("total")).isEqualTo(2L);
|
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);
|
Map<String, Object> unused = attachmentService.page(TestUsers.superAdmin(), siteId, true, 1, 10);
|
||||||
assertThat(orphans.get("total")).isEqualTo(1L);
|
assertThat(unused.get("total")).isEqualTo(1L);
|
||||||
List<AttachmentVo> list = (List<AttachmentVo>) orphans.get("list");
|
List<AttachmentVo> list = (List<AttachmentVo>) unused.get("list");
|
||||||
assertThat(list).extracting(AttachmentVo::getFileName).containsExactly("图.png");
|
assertThat(list).extracting(AttachmentVo::getFileName).containsExactly("图.png");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,16 +140,16 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
void upload_dangerousExtensionsRejected() {
|
void upload_dangerousExtensionsRejected() {
|
||||||
for (String name : new String[]{"木马.exe", "脚本.bat", "图.svg"}) {
|
for (String name : new String[]{"木马.exe", "脚本.bat", "图.svg"}) {
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
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)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不支持的文件类型");
|
.hasMessageContaining("不支持的文件类型");
|
||||||
}
|
}
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
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)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不支持的文件类型");
|
.hasMessageContaining("不支持的文件类型");
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
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)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不支持的文件类型");
|
.hasMessageContaining("不支持的文件类型");
|
||||||
}
|
}
|
||||||
@@ -162,21 +162,21 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
exeBytes[0] = 'M';
|
exeBytes[0] = 'M';
|
||||||
exeBytes[1] = 'Z';
|
exeBytes[1] = 'Z';
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
assertThatThrownBy(() -> attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null,
|
TestUsers.superAdmin(), siteId, null, null,
|
||||||
new MockMultipartFile("file", "照片.jpg", "image/jpeg", exeBytes)))
|
new MockMultipartFile("file", "照片.jpg", "image/jpeg", exeBytes)))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不是有效图片");
|
.hasMessageContaining("不是有效图片");
|
||||||
|
|
||||||
// 真实图片字节 + application/octet-stream → Content-Type 校验拒绝
|
// 真实图片字节 + application/octet-stream → Content-Type 校验拒绝
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
assertThatThrownBy(() -> attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null,
|
TestUsers.superAdmin(), siteId, null, null,
|
||||||
new MockMultipartFile("file", "照片.jpg", "application/octet-stream", tinyImage("jpg"))))
|
new MockMultipartFile("file", "照片.jpg", "application/octet-stream", tinyImage("jpg"))))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("Content-Type");
|
.hasMessageContaining("Content-Type");
|
||||||
|
|
||||||
// 纯文本改名 .png(Content-Type 伪装到位)→ 魔数校验拒绝
|
// 纯文本改名 .png(Content-Type 伪装到位)→ 魔数校验拒绝
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
assertThatThrownBy(() -> attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null,
|
TestUsers.superAdmin(), siteId, null, null,
|
||||||
new MockMultipartFile("file", "文档.png", "image/png",
|
new MockMultipartFile("file", "文档.png", "image/png",
|
||||||
"not-an-image".getBytes(StandardCharsets.UTF_8))))
|
"not-an-image".getBytes(StandardCharsets.UTF_8))))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
@@ -184,7 +184,7 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
|
|
||||||
// webp 后缀但魔数头错误 → 拒绝
|
// webp 后缀但魔数头错误 → 拒绝
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
assertThatThrownBy(() -> attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null,
|
TestUsers.superAdmin(), siteId, null, null,
|
||||||
new MockMultipartFile("file", "动图.webp", "image/webp",
|
new MockMultipartFile("file", "动图.webp", "image/webp",
|
||||||
"RIFFxxxxGIF8".getBytes(StandardCharsets.UTF_8))))
|
"RIFFxxxxGIF8".getBytes(StandardCharsets.UTF_8))))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
@@ -196,12 +196,12 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
void upload_realImagesAccepted() {
|
void upload_realImagesAccepted() {
|
||||||
for (String format : new String[]{"jpg", "png", "gif", "bmp"}) {
|
for (String format : new String[]{"jpg", "png", "gif", "bmp"}) {
|
||||||
assertThat(attachmentService.upload(
|
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()))
|
new MockMultipartFile("file", "动图.webp", "image/webp", webpBytes()))
|
||||||
.getId()).isNotNull();
|
.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()))
|
new MockMultipartFile("file", "图标.ico", "image/x-icon", icoBytes()))
|
||||||
.getId()).isNotNull();
|
.getId()).isNotNull();
|
||||||
}
|
}
|
||||||
@@ -210,19 +210,19 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
@DisplayName("空文件/不存在站点/越权站点上传被拒(异常分支)")
|
@DisplayName("空文件/不存在站点/越权站点上传被拒(异常分支)")
|
||||||
void upload_invalidInputsRejected() {
|
void upload_invalidInputsRejected() {
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
assertThatThrownBy(() -> attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null, new MockMultipartFile(
|
TestUsers.superAdmin(), siteId, null, null, new MockMultipartFile(
|
||||||
"file", "空.jpg", "image/jpeg", new byte[0])))
|
"file", "空.jpg", "image/jpeg", new byte[0])))
|
||||||
.isInstanceOf(BizException.class)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不能为空");
|
.hasMessageContaining("不能为空");
|
||||||
|
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
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)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("站点不存在");
|
.hasMessageContaining("站点不存在");
|
||||||
|
|
||||||
// 学校2 编辑不能向学校1 站点上传(多租户)
|
// 学校2 编辑不能向学校1 站点上传(多租户)
|
||||||
assertThatThrownBy(() -> attachmentService.upload(
|
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)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("无权");
|
.hasMessageContaining("无权");
|
||||||
}
|
}
|
||||||
@@ -231,7 +231,7 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
@DisplayName("删除附件记录:本站成功(物理文件留待运维清理),他站 FORBIDDEN")
|
@DisplayName("删除附件记录:本站成功(物理文件留待运维清理),他站 FORBIDDEN")
|
||||||
void delete_siteScoped() {
|
void delete_siteScoped() {
|
||||||
Integer attId = attachmentService.upload(
|
Integer attId = attachmentService.upload(
|
||||||
TestUsers.superAdmin(), siteId, null, imageFile("jpg")).getId();
|
TestUsers.superAdmin(), siteId, null, null, imageFile("jpg")).getId();
|
||||||
|
|
||||||
DeleteRequest wrongSite = new DeleteRequest();
|
DeleteRequest wrongSite = new DeleteRequest();
|
||||||
wrongSite.setSiteId(persistSite("demo2", 2, "演示站点二").getId());
|
wrongSite.setSiteId(persistSite("demo2", 2, "演示站点二").getId());
|
||||||
@@ -246,4 +246,101 @@ class AttachmentServiceIT extends CmsIntegrationTestBase {
|
|||||||
attachmentService.delete(TestUsers.superAdmin(), del);
|
attachmentService.delete(TestUsers.superAdmin(), del);
|
||||||
assertThat(attachmentRepository.findById(attId)).isEmpty();
|
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);
|
b.setStatus(1);
|
||||||
return b;
|
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)
|
.isInstanceOf(BizException.class)
|
||||||
.hasMessageContaining("不存在");
|
.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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,6 +457,174 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
|||||||
.hasMessageContaining("不能改为院系子站");
|
.hasMessageContaining("不能改为院系子站");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== B1 建站权限拆分(校级可建/编辑本院系子站,顶层站仍仅超管)=====
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级用户新建院系子站成功,schId 自动继承本校主站")
|
||||||
|
void save_subSiteBySchoolAdmin_shouldSucceed() {
|
||||||
|
CmsSite main = persistSite("b1main", 9, "云海大学门户");
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setCode("b1jdx");
|
||||||
|
req.setParentSiteId(main.getId());
|
||||||
|
req.setName("机电学院");
|
||||||
|
|
||||||
|
SiteVo vo = siteService.save(TestUsers.schoolUser(9), req);
|
||||||
|
|
||||||
|
assertThat(vo.getParentSiteId()).isEqualTo(main.getId());
|
||||||
|
CmsSite saved = siteService.getByCode("b1jdx");
|
||||||
|
assertThat(saved.getSchId()).as("schId 留空自动继承主站").isEqualTo(9);
|
||||||
|
assertThat(saved.getSchName()).isEqualTo("学校-9");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级用户挂他校主站建子站被拒(跨校)")
|
||||||
|
void save_subSiteBySchoolAdmin_otherSchoolParent_forbidden() {
|
||||||
|
CmsSite otherMain = persistSite("b1other", 8, "他校门户");
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setCode("b1steal");
|
||||||
|
req.setParentSiteId(otherMain.getId());
|
||||||
|
req.setName("越权院系站");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("无权");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级用户新建顶层站被拒(仅超管)")
|
||||||
|
void save_topSiteBySchoolAdmin_forbidden() {
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setCode("b1top");
|
||||||
|
req.setSchId(9);
|
||||||
|
req.setName("越权顶层站");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("超管");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级编辑本校院系子站(改名/换肤)成功")
|
||||||
|
void save_editOwnSubSiteBySchoolAdmin_shouldSucceed() {
|
||||||
|
CmsSite main = persistSite("b1main2", 9, "云海大学门户");
|
||||||
|
persistSite("b1jdx2", 9, "机电学院", main.getId());
|
||||||
|
Integer subId = siteService.getByCode("b1jdx2").getId();
|
||||||
|
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setId(subId);
|
||||||
|
req.setCode("b1jdx2");
|
||||||
|
req.setParentSiteId(main.getId());
|
||||||
|
req.setName("机械与电气工程学院");
|
||||||
|
req.setTheme("t-demo-red");
|
||||||
|
|
||||||
|
SiteVo vo = siteService.save(TestUsers.schoolUser(9), req);
|
||||||
|
|
||||||
|
assertThat(vo.getName()).isEqualTo("机械与电气工程学院");
|
||||||
|
CmsSite saved = siteService.getByCode("b1jdx2");
|
||||||
|
assertThat(saved.getTheme()).as("换肤生效").isEqualTo("t-demo-red");
|
||||||
|
assertThat(saved.getParentSiteId()).isEqualTo(main.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级把院系子站改挂为顶层(parentSiteId 传 0)被拒")
|
||||||
|
void save_editSubSite_promoteToTopBySchoolAdmin_forbidden() {
|
||||||
|
CmsSite main = persistSite("b1main3", 9, "云海大学门户");
|
||||||
|
persistSite("b1jdx3", 9, "机电学院", main.getId());
|
||||||
|
Integer subId = siteService.getByCode("b1jdx3").getId();
|
||||||
|
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setId(subId);
|
||||||
|
req.setCode("b1jdx3");
|
||||||
|
req.setParentSiteId(0);
|
||||||
|
req.setName("机电学院");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("超管");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级编辑顶层站(含本校主站)被拒")
|
||||||
|
void save_editTopSiteBySchoolAdmin_forbidden() {
|
||||||
|
CmsSite main = persistSite("b1main4", 9, "云海大学门户");
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setId(main.getId());
|
||||||
|
req.setCode("b1main4");
|
||||||
|
req.setParentSiteId(0);
|
||||||
|
req.setSchId(9);
|
||||||
|
req.setName("云海大学门户");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("超管");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 无校级角色(schId 为空)建子站被拒")
|
||||||
|
void save_subSiteBySchoolAdmin_noSchIdRole_forbidden() {
|
||||||
|
CmsSite main = persistSite("b1main5", 9, "云海大学门户");
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setCode("b1norole");
|
||||||
|
req.setParentSiteId(main.getId());
|
||||||
|
req.setName("无角色院系站");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.plainUser(), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("无权");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级请求体伪造 schId(与他校一致)被拒(关闭伪造通道)")
|
||||||
|
void save_subSiteBySchoolAdmin_forgedSchId_forbidden() {
|
||||||
|
CmsSite main = persistSite("b1main6", 9, "云海大学门户");
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setCode("b1forge");
|
||||||
|
req.setParentSiteId(main.getId());
|
||||||
|
req.setSchId(8);
|
||||||
|
req.setName("伪造学校院系站");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("无权");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级编辑他校院系子站被拒(防改挂接管)")
|
||||||
|
void save_editOtherSchoolSubSiteBySchoolAdmin_forbidden() {
|
||||||
|
CmsSite ownMain = persistSite("b1main7", 9, "云海大学门户");
|
||||||
|
CmsSite otherMain = persistSite("b1main8", 8, "他校门户");
|
||||||
|
persistSite("b1otherjdx", 8, "他校机电学院", otherMain.getId());
|
||||||
|
Integer otherSubId = siteService.getByCode("b1otherjdx").getId();
|
||||||
|
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setId(otherSubId);
|
||||||
|
req.setCode("b1otherjdx");
|
||||||
|
req.setParentSiteId(ownMain.getId());
|
||||||
|
req.setName("被他校改挂");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.schoolUser(9), req))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("无权");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B1: 校级编辑子站改挂到另一同校主站仍放行(既有能力保留)")
|
||||||
|
void save_editSubSite_moveToAnotherSameSchoolMainBySchoolAdmin_shouldSucceed() {
|
||||||
|
CmsSite mainA = persistSite("b1maina", 9, "主站A");
|
||||||
|
CmsSite mainB = persistSite("b1mainb", 9, "主站B");
|
||||||
|
persistSite("b1jdx9", 9, "机电学院", mainA.getId());
|
||||||
|
Integer subId = siteService.getByCode("b1jdx9").getId();
|
||||||
|
|
||||||
|
SiteSaveRequest req = new SiteSaveRequest();
|
||||||
|
req.setId(subId);
|
||||||
|
req.setCode("b1jdx9");
|
||||||
|
req.setParentSiteId(mainB.getId());
|
||||||
|
req.setName("机电学院");
|
||||||
|
|
||||||
|
SiteVo vo = siteService.save(TestUsers.schoolUser(9), req);
|
||||||
|
assertThat(vo.getParentSiteId()).isEqualTo(mainB.getId());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("编辑院系子站:可改挂到另一顶层主站(同校)")
|
@DisplayName("编辑院系子站:可改挂到另一顶层主站(同校)")
|
||||||
void save_collegeCanMoveToAnotherMain() {
|
void save_collegeCanMoveToAnotherMain() {
|
||||||
@@ -475,4 +643,165 @@ class SiteServiceIT extends CmsIntegrationTestBase {
|
|||||||
SiteVo vo = siteService.save(TestUsers.superAdmin(), req);
|
SiteVo vo = siteService.save(TestUsers.superAdmin(), req);
|
||||||
assertThat(vo.getParentSiteId()).isEqualTo(mainBId);
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== B3 custom_css 站点级自定义样式(服务端黑名单强校验)=====
|
||||||
|
|
||||||
|
private SiteSaveRequest cssReq(String code, String css) {
|
||||||
|
SiteSaveRequest req = baseConfigReq(code);
|
||||||
|
req.setConfigJson("{\"custom_css\":\"" + css + "\"}");
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: 合法 custom_css 保存成功(普通 CSS 规则)")
|
||||||
|
void save_customCss_valid_passes() {
|
||||||
|
String css = ".site-header{padding:16px 0}";
|
||||||
|
siteService.save(TestUsers.superAdmin(), cssReq("css1", css));
|
||||||
|
assertThat(siteService.configValue(siteService.getByCode("css1"), "custom_css"))
|
||||||
|
.isEqualTo(css);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: custom_css 超过 10KB 被拒(上限 10240 字符)")
|
||||||
|
void save_customCss_overLimit_rejected() {
|
||||||
|
StringBuilder big = new StringBuilder();
|
||||||
|
for (int i = 0; i < 10241; i++) {
|
||||||
|
big.append('a');
|
||||||
|
}
|
||||||
|
assertThatThrownBy(() -> siteService.save(TestUsers.superAdmin(), cssReq("css2", big.toString())))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
|
||||||
|
// 恰好 10240 放行(边界)
|
||||||
|
StringBuilder edge = new StringBuilder();
|
||||||
|
for (int i = 0; i < 10240; i++) {
|
||||||
|
edge.append('a');
|
||||||
|
}
|
||||||
|
assertThat(siteService.save(TestUsers.superAdmin(), cssReq("css3", edge.toString()))).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: custom_css 含 </style> 被拒(忽略大小写,防逃逸内联 style 块)")
|
||||||
|
void save_customCss_containsStyleClose_rejected() {
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css4", ".a{color:red}</style><b>")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css5", ".a{color:red}</STYLE>")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: custom_css 含 <script 被拒(忽略大小写)")
|
||||||
|
void save_customCss_containsScript_rejected() {
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css6", "</style><script>alert(1)</script>")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css7", ".a{color:red}<SCRIPT src=x>")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3: custom_css 含 @import 被拒(防外链样式注入)")
|
||||||
|
void save_customCss_containsAtImport_rejected() {
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css8", "@import url(http://evil.com/x.css);")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css9", ".a{color:red}\\n@IMPORT 'x.css'")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("B3(补充): custom_css 含 expression( 被拒(IE 表达式向量,红线四项之一)")
|
||||||
|
void save_customCss_containsExpression_rejected() {
|
||||||
|
assertThatThrownBy(() -> siteService.save(
|
||||||
|
TestUsers.superAdmin(), cssReq("css10", "body{width:EXPRESSION(alert(1))}")))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessageContaining("custom_css");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ CREATE TABLE `cms_attachment` (
|
|||||||
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||||
`site_id` INT NOT NULL COMMENT '所属站点',
|
`site_id` INT NOT NULL COMMENT '所属站点',
|
||||||
`article_id` INT 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_name` VARCHAR(255) NOT NULL COMMENT '原始文件名',
|
||||||
`file_path` VARCHAR(500) NOT NULL COMMENT '存储路径',
|
`file_path` VARCHAR(500) NOT NULL COMMENT '存储路径',
|
||||||
`web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL',
|
`web_url` VARCHAR(500) NOT NULL COMMENT 'Web 访问 URL',
|
||||||
@@ -93,7 +95,8 @@ CREATE TABLE `cms_attachment` (
|
|||||||
`update_time` DATETIME NULL,
|
`update_time` DATETIME NULL,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_att_article` (`article_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 附件';
|
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 附件';
|
||||||
|
|
||||||
CREATE TABLE `cms_banner` (
|
CREATE TABLE `cms_banner` (
|
||||||
|
|||||||
Reference in New Issue
Block a user