feat(W1): 皮肤包完整管理-外部主题目录+热注册,zip上传/派生/编辑/删除,ThemePackageValidator收敛,Thymeleaf文件解析器,list增source/inUseCount/editable/usedBySiteNames,T-W1-01..20 全绿299/299
build / build-push (push) Successful in 52s

This commit is contained in:
chenxing
2026-09-08 14:52:29 +08:00
parent 536bd6cea4
commit 338ffb56dd
16 changed files with 1548 additions and 71 deletions
@@ -1,8 +1,13 @@
package com.yunhai.cms.theme;
import com.yunhai.cms.controller.api.ThemeController;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.support.CmsIntegrationTestBase;
import com.yunhai.cms.support.TestUsers;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -14,6 +19,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -32,14 +38,33 @@ class ThemeApiIT extends CmsIntegrationTestBase {
private ThemeService themeService;
@Autowired
private ThemeRegistry themeRegistry;
@Autowired
private ThemePackageService themePackageService;
@Autowired
private SiteService siteService;
@Autowired
private PortalQueryService portalQueryService;
@Autowired
private CmsSiteRepository siteRepository;
private MockMvc standalone;
private java.nio.file.Path themeDir;
@BeforeEach
void setUp() {
void setUp() throws Exception {
purgeTables();
// W1:IT 内把外部主题目录指向临时目录(不污染 ${user.dir}/data/themes)
themeDir = java.nio.file.Files.createTempDirectory("w1-it-themes");
themeRegistry.setThemeDir(themeDir.toString());
themeRegistry.reload();
themePackageService.setThemeDir(themeDir.toString());
standalone = MockMvcBuilders
.standaloneSetup(new ThemeController(themeService, themeRegistry)).build();
.standaloneSetup(new ThemeController(themeService, themeRegistry, themePackageService)).build();
}
@AfterEach
void cleanThemeDir() {
com.yunhai.cms.support.ThemePackageTestSupport.deleteRecursively(themeDir);
}
@AfterEach
@@ -109,4 +134,139 @@ class ThemeApiIT extends CmsIntegrationTestBase {
.andExpect(header().string("Cache-Control", "max-age=3600, public"));
}
}
// ===== W1 皮肤包管理(方案 §5.3 T-W1-13..18)=====
@Test
@DisplayName("T-W1-13: 非超管上传 403")
void uploadBySchoolAdminForbidden() throws Exception {
org.springframework.mock.web.MockMultipartFile zip =
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-fb", "t-api-fb", true, true, true, true, true, null);
assertThatThrownBy(() -> themePackageService.upload(TestUsers.schoolUser(1), zip))
.isInstanceOf(BizException.class)
.hasMessageContaining("仅平台超管");
}
@Test
@DisplayName("T-W1-14: list 增补字段 source/inUseCount/editable;内置包 editable=false")
void listCarriesNewFields() throws Exception {
themePackageService.upload(TestUsers.superAdmin(),
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-ext", "t-api-ext", true, true, true, true, true, null));
List<com.yunhai.cms.model.vo.ThemeVo> list = themeService.list();
com.yunhai.cms.model.vo.ThemeVo def = list.stream()
.filter(v -> "default".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new);
assertThat(def.getSource()).isEqualTo("builtin");
assertThat(def.isEditable()).isFalse();
assertThat(def.getInUseCount()).isGreaterThanOrEqualTo(0);
com.yunhai.cms.model.vo.ThemeVo ext = list.stream()
.filter(v -> "t-api-ext".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new);
assertThat(ext.getSource()).isEqualTo("external");
assertThat(ext.isEditable()).isTrue();
assertThat(ext.getInUseCount()).isEqualTo(0);
}
@Test
@DisplayName("T-W1-15: 被用外部包删除拒绝且消息含站点名")
void deleteInUsePackageRejected() throws Exception {
themePackageService.upload(TestUsers.superAdmin(),
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-del", "t-api-del", true, true, true, true, true, null));
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setCode("delsite");
req.setParentSiteId(0);
req.setSchId(1);
req.setSchName("第一中学");
req.setName("删除保护门户");
req.setTheme("t-api-del");
siteService.save(TestUsers.superAdmin(), req);
assertThatThrownBy(() -> themePackageService.remove(TestUsers.superAdmin(), "t-api-del"))
.isInstanceOf(BizException.class)
.hasMessageContaining("删除保护门户");
assertThat(themeRegistry.contains("t-api-del")).isTrue();
}
@Test
@DisplayName("T-W1-16: 未被用外部包删除成功——list 不含、目录不存在、引用站点历史快照失效")
void deleteUnusedPackageSucceeds() throws Exception {
themePackageService.upload(TestUsers.superAdmin(),
com.yunhai.cms.support.ThemePackageTestSupport.buildZip(
"t-api-ext2", "t-api-ext2", true, true, true, true, true, null));
com.yunhai.cms.model.dto.SiteSaveRequest req = new com.yunhai.cms.model.dto.SiteSaveRequest();
req.setCode("extsite");
req.setParentSiteId(0);
req.setSchId(1);
req.setSchName("第一中学");
req.setName("外部皮肤门户");
req.setTheme("t-api-ext2");
siteService.save(TestUsers.superAdmin(), req);
// 删除前快照主题=外部包(进缓存)
assertThat(portalQueryService.siteSnapshotByPath("extsite").getTheme()).isEqualTo("t-api-ext2");
// 站点先换回默认皮肤(保存点发布 SiteContentChangedEvent → 缓存失效)
com.yunhai.cms.model.dto.SiteSaveRequest switchBack = new com.yunhai.cms.model.dto.SiteSaveRequest();
switchBack.setId(siteRepository.findByCode("extsite").orElseThrow(IllegalStateException::new).getId());
switchBack.setCode("extsite");
switchBack.setParentSiteId(0);
switchBack.setSchId(1);
switchBack.setName("外部皮肤门户");
switchBack.setTheme(com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME);
siteService.save(TestUsers.superAdmin(), switchBack);
themePackageService.remove(TestUsers.superAdmin(), "t-api-ext2");
assertThat(themeRegistry.contains("t-api-ext2")).isFalse();
assertThat(themeService.list().stream()
.noneMatch(v -> "t-api-ext2".equals(v.getName()))).isTrue();
assertThat(java.nio.file.Files.exists(themeDir.resolve("t-api-ext2"))).isFalse();
// SiteContentChangedEvent 失效链路:缓存清空后重新装配回退 default(历史快照已失效)
assertThat(portalQueryService.siteSnapshotByPath("extsite").getTheme())
.isEqualTo(com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME);
}
@Test
@DisplayName("T-W1-17: 派生+编辑链路——derive(default→my-skin) → PUT meta → list 与资产反映新值")
void deriveAndUpdateMetaChain() throws Exception {
themePackageService.derive(TestUsers.superAdmin(), "default", "my-skin");
assertThat(themeRegistry.contains("my-skin")).isTrue();
assertThat(themeRegistry.sourceOf("my-skin")).isEqualTo("external");
com.yunhai.cms.model.vo.ThemeVo.ThemeParamVo primary = themeService.list().stream()
.filter(v -> "default".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new)
.getParams().stream().filter(p -> "primary".equals(p.getKey()))
.findFirst().orElseThrow(IllegalStateException::new);
com.yunhai.cms.theme.ThemeParam edited = new com.yunhai.cms.theme.ThemeParam();
edited.setKey(primary.getKey());
edited.setLabel(primary.getLabel());
edited.setType(primary.getType());
edited.setCssVar(primary.getCssVar());
edited.setDefaultValue("#123456");
themePackageService.updateMeta(TestUsers.superAdmin(), "my-skin", "我的皮肤",
java.util.Collections.singletonList(edited));
com.yunhai.cms.model.vo.ThemeVo vo = themeService.list().stream()
.filter(v -> "my-skin".equals(v.getName())).findFirst().orElseThrow(IllegalStateException::new);
assertThat(vo.getTitle()).isEqualTo("我的皮肤");
assertThat(vo.getParams().stream().filter(p -> "primary".equals(p.getKey()))
.findFirst().orElseThrow(IllegalStateException::new).getDefaultValue()).isEqualTo("#123456");
// 外部包资产经代理可取
standalone.perform(get("/api/theme/my-skin/asset/screenshot.png"))
.andExpect(status().isOk());
standalone.perform(get("/api/theme/my-skin/asset/css/main.css"))
.andExpect(status().isOk());
}
@Test
@DisplayName("T-W1-18: reload 端点权限——校级 403、超管 200")
void reloadEndpointPermission() throws Exception {
assertThatThrownBy(() -> themePackageService.reload(TestUsers.schoolUser(1)))
.isInstanceOf(BizException.class)
.hasMessageContaining("仅平台超管");
themePackageService.reload(TestUsers.superAdmin());
}
}