From 338ffb56dd67fadb1d300841f67e3a40453f3991 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 8 Sep 2026 14:52:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(W1):=20=E7=9A=AE=E8=82=A4=E5=8C=85?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E7=AE=A1=E7=90=86-=E5=A4=96=E9=83=A8?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E7=9B=AE=E5=BD=95+=E7=83=AD=E6=B3=A8?= =?UTF-8?q?=E5=86=8C,zip=E4=B8=8A=E4=BC=A0/=E6=B4=BE=E7=94=9F/=E7=BC=96?= =?UTF-8?q?=E8=BE=91/=E5=88=A0=E9=99=A4,ThemePackageValidator=E6=94=B6?= =?UTF-8?q?=E6=95=9B,Thymeleaf=E6=96=87=E4=BB=B6=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=99=A8,list=E5=A2=9Esource/inUseCount/editable/usedBySiteNam?= =?UTF-8?q?es,T-W1-01..20=20=E5=85=A8=E7=BB=BF299/299?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/ThemeTemplateResolverConfig.java | 84 ++++ .../cms/controller/api/ThemeController.java | 53 +- .../portal/PortalAssetController.java | 5 +- .../cms/model/dto/ThemeDeriveRequest.java | 17 + .../cms/model/dto/ThemeMetaRequest.java | 16 + .../java/com/yunhai/cms/model/vo/ThemeVo.java | 12 + .../cms/repository/CmsSiteRepository.java | 3 + .../yunhai/cms/service/impl/ThemeService.java | 23 +- .../yunhai/cms/theme/ThemePackageService.java | 461 ++++++++++++++++++ .../cms/theme/ThemePackageValidator.java | 75 +++ .../com/yunhai/cms/theme/ThemeRegistry.java | 212 +++++--- .../com/yunhai/cms/portal/ThemeRenderIT.java | 65 ++- .../cms/support/ThemePackageTestSupport.java | 100 ++++ .../java/com/yunhai/cms/theme/ThemeApiIT.java | 164 ++++++- .../cms/theme/ThemePackageServiceTest.java | 213 ++++++++ .../yunhai/cms/theme/ThemeRegistryTest.java | 116 +++++ 16 files changed, 1548 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/yunhai/cms/config/ThemeTemplateResolverConfig.java create mode 100644 src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java create mode 100644 src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java create mode 100644 src/main/java/com/yunhai/cms/theme/ThemePackageService.java create mode 100644 src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java create mode 100644 src/test/java/com/yunhai/cms/support/ThemePackageTestSupport.java create mode 100644 src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java diff --git a/src/main/java/com/yunhai/cms/config/ThemeTemplateResolverConfig.java b/src/main/java/com/yunhai/cms/config/ThemeTemplateResolverConfig.java new file mode 100644 index 0000000..d6a9dde --- /dev/null +++ b/src/main/java/com/yunhai/cms/config/ThemeTemplateResolverConfig.java @@ -0,0 +1,84 @@ +package com.yunhai.cms.config; + +import com.yunhai.cms.theme.ThemeRegistry; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.thymeleaf.IEngineConfiguration; +import org.thymeleaf.templateresolver.FileTemplateResolver; +import org.thymeleaf.templateresolver.ITemplateResolver; +import org.thymeleaf.templateresource.FileTemplateResource; +import org.thymeleaf.templateresource.ITemplateResource; + +import java.nio.file.Paths; + +/** + * W1(G7 关键,方案 §5.4 步骤 3):为 Thymeleaf 追加外部主题目录的文件解析器—— + * 视图名 {@code themes/{外部包}/home} 改写为文件路径 {cms.theme.dir}/{外部包}/home.html; + * 内置包名不改写(返回不存在资源,checkExistence=true 时本解析器跳过,由 classpath 解析器处理)。 + * + *

Spring 的 orderedStream 只认 {@code @Order}/{@code Ordered}(Thymeleaf 的 getOrder() 不参与 + * Spring 排序):必须排最前,否则 Boot 默认 SpringResourceTemplateResolver(checkExistence=false) + * 会抢先 claim 导致外部包模板解析不到。cacheable=true,registry.reload() 时经 + * TemplateEngine.clearTemplateCache() 清缓存;目录经 registry.getThemeDir() 动态读取 + * (测试可指向临时目录)。外部包门户 SSR 的前提(T-W1-19)。 + * + *

Thymeleaf 3.0.15 的 {@code computeTemplateResource} 后两个参数是 + * (resourceName=template+suffix, characterEncoding),非 suffix/markuper——路径由 template 自行拼接。 + */ +@Slf4j +@Configuration +public class ThemeTemplateResolverConfig { + + @Bean + @org.springframework.core.annotation.Order(Ordered.HIGHEST_PRECEDENCE) + public ITemplateResolver externalThemeTemplateResolver(final ThemeRegistry registry, + final org.springframework.beans.factory.ObjectProvider engineProvider) { + FileTemplateResolver resolver = new FileTemplateResolver() { + @Override + protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration, + String ownerTemplate, String template, + String resourceName, String characterEncoding, + java.util.Map templateResolutionAttributes) { + String t = template.replace('\\', '/'); + if (t.startsWith("themes/") && !t.equals("themes/")) { + String rest = t.substring("themes/".length()); + int slash = rest.indexOf('/'); + String pkg = slash > 0 ? rest.substring(0, slash) : rest; + if (ThemeRegistry.SOURCE_EXTERNAL.equals(registry.sourceOf(pkg))) { + String rel = (rest.endsWith(".html") ? rest : rest + ".html") + .replace('/', java.io.File.separatorChar); + String path = Paths.get(normalize(registry.getThemeDir()), rel).toString(); + return new FileTemplateResource(path, characterEncoding); + } + } + // 内置包名等:返回不存在资源(checkExistence=true → 本解析器跳过,回落 classpath 解析器) + return new FileTemplateResource("__nonexistent_external__/themes/" + t, characterEncoding); + } + }; + resolver.setCharacterEncoding("UTF-8"); + resolver.setCheckExistence(true); + resolver.setCacheable(true); + registry.addReloadListener(() -> { + try { + org.thymeleaf.TemplateEngine engine = engineProvider.getIfAvailable(); + if (engine != null) { + engine.clearTemplateCache(); + } + } catch (Exception e) { + log.warn("[cms] reload 清模板缓存失败:{}", e.getMessage()); + } + }); + log.info("[cms] 外部主题模板解析器已注册(仅外部包命中,@Order 最优先,内置包跳过回落 classpath)"); + return resolver; + } + + private static String normalize(String dir) { + String d = dir == null ? "" : dir.trim().replace('\\', '/'); + while (d.endsWith("/")) { + d = d.substring(0, d.length() - 1); + } + return d; + } +} diff --git a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java index d6ab2dd..0acd80f 100644 --- a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java +++ b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java @@ -1,9 +1,10 @@ package com.yunhai.cms.controller.api; import com.yunhai.cms.service.impl.ThemeService; +import com.yunhai.cms.theme.ThemeInfo; +import com.yunhai.cms.theme.ThemePackageService; import com.yunhai.cms.theme.ThemeRegistry; import lombok.RequiredArgsConstructor; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; @@ -12,7 +13,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import com.yunhai.platform.utils.model.BoaUser; import javax.servlet.http.HttpServletRequest; import java.time.Duration; @@ -33,12 +41,53 @@ public class ThemeController { private final ThemeService themeService; private final ThemeRegistry themeRegistry; + private final ThemePackageService themePackageService; @GetMapping("/list") public List list() { return themeService.list(); } + /** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册 */ + @PostMapping("/upload") + public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) { + ThemeInfo info = themePackageService.upload(authUserWithDept, file); + return themeService.voOf(info); + } + + /** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除) */ + @PostMapping("/derive") + public com.yunhai.cms.model.vo.ThemeVo derive(BoaUser authUserWithDept, + @RequestBody com.yunhai.cms.model.dto.ThemeDeriveRequest req) { + ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName()); + return themeService.voOf(info); + } + + /** W1(G4):编辑外部包 theme.json 的 title 与参数 schema(保存后引用站点立即生效) */ + @PutMapping("/{name}/meta") + public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept, + @PathVariable("name") String name, + @RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) { + ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name, req.getTitle(), req.getParams()); + return themeService.voOf(info); + } + + /** W1(G5):删除外部包(被用站点拒绝;内置不可删) */ + @DeleteMapping("/{name}") + public java.util.Map remove(BoaUser authUserWithDept, @PathVariable("name") String name) { + themePackageService.remove(authUserWithDept, name); + java.util.Map result = new java.util.HashMap<>(); + result.put("ok", true); + return result; + } + + /** W1(G2/B4):热重载(运维直放目录后生效;default 缺失报错且旧集合不丢) */ + @PostMapping("/reload") + public List reload(BoaUser authUserWithDept) { + themePackageService.reload(authUserWithDept); + return themeService.list(); + } + @GetMapping("/{name}/asset/**") public ResponseEntity asset(@PathVariable("name") String name, HttpServletRequest request) { @@ -49,7 +98,7 @@ public class ThemeController { if (path == null || path.isEmpty() || path.contains("..")) { return ResponseEntity.badRequest().build(); } - Resource resource = new ClassPathResource("templates/themes/" + name + "/assets/" + path); + Resource resource = themeRegistry.resource(name, "assets/" + path); if (!resource.exists()) { return ResponseEntity.notFound().build(); } diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java b/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java index 07904f2..2449d63 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalAssetController.java @@ -12,7 +12,6 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import javax.servlet.http.HttpServletRequest; @@ -65,8 +64,8 @@ public class PortalAssetController { if (path == null || path.isEmpty() || path.contains("..")) { return ResponseEntity.badRequest().build(); } - Resource resource = new ClassPathResource( - "templates/themes/" + theme + "/assets/" + path); + // W1(T-W1-20):内置→ClassPathResource、外部→FileSystemResource,统一走 Registry 定位 + Resource resource = themeRegistry.resource(theme, "assets/" + path); if (!resource.exists()) { return ResponseEntity.notFound().build(); } diff --git a/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java b/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java new file mode 100644 index 0000000..5e35a6f --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/ThemeDeriveRequest.java @@ -0,0 +1,17 @@ +package com.yunhai.cms.model.dto; + +import com.yunhai.cms.theme.ThemeParam; +import lombok.Data; + +import java.util.List; + +/** W1 皮肤包派生请求(POST /api/theme/derive) */ +@Data +public class ThemeDeriveRequest { + + /** 派生源包名(内置或外部) */ + private String source; + + /** 新包名(外部包目录名,theme.json name 同步改写) */ + private String name; +} diff --git a/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java b/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java new file mode 100644 index 0000000..57a510a --- /dev/null +++ b/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java @@ -0,0 +1,16 @@ +package com.yunhai.cms.model.dto; + +import com.yunhai.cms.theme.ThemeParam; +import lombok.Data; + +import java.util.List; + +/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta):title/参数 schema,保留其余 theme.json 字段 */ +@Data +public class ThemeMetaRequest { + + private String title; + + /** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改 */ + private List params; +} diff --git a/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java b/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java index cd074c1..d4a79e6 100644 --- a/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java +++ b/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java @@ -30,6 +30,18 @@ public class ThemeVo { /** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */ private List params; + /** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */ + private String source; + + /** 被用站点数(W1,G1:删除保护口径一致) */ + private long inUseCount; + + /** 是否可编辑/删除(W1:仅外部包 true) */ + private boolean editable; + + /** 引用站点名列表(W1:删除保护提示/前端 tooltip,与 inUseCount 同源) */ + private List usedBySiteNames; + @Data public static class ThemeParamVo { diff --git a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java index c4e4457..14e89d9 100644 --- a/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java +++ b/src/main/java/com/yunhai/cms/repository/CmsSiteRepository.java @@ -21,6 +21,9 @@ public interface CmsSiteRepository extends JpaRepository { /** 皮肤包被用站点数(W1 删除保护共用) */ long countByTheme(String theme); + /** 使用指定皮肤包的站点(W1 删除保护:拒绝消息提示站点名) */ + List findByTheme(String theme); + /** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */ List findByStatusOrderByIdAsc(Integer status); diff --git a/src/main/java/com/yunhai/cms/service/impl/ThemeService.java b/src/main/java/com/yunhai/cms/service/impl/ThemeService.java index 115eae8..7cf2179 100644 --- a/src/main/java/com/yunhai/cms/service/impl/ThemeService.java +++ b/src/main/java/com/yunhai/cms/service/impl/ThemeService.java @@ -18,16 +18,35 @@ import java.util.List; public class ThemeService { private final ThemeRegistry themeRegistry; + private final com.yunhai.cms.repository.CmsSiteRepository siteRepository; - /** 可用主题(default 优先,其余按名称排序),管理端建站向导与站点编辑共用 */ + /** 可用主题(default 优先,其余按名称排序),管理端建站向导与站点编辑共用。 + * W1(G1/T-W1-14):增 source/inUseCount/editable——来源与删除保护口径同源(findByTheme)。 */ public List list() { List result = new ArrayList<>(); for (ThemeInfo info : themeRegistry.list()) { - result.add(ThemeVo.of(info)); + result.add(voOf(info)); } return result; } + /** 单个包 VO 装配(来源/被用数/可编辑随 Registry sourceOf 与站点仓储实时装配) */ + public ThemeVo voOf(ThemeInfo info) { + ThemeVo vo = ThemeVo.of(info); + String source = themeRegistry.sourceOf(info.getName()) == null + ? ThemeRegistry.SOURCE_BUILTIN : themeRegistry.sourceOf(info.getName()); + vo.setSource(source); + java.util.List usedBy = siteRepository.findByTheme(info.getName()); + vo.setInUseCount(usedBy.size()); + java.util.List usedNames = new ArrayList<>(); + for (com.yunhai.cms.entity.CmsSite s : usedBy) { + usedNames.add(s.getName()); + } + vo.setUsedBySiteNames(usedNames); + vo.setEditable(ThemeRegistry.SOURCE_EXTERNAL.equals(source)); + return vo; + } + /** 未注册主题返回 null(白名单判断用) */ public ThemeInfo get(String name) { return themeRegistry.get(name); diff --git a/src/main/java/com/yunhai/cms/theme/ThemePackageService.java b/src/main/java/com/yunhai/cms/theme/ThemePackageService.java new file mode 100644 index 0000000..e426b17 --- /dev/null +++ b/src/main/java/com/yunhai/cms/theme/ThemePackageService.java @@ -0,0 +1,461 @@ +package com.yunhai.cms.theme; + +import com.yunhai.cms.entity.CmsSite; +import com.yunhai.cms.event.SiteContentChangedEvent; +import com.yunhai.cms.repository.CmsSiteRepository; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * 皮肤包完整管理(W1,方案 §5.4 步骤 2,D1:内置只读、外部可管): + * 上传(zip 防御:条目数≤2000/单文件≤10MB/总≤20MB/zip-slip/扩展名白名单)→ + * 解包临时目录 → 复用 Registry 校验语义(ThemePackageValidator)产出报告式错误 → + * 原子 move 到 {cms.theme.dir}/{name} → registry.reload(); + * 派生(复制包体+改名)、编辑(仅重写 theme.json 的 title/params,保留 specVersion 等)、 + * 删除(被用站点拒绝且提示站点名;内置不可删);写操作后发布 SiteContentChangedEvent(缓存失效)。 + */ +@Slf4j +@Service +public class ThemePackageService { + + private static final long MAX_TOTAL_BYTES = 20L * 1024 * 1024; + private static final long MAX_ENTRY_BYTES = 10L * 1024 * 1024; + private static final int MAX_ENTRIES = 2000; + private static final Pattern SAFE_ENTRY = Pattern.compile("^[A-Za-z0-9_\\-./]{1,200}$"); + private static final Set ALLOWED_EXTENSIONS = new HashSet<>(Arrays.asList( + ".html", ".css", ".js", ".json", ".txt", ".ico", ".svg", ".png", ".jpg", ".jpeg", + ".gif", ".webp", ".woff", ".woff2", ".ttf")); + + private final ThemeRegistry themeRegistry; + private final CmsSiteRepository siteRepository; + private final ApplicationEventPublisher eventPublisher; + + private String themeDir; + + public ThemePackageService(ThemeRegistry themeRegistry, CmsSiteRepository siteRepository, + ApplicationEventPublisher eventPublisher) { + this.themeRegistry = themeRegistry; + this.siteRepository = siteRepository; + this.eventPublisher = eventPublisher; + this.themeDir = System.getProperty("user.dir") + "/data/themes"; + } + + @Autowired + public void applyRegistryDir(ThemeRegistry registry) { + this.themeDir = registry.getThemeDir(); + } + + public void setThemeDir(String dir) { + this.themeDir = dir; + } + + // ===== 上传(T-W1-05..12)===== + + public ThemeInfo upload(BoaUser user, MultipartFile file) { + requireSuperAdmin(user); + if (file == null || file.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空"); + } + if (file.getSize() > MAX_TOTAL_BYTES) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包超过 20MB 上限(" + file.getSize() + " 字节)"); + } + Path staging = null; + try { + staging = Files.createTempDirectory(Paths.get(themeDir), ".upload-"); + List report = new ArrayList<>(); + String pkgName = extractZip(file, staging, report); + if (!report.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:" + String.join(";", report)); + } + if (pkgName == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:缺 theme.json"); + } + String rootDir = singleRootDirName(staging); + if (rootDir != null && !rootDir.equals(pkgName)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "皮肤包校验未通过:name 与目录名不一致:" + rootDir + " != " + pkgName); + } + if (themeRegistry.contains(pkgName) + && ThemeRegistry.SOURCE_BUILTIN.equals(themeRegistry.sourceOf(pkgName))) { + throw new BizException(ErrorCode.PARAM_ERROR, + "皮肤包与内置包同名(" + pkgName + "),禁止覆盖内置包"); + } + // 包内校验(复用 Registry 语义 → 报告式错误,T-W1-08/09/10) + validateStagedPackage(rootDir == null ? staging : staging.resolve(rootDir), report); + if (!report.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:" + String.join(";", report)); + } + // 原子替换/落位(同名外部包=覆盖更新,T-W1-12) + Path target = Paths.get(themeDir, pkgName); + if (Files.exists(target)) { + Path old = Files.createTempDirectory(Paths.get(themeDir), ".old-"); + moveDir(target, old.resolve("pkg")); + } + moveDir(rootDir == null ? staging : staging.resolve(rootDir), target); + themeRegistry.reload(); + publishThemeChanged(); + log.info("皮肤包 {} 上传注册成功(外部包)", pkgName); + return themeRegistry.get(pkgName); + } catch (BizException e) { + throw e; + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包处理失败:" + e.getMessage()); + } finally { + if (staging != null) { + deleteRecursively(staging); + } + } + } + + /** zip 解包后:仅当 staging 下恰有一个目录且无散落文件时返回该目录名(包根目录),否则 null */ + private static String singleRootDirName(Path staging) throws IOException { + List entries = new ArrayList<>(); + try (java.util.stream.Stream children = Files.list(staging)) { + children.forEach(entries::add); + } + if (entries.size() == 1 && Files.isDirectory(entries.get(0))) { + return entries.get(0).getFileName().toString(); + } + return null; + } + + /** 解包到 staging;返回 theme.json 声明的 name(防御:zip-slip/条目数/体积/扩展名白名单) */ + private String extractZip(MultipartFile file, Path staging, List report) throws IOException { + String declaredName = null; + int entries = 0; + long total = 0; + try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) { + ZipEntry entry; + byte[] buffer = new byte[8192]; + while ((entry = zip.getNextEntry()) != null) { + if (++entries > MAX_ENTRIES) { + report.add("zip 条目数超过 " + MAX_ENTRIES); + return null; + } + String name = entry.getName().replace('\\', '/'); + if (name.contains("..") || !SAFE_ENTRY.matcher(name).matches()) { + report.add("zip 条目含非法路径:" + name); + return null; + } + if (entry.isDirectory()) { + continue; + } + String ext = name.lastIndexOf('.') >= 0 + ? name.substring(name.lastIndexOf('.')).toLowerCase() : ""; + if (!ALLOWED_EXTENSIONS.contains(ext)) { + report.add("zip 条目扩展名不允许:" + name); + return null; + } + Path out = staging.resolve(name).normalize(); + if (!out.startsWith(staging)) { + report.add("zip 条目含非法路径(越界):" + name); + return null; + } + Files.createDirectories(out.getParent()); + long written = 0; + try (java.io.OutputStream os = Files.newOutputStream(out)) { + int n; + while ((n = zip.read(buffer)) > 0) { + written += n; + total += n; + if (written > MAX_ENTRY_BYTES) { + report.add("单文件超过 10MB:" + name); + return null; + } + if (total > MAX_TOTAL_BYTES) { + report.add("皮肤包超过 20MB 上限"); + return null; + } + os.write(buffer, 0, n); + } + } + if ("theme.json".equals(name) || name.endsWith("/theme.json") && name.indexOf('/') == name.length() - 11) { + String json = new String(Files.readAllBytes(out), java.nio.charset.StandardCharsets.UTF_8); + try { + declaredName = new com.fasterxml.jackson.databind.ObjectMapper() + .readTree(json).path("name").asText(null); + } catch (Exception e) { + declaredName = null; + } + } + } + } + return declaredName; + } + + /** 包内校验(与 Registry 同一 ThemePackageValidator 语义 → 逐条报告) */ + private void validateStagedPackage(Path pkgDir, List report) { + Path json = pkgDir.resolve("theme.json"); + if (!Files.isRegularFile(json)) { + report.add("缺 theme.json"); + return; + } + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper() + .configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + false); + ThemeInfo info = mapper.readValue(json.toFile(), ThemeInfo.class); + if (info.getName() == null) { + report.add("缺 theme.json(name 为空)"); + return; + } + if (!ThemeRegistry.isValidName(info.getName())) { + report.add("name 不符合命名规范:" + info.getName()); + return; + } + if (!info.getName().equals(pkgDir.getFileName().toString())) { + report.add("name 与目录名不一致:" + pkgDir.getFileName() + " != " + info.getName()); + return; + } + for (String template : ThemeRegistry.requiredTemplates()) { + if (!Files.exists(pkgDir.resolve(template))) { + report.add("缺必备模板 " + template); + } + } + if (!Files.exists(pkgDir.resolve("fragments").resolve("head.html"))) { + report.add("缺 fragments/head.html 公共片段"); + } + try { + ThemePackageValidator.validateParams(info); + } catch (IllegalArgumentException e) { + report.add(e.getMessage()); + } + if (!Files.exists(pkgDir.resolve(info.screenshotPath()))) { + report.add("声明的 screenshot 不存在:" + info.screenshotPath()); + } + } catch (IOException e) { + report.add("theme.json 不是合法 JSON"); + } + } + + // ===== 派生(T-W1-17)===== + + public ThemeInfo derive(BoaUser user, String sourceName, String newName) { + requireSuperAdmin(user); + if (!themeRegistry.contains(sourceName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + sourceName); + } + if (!ThemeRegistry.isValidName(newName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "新皮肤包名不符合命名规范:" + newName); + } + if (themeRegistry.contains(newName)) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包已存在:" + newName); + } + Path target = Paths.get(themeDir, newName); + try { + Files.createDirectories(target); + if (ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(sourceName))) { + copyDir(Paths.get(themeDir, sourceName), target); + } else { + copyClasspathPackage(sourceName, target); + } + // 改写 theme.json:name=newName(title 追加副名,其余保留) + Path json = target.resolve("theme.json"); + String content = new String(Files.readAllBytes(json), java.nio.charset.StandardCharsets.UTF_8); + content = content.replaceAll("(\"name\"\\s*:\\s*\")" + sourceName + "(\")", + "$1" + newName + "$2"); + Files.write(json, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + themeRegistry.reload(); + publishThemeChanged(); + log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName); + return themeRegistry.get(newName); + } catch (BizException e) { + throw e; + } catch (Exception e) { + deleteRecursively(target); + throw new BizException(ErrorCode.PARAM_ERROR, "派生皮肤包失败:" + e.getMessage()); + } + } + + /** 内置包(可能在 jar 内)复制:PathMatchingResourcePatternResolver 遍历其全部资源 */ + private void copyClasspathPackage(String sourceName, Path target) throws IOException { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + org.springframework.core.io.Resource[] resources = + resolver.getResources("classpath*:templates/themes/" + sourceName + "/**"); + for (org.springframework.core.io.Resource r : resources) { + String url = r.getURL().toString().replace('\\', '/'); + int root = url.indexOf("templates/themes/" + sourceName + "/"); + if (root < 0) { + continue; + } + String rel = url.substring(root + ("templates/themes/" + sourceName + "/").length()); + if (rel.isEmpty() || url.endsWith("/")) { + continue; + } + Path out = target.resolve(rel); + Files.createDirectories(out.getParent()); + try (java.io.InputStream in = r.getInputStream()) { + Files.copy(in, out, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + // 兜底:theme.json 未被 ** 命中时显式拷贝 + if (!Files.exists(target.resolve("theme.json"))) { + try (java.io.InputStream in = new ClassPathResource( + "templates/themes/" + sourceName + "/theme.json").getInputStream()) { + Files.copy(in, target.resolve("theme.json"), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } + } + + // ===== 编辑(T-W1-17:title/参数 schema;保留 specVersion/模板清单字段)===== + + public ThemeInfo updateMeta(BoaUser user, String name, String title, List params) { + requireSuperAdmin(user); + if (!themeRegistry.contains(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name); + } + if (!ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name))) { + throw new BizException(ErrorCode.FORBIDDEN, "内置包不可编辑(D1:内置只读)"); + } + Path json = Paths.get(themeDir, name, "theme.json"); + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + com.fasterxml.jackson.databind.node.ObjectNode root = + (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile()); + if (StringUtils.hasText(title)) { + root.put("title", title); + } + if (params != null) { + ThemeInfo probe = new ThemeInfo(); + probe.setParams(params); + try { + ThemePackageValidator.validateParams(probe); + } catch (IllegalArgumentException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "参数 schema 非法:" + e.getMessage()); + } + root.set("params", mapper.valueToTree(params)); + } + Files.write(json, mapper.writerWithDefaultPrettyPrinter() + .writeValueAsBytes(root)); + themeRegistry.reload(); + publishThemeChanged(); + return themeRegistry.get(name); + } catch (BizException e) { + throw e; + } catch (IOException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "theme.json 写入失败:" + e.getMessage()); + } + } + + // ===== 删除(T-W1-15/16)===== + + public void remove(BoaUser user, String name) { + requireSuperAdmin(user); + if (!themeRegistry.contains(name)) { + throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name); + } + if (!ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name))) { + throw new BizException(ErrorCode.FORBIDDEN, "内置包不可删除(D1:内置只读)"); + } + List inUse = siteRepository.findByTheme(name); + if (!inUse.isEmpty()) { + List names = new ArrayList<>(); + for (CmsSite s : inUse) { + names.add(s.getName()); + } + throw new BizException(ErrorCode.PARAM_ERROR, + "皮肤包正被站点使用,无法删除:" + String.join("、", names)); + } + deleteRecursively(Paths.get(themeDir, name)); + themeRegistry.reload(); + publishThemeChanged(); + log.info("皮肤包 {} 已删除并热注销", name); + } + + // ===== 热重载(T-W1-02/18)===== + + public void reload(BoaUser user) { + requireSuperAdmin(user); + themeRegistry.reload(); + } + + // ===== 内部 ===== + + private void requireSuperAdmin(BoaUser user) { + if (user == null || user.getType() == null || user.getType() != 1) { + throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作"); + } + } + + /** 引用站点缓存失效(SiteService.java:159 同款事件,PortalCacheEvictListener 整缓存 clear) */ + private void publishThemeChanged() { + eventPublisher.publishEvent(new SiteContentChangedEvent(null, "theme")); + } + + private static void moveDir(Path from, Path to) throws IOException { + Files.createDirectories(to.getParent()); + try (java.util.stream.Stream walk = Files.walk(from)) { + walk.sorted(Comparator.naturalOrder()).forEach(p -> { + try { + Path dst = to.resolve(from.relativize(p).toString()); + if (Files.isDirectory(p)) { + Files.createDirectories(dst); + } else { + Files.createDirectories(dst.getParent()); + Files.move(p, dst); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } + deleteRecursively(from); + } + + private static void copyDir(Path from, Path to) throws IOException { + try (java.util.stream.Stream walk = Files.walk(from)) { + walk.forEach(p -> { + try { + Path dst = to.resolve(from.relativize(p).toString()); + if (Files.isDirectory(p)) { + Files.createDirectories(dst); + } else { + Files.createDirectories(dst.getParent()); + Files.copy(p, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new IllegalStateException(e); + } + }); + } + } + + private static void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (java.util.stream.Stream walk = Files.walk(path)) { + walk.sorted(Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (IOException ignored) { + } + }); + } catch (IOException ignored) { + } + } +} diff --git a/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java b/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java new file mode 100644 index 0000000..4cbdd9e --- /dev/null +++ b/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java @@ -0,0 +1,75 @@ +package com.yunhai.cms.theme; + +import java.util.List; +import java.util.function.Predicate; + +/** + * 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用, + * 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移: + * specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、 + * fragments/head.html、param 规范、screenshot 存在。 + * existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1)。 + */ +public final class ThemePackageValidator { + + private ThemePackageValidator() { + } + + /** 校验不通过抛 IllegalArgumentException(bad package 语义,Registry 剔除 + log.warn) */ + public static void validate(ThemeInfo info, String dirName, Predicate existsFn) { + if (info == null) { + throw new IllegalArgumentException("清单为空"); + } + if (info.getSpecVersion() == null || info.getSpecVersion() != 1) { + throw new IllegalArgumentException("specVersion 必须为 1"); + } + if (!ThemeRegistry.isValidName(info.getName())) { + throw new IllegalArgumentException("name 缺失或不符合命名规范:" + info.getName()); + } + if (!info.getName().equals(dirName)) { + throw new IllegalArgumentException("name 与目录名不一致:" + dirName + " != " + info.getName()); + } + for (String template : ThemeRegistry.requiredTemplates()) { + if (!existsFn.test(template)) { + throw new IllegalArgumentException("缺必备模板 " + template); + } + } + // fragments/ 目录存在性以 head.html 片段代替(jar 内目录项不可靠) + if (!existsFn.test("fragments/head.html")) { + throw new IllegalArgumentException("缺 fragments/head.html 公共片段"); + } + validateParams(info); + String shot = info.screenshotPath(); + if (!existsFn.test(shot)) { + throw new IllegalArgumentException("声明的 screenshot 不存在:" + shot); + } + } + + /** param 规范(key/类型/重复/cssVar 仅 color/select 必带 options);不合法抛 IllegalArgumentException */ + public static void validateParams(ThemeInfo info) { + List params = info.getParams(); + List seen = new java.util.ArrayList<>(); + for (ThemeParam p : params) { + if (p.getKey() == null || !ThemeParam.KEY_PATTERN.matcher(p.getKey()).matches()) { + throw new IllegalArgumentException("param key 非法:" + p.getKey()); + } + if (seen.contains(p.getKey())) { + throw new IllegalArgumentException("param key 重复:" + p.getKey()); + } + seen.add(p.getKey()); + String type = p.getType(); + boolean knownType = ThemeParam.TYPE_COLOR.equals(type) || ThemeParam.TYPE_IMAGE.equals(type) + || ThemeParam.TYPE_SELECT.equals(type) || ThemeParam.TYPE_TEXT.equals(type); + if (!knownType) { + throw new IllegalArgumentException("param " + p.getKey() + " 类型非法:" + type); + } + if (p.getCssVar() != null && !p.getCssVar().isEmpty() && !ThemeParam.TYPE_COLOR.equals(type)) { + throw new IllegalArgumentException("cssVar 仅允许 color 类型参数:" + p.getKey()); + } + if (ThemeParam.TYPE_SELECT.equals(type) + && (p.getOptions() == null || p.getOptions().isEmpty())) { + throw new IllegalArgumentException("select 参数缺 options:" + p.getKey()); + } + } + } +} diff --git a/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java b/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java index fb43f2e..9760d7f 100644 --- a/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java +++ b/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java @@ -3,31 +3,43 @@ package com.yunhai.cms.theme; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Predicate; import java.util.regex.Pattern; /** - * 主题注册表(方案 §5.1):启动时按 SCAN_PATTERN 扫描各主题包的 theme.json, + * 主题注册表(方案 §5.1;W1 扩展:外部主题目录 + 热注册,方案 §5.4 步骤 1): + * 启动时扫描 classpath 内置包与 {@code cms.theme.dir} 外部包(默认 ${user.dir}/data/themes), * 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。 - * 运行时不支持热加载,皮肤包随版本发布(zip 上传是 P3 项)。 + * + *

W1 语义(D1):内置只读、外部可管;内置优先于同名外部(冲突留 warn);{@link #reload()} + * 运行期热重扫(default 缺失时抛异常且不回滚旧集合,T-W1-04);校验语义收敛于 + * {@link ThemePackageValidator}(Registry 与 PackageService 共用,杜绝两套校验漂移)。 */ @Slf4j @Component public class ThemeRegistry { public static final String DEFAULT_THEME = "default"; + public static final String SOURCE_BUILTIN = "builtin"; + public static final String SOURCE_EXTERNAL = "external"; /** 包名与目录名约束(方案 §4.1) */ private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,30}[a-z0-9]$"); @@ -43,30 +55,117 @@ public class ThemeRegistry { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private final Map themes = new LinkedHashMap<>(); + private final Map sources = new LinkedHashMap<>(); + private final List reloadListeners = new ArrayList<>(); + + /** 外部主题目录(W1;测试经 setter 指向临时目录) */ + private String themeDir; + + @Value("${cms.theme.dir:}") + public void applyConfiguredThemeDir(String dir) { + if (dir != null && !dir.trim().isEmpty()) { + this.themeDir = dir.trim(); + } + } + + public ThemeRegistry() { + if (themeDir == null || themeDir.isEmpty()) { + themeDir = System.getProperty("user.dir") + "/data/themes"; + } + } + + public void setThemeDir(String dir) { + this.themeDir = dir; + } + + public String getThemeDir() { + return themeDir; + } + + /** reload 后清外部模板缓存(ThemeTemplateResolverConfig 注册回调) */ + public void addReloadListener(Runnable listener) { + reloadListeners.add(listener); + } @PostConstruct public void load() { - themes.clear(); + reloadInternal(true); + } + + /** + * W1 热重扫(T-W1-02/04):重扫 classpath + 外部目录; + * default 缺失或非法时抛 IllegalStateException 且旧集合保持不变。 + */ + public synchronized void reload() { + reloadInternal(false); + } + + private void reloadInternal(boolean startup) { Map loaded = new LinkedHashMap<>(); + Map loadedSources = new LinkedHashMap<>(); int rejected = 0; - for (Resource resource : scanResources()) { + + // ① 内置 classpath 包(先注册,优先级高) + for (Resource resource : scanClasspathResources()) { String dirName = dirNameOf(resource); try { ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class); - validate(info, dirName); + ThemePackageValidator.validate(info, dirName, rel -> + new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists()); loaded.put(info.getName(), info); + loadedSources.put(info.getName(), SOURCE_BUILTIN); } catch (Exception e) { rejected++; log.warn("主题包 {} 注册失败被剔除:{}", dirName, e.getMessage()); } } + + // ② 外部目录包(同名让位内置,warn 留痕;T-W1-03) + for (Path pkg : scanExternalPackages()) { + String dirName = pkg.getFileName().toString(); + Path json = pkg.resolve("theme.json"); + try { + ThemeInfo info = MAPPER.readValue(json.toFile(), ThemeInfo.class); + ThemePackageValidator.validate(info, dirName, rel -> Files.exists(pkg.resolve(rel))); + if (loaded.containsKey(info.getName())) { + log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName); + continue; + } + loaded.put(info.getName(), info); + loadedSources.put(info.getName(), SOURCE_EXTERNAL); + } catch (Exception e) { + rejected++; + log.warn("外部主题包 {} 注册失败被剔除:{}", dirName, e.getMessage()); + } + } + List sorted = new ArrayList<>(loaded.values()); sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName()) ? "" : info.getName())); + Map newThemes = new LinkedHashMap<>(); + Map newSources = new LinkedHashMap<>(); for (ThemeInfo info : sorted) { - themes.put(info.getName(), info); + newThemes.put(info.getName(), info); + newSources.put(info.getName(), loadedSources.get(info.getName())); + } + + if (!newThemes.containsKey(DEFAULT_THEME)) { + if (startup) { + throw new IllegalStateException("默认主题包 default 缺失或非法,拒绝启动(方案 §5.1)"); + } + throw new IllegalStateException("默认主题包 default 缺失或非法,reload 已中止(旧集合保持不变,T-W1-04)"); + } + themes.clear(); + themes.putAll(newThemes); + sources.clear(); + sources.putAll(newSources); + for (Runnable listener : reloadListeners) { + try { + listener.run(); + } catch (Exception e) { + log.warn("主题注册表 reload 回调失败:{}", e.getMessage()); + } } - verifyDefaultPresent(); log.info("主题注册表加载完成:可用 {} {},剔除 {} 个坏包", themes.size(), themes.keySet(), rejected); } @@ -90,66 +189,38 @@ public class ThemeRegistry { return name == null ? null : themes.get(name); } - // ===== 包规范校验(方案 §4.1)===== - - private void validate(ThemeInfo info, String dirName) { - if (info == null) { - throw new IllegalArgumentException("清单为空"); - } - if (info.getSpecVersion() == null || info.getSpecVersion() != 1) { - throw new IllegalArgumentException("specVersion 必须为 1"); - } - if (info.getName() == null || !NAME_PATTERN.matcher(info.getName()).matches()) { - throw new IllegalArgumentException("name 缺失或不符合命名规范:" + info.getName()); - } - if (!info.getName().equals(dirName)) { - throw new IllegalArgumentException("name 与目录名不一致:" + dirName + " != " + info.getName()); - } - for (String template : REQUIRED_TEMPLATES) { - if (!new ClassPathResource("templates/themes/" + dirName + "/" + template).exists()) { - throw new IllegalArgumentException("缺必备模板 " + template); - } - } - // fragments/ 目录存在性以 head.html 片段代替(jar 内目录项不可靠) - if (!new ClassPathResource("templates/themes/" + dirName + "/fragments/head.html").exists()) { - throw new IllegalArgumentException("缺 fragments/head.html 公共片段"); - } - validateParams(info); - String shot = "templates/themes/" + dirName + "/" + info.screenshotPath(); - if (!new ClassPathResource(shot).exists()) { - throw new IllegalArgumentException("声明的 screenshot 不存在:" + info.screenshotPath()); - } + /** W1:包来源(builtin/external);未注册返回 null */ + public String sourceOf(String name) { + return name == null ? null : sources.get(name); } - private void validateParams(ThemeInfo info) { - List seen = new ArrayList<>(); - for (ThemeParam p : info.getParams()) { - if (p.getKey() == null || !ThemeParam.KEY_PATTERN.matcher(p.getKey()).matches()) { - throw new IllegalArgumentException("param key 非法:" + p.getKey()); - } - if (seen.contains(p.getKey())) { - throw new IllegalArgumentException("param key 重复:" + p.getKey()); - } - seen.add(p.getKey()); - String type = p.getType(); - boolean knownType = ThemeParam.TYPE_COLOR.equals(type) || ThemeParam.TYPE_IMAGE.equals(type) - || ThemeParam.TYPE_SELECT.equals(type) || ThemeParam.TYPE_TEXT.equals(type); - if (!knownType) { - throw new IllegalArgumentException("param " + p.getKey() + " 类型非法:" + type); - } - if (p.getCssVar() != null && !p.getCssVar().isEmpty() && !ThemeParam.TYPE_COLOR.equals(type)) { - throw new IllegalArgumentException("cssVar 仅允许 color 类型参数:" + p.getKey()); - } - if (ThemeParam.TYPE_SELECT.equals(type) - && (p.getOptions() == null || p.getOptions().isEmpty())) { - throw new IllegalArgumentException("select 参数缺 options:" + p.getKey()); - } + /** W1:包内相对资源定位(内置→ClassPathResource;外部→FileSystemResource,exists 即时判定) */ + public Resource resource(String name, String relativePath) { + String rel = relativePath == null ? "" : relativePath; + while (rel.startsWith("/")) { + rel = rel.substring(1); } + if (SOURCE_EXTERNAL.equals(sources.get(name))) { + return new FileSystemResource(Paths.get(themeDir, name, rel).toFile()); + } + return new ClassPathResource("templates/themes/" + name + "/" + rel); + } + + // ===== 包规范校验(§4.1,语义收敛于 ThemePackageValidator)===== + + /** 包名规范(PackageService 上传/派生共用) */ + public static boolean isValidName(String name) { + return name != null && NAME_PATTERN.matcher(name).matches(); + } + + public static List requiredTemplates() { + return REQUIRED_TEMPLATES; } // ===== 资源定位 ===== - private List scanResources() { + /** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */ + protected List scanClasspathResources() { try { Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN); return Arrays.asList(resources); @@ -158,6 +229,27 @@ public class ThemeRegistry { } } + /** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */ + protected List scanExternalPackages() { + List packages = new ArrayList<>(); + if (themeDir == null || themeDir.isEmpty()) { + return packages; + } + Path root = Paths.get(themeDir); + if (!Files.isDirectory(root)) { + return packages; + } + try (java.util.stream.Stream children = Files.list(root)) { + children.filter(Files::isDirectory) + .filter(p -> Files.isRegularFile(p.resolve("theme.json"))) + .sorted(Comparator.comparing(p -> p.getFileName().toString())) + .forEach(packages::add); + } catch (IOException e) { + log.warn("外部主题目录扫描失败:{}({})", themeDir, e.getMessage()); + } + return packages; + } + /** 从 theme.json 资源 URL 截取所属包目录名 */ private String dirNameOf(Resource resource) { try { diff --git a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java index 289923a..6de37a9 100644 --- a/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java +++ b/src/test/java/com/yunhai/cms/portal/ThemeRenderIT.java @@ -2,6 +2,7 @@ package com.yunhai.cms.portal; import com.yunhai.cms.entity.CmsSite; import com.yunhai.cms.support.CmsIntegrationTestBase; +import com.yunhai.cms.support.TestUsers; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -9,6 +10,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.nio.charset.StandardCharsets; @@ -25,25 +27,47 @@ class ThemeRenderIT extends CmsIntegrationTestBase { @Autowired private MockMvc mockMvc; + @Autowired + private com.yunhai.cms.service.impl.ThemeService themeService; + @Autowired + private com.yunhai.cms.theme.ThemeRegistry themeRegistry; + @Autowired + private com.yunhai.cms.theme.ThemePackageService themePackageService; + + private java.nio.file.Path themeDir; + private org.springframework.test.web.servlet.MockMvc assetStandalone; @BeforeEach - void seed() { + void seed() throws Exception { purgeTables(); + // W1:外部主题目录指向临时目录 + 资产端点 standalone(/api/* 走全链路被 CAS 302,无法匿名断 200) + themeDir = java.nio.file.Files.createTempDirectory("w1-render-themes"); + themeRegistry.setThemeDir(themeDir.toString()); + themeRegistry.reload(); + themePackageService.setThemeDir(themeDir.toString()); + assetStandalone = MockMvcBuilders.standaloneSetup( + new com.yunhai.cms.controller.api.ThemeController(themeService, themeRegistry, themePackageService)) + .build(); } @AfterEach void clean() { purgeTables(); + com.yunhai.cms.support.ThemePackageTestSupport.deleteRecursively(themeDir); } private void persistSiteWithConfig(String code, String configJson) { + persistSiteWithTheme(code, "default", configJson); + } + + private void persistSiteWithTheme(String code, String theme, String configJson) { CmsSite site = new CmsSite(); site.setCode(code); site.setPath(code); // W4:顶层 path=code(与 persistSite 同口径) site.setSchId(1); site.setSchName("学校-1"); site.setName("注入测试门户"); - site.setTheme("default"); + site.setTheme(theme); site.setConfigJson(configJson); site.setStatus(1); siteRepository.saveAndFlush(site); @@ -56,6 +80,43 @@ class ThemeRenderIT extends CmsIntegrationTestBase { return result.getResponse().getContentAsString(StandardCharsets.UTF_8); } + // ===== W1 外部包门户 SSR(方案 §5.3 T-W1-19/20)===== + + @Autowired + private java.util.List resolverChain; + + @Test + @DisplayName("T-W1-19: 外部包门户首页 SSR 200 且含外部包特征 markup") + void externalPackagePortalSsr() throws Exception { + System.out.println("RESOLVER-CHAIN=" + resolverChain); + System.out.println("SOURCE-OF=" + themeRegistry.sourceOf("t-ext-ssr") + " DIR=" + themeRegistry.getThemeDir()); + themePackageService.upload(TestUsers.superAdmin(), + com.yunhai.cms.support.ThemePackageTestSupport.buildZip( + "t-ext-ssr", "t-ext-ssr", true, true, true, true, true, "EXT-SSR-OK")); + persistSiteWithTheme("extportal", "t-ext-ssr", "{}"); + + MvcResult result = mockMvc.perform(get("/portal/extportal/")).andExpect(status().isOk()).andReturn(); + assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .as("外部包模板经 Thymeleaf 文件解析器渲染(G7)") + .contains("EXT-SSR-OK"); + } + + @Test + @DisplayName("T-W1-20: 外部包资产代理——门户资产与 /api 代理均 200") + void externalPackageAssetsServed() throws Exception { + themePackageService.upload(TestUsers.superAdmin(), + com.yunhai.cms.support.ThemePackageTestSupport.buildZip( + "t-ext-asset", "t-ext-asset", true, true, true, true, true, null)); + persistSiteWithTheme("extassets", "t-ext-asset", "{}"); + + // 门户资产路由(匿名) + mockMvc.perform(get("/portal/extassets/assets/t-ext-asset/css/main.css")) + .andExpect(status().isOk()); + // /api 资产代理走 standalone(CAS 只拦 /api/*,全链路匿名是 302 而非 200) + assetStandalone.perform(get("/api/theme/t-ext-asset/asset/css/main.css")) + .andExpect(status().isOk()); + } + @Test @DisplayName("T13: 微调参数注入 CSS 变量且位于皮肤 CSS link 之后") void themeParamsInjectedAfterSkinCss() throws Exception { diff --git a/src/test/java/com/yunhai/cms/support/ThemePackageTestSupport.java b/src/test/java/com/yunhai/cms/support/ThemePackageTestSupport.java new file mode 100644 index 0000000..a1733aa --- /dev/null +++ b/src/test/java/com/yunhai/cms/support/ThemePackageTestSupport.java @@ -0,0 +1,100 @@ +package com.yunhai.cms.support; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.function.UnaryOperator; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * W1 皮肤包测试支撑:把 classpath 夹具包(t-demo-red)现场打成 zip(可注入标记), + * 供 IT 上传/派生/SSR 用例构造合规包,不污染 classpath 夹具。 + */ +public final class ThemePackageTestSupport { + + private ThemePackageTestSupport() { + } + + /** rootDir 为 zip 内根目录名(须与 theme.json 的 name 一致,T-W1-09 语义);homeMarker 非空时追加进 home.html */ + /** rootDir 为 zip 内根目录名(须与 theme.json 的 name 一致,T-W1-09 语义);homeMarker 非空时追加进 home.html。 + * 整包递归拷贝 classpath 夹具 t-demo-red(含 fragments/header 等 SSR 依赖),改名后打包。 */ + public static MockMultipartFile buildZip(String pkgName, String rootDir, boolean withThemeJson, + boolean withList, boolean withScreenshot, boolean withHead, + boolean withCss, String homeMarker) throws Exception { + Path staging = Files.createTempDirectory("w1-zip"); + Path pkg = staging.resolve(rootDir); + // 递归拷贝夹具包全部文件(磁盘形态:test/resources 在 surefire classpath 上可按文件访问) + Path fixture = new ClassPathResource("templates/themes/t-demo-red/theme.json").getFile() + .toPath().getParent(); + try (java.util.stream.Stream walk = Files.walk(fixture)) { + walk.filter(Files::isRegularFile).forEach(src -> { + try { + Path dst = pkg.resolve(fixture.relativize(src).toString()); + Files.createDirectories(dst.getParent()); + Files.copy(src, dst, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + } + if (!withThemeJson) { + Files.delete(pkg.resolve("theme.json")); + } else { + String json = new String(Files.readAllBytes(pkg.resolve("theme.json")), StandardCharsets.UTF_8); + json = json.replace("t-demo-red", pkgName); + Files.write(pkg.resolve("theme.json"), json.getBytes(StandardCharsets.UTF_8)); + } + if (!withList) { + Files.deleteIfExists(pkg.resolve("list.html")); + } + if (!withCss) { + Files.deleteIfExists(pkg.resolve("assets").resolve("css").resolve("main.css")); + } + if (!withScreenshot) { + Files.deleteIfExists(pkg.resolve("assets").resolve("screenshot.png")); + } + if (!withHead) { + Files.deleteIfExists(pkg.resolve("fragments").resolve("head.html")); + } + if (homeMarker != null) { + Path home = pkg.resolve("home.html"); + String html = new String(Files.readAllBytes(home), StandardCharsets.UTF_8); + html = html.replace("", "

" + homeMarker + "

"); + Files.write(home, html.getBytes(StandardCharsets.UTF_8)); + } + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bos)) { + Files.walk(staging).filter(Files::isRegularFile).forEach(p -> { + try { + zip.putNextEntry(new ZipEntry(staging.relativize(p).toString().replace('\\', '/'))); + zip.write(Files.readAllBytes(p)); + zip.closeEntry(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + } + deleteRecursively(staging); + return new MockMultipartFile("file", pkgName + ".zip", "application/zip", bos.toByteArray()); + } + + public static void deleteRecursively(Path path) { + if (path == null || !Files.exists(path)) { + return; + } + try (java.util.stream.Stream walk = Files.walk(path)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (java.io.IOException ignored) { + } + }); + } catch (java.io.IOException ignored) { + } + } +} diff --git a/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java b/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java index 984c871..af659b3 100644 --- a/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java +++ b/src/test/java/com/yunhai/cms/theme/ThemeApiIT.java @@ -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 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()); + } } diff --git a/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java b/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java new file mode 100644 index 0000000..e8aa45a --- /dev/null +++ b/src/test/java/com/yunhai/cms/theme/ThemePackageServiceTest.java @@ -0,0 +1,213 @@ +package com.yunhai.cms.theme; + +import com.yunhai.cms.support.TestUsers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.mock.web.MockMultipartFile; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 皮肤包上传/派生/编辑/删除 纯单元测试(方案 §5.3 T-W1-05..12):临时目录 + 直接 new,不启 Spring。 + * 合规 zip 由 classpath 夹具 t-demo-red 现场构造(改名为独立包名,不污染全量 IT 的 classpath 夹具)。 + */ +@DisplayName("ThemePackageService - 皮肤包管理(方案 §5.2/§5.4)") +class ThemePackageServiceTest { + + private ThemeRegistry registry; + private ThemePackageService service; + private Path themeDir; + private final List cleanups = new ArrayList<>(); + + @BeforeEach + void setUp() throws Exception { + themeDir = Files.createTempDirectory("w1-pkg"); + cleanups.add(themeDir); + registry = new ThemeRegistry(); + registry.setThemeDir(themeDir.toString()); // 上传/派生落位与 reload 扫描必须是同一目录 + registry.load(); + service = new ThemePackageService(registry, null, event -> { + }); + service.setThemeDir(themeDir.toString()); + } + + @AfterEach + void clean() { + for (Path dir : cleanups) { + deleteRecursively(dir); + } + } + + /** 把 classpath 夹具包打成 zip(name 改为指定值;根目录=包目录名) */ + private MockMultipartFile zipOf(String pkgName, String rootDir, boolean withThemeJson, + boolean withListHtml, boolean withScreenshot, boolean withHead) throws Exception { + Path staging = Files.createTempDirectory("w1-zip"); + cleanups.add(staging); + Path pkg = staging.resolve(rootDir); + Files.createDirectories(pkg.resolve("assets")); + Files.createDirectories(pkg.resolve("fragments")); + if (withThemeJson) { + String json = org.springframework.util.FileCopyUtils.copyToString( + new java.io.InputStreamReader(new ClassPathResource("templates/themes/t-demo-red/theme.json") + .getInputStream(), StandardCharsets.UTF_8)); + json = json.replace("t-demo-red", pkgName); + Files.write(pkg.resolve("theme.json"), json.getBytes(StandardCharsets.UTF_8)); + } + if (withListHtml) { + Files.copy(new ClassPathResource("templates/themes/t-demo-red/list.html").getInputStream(), + pkg.resolve("list.html")); + } + for (String t : new String[] {"home.html", "detail.html", "single.html"}) { + Files.copy(new ClassPathResource("templates/themes/t-demo-red/" + t).getInputStream(), + pkg.resolve(t)); + } + if (withScreenshot) { + Files.copy(new ClassPathResource("templates/themes/t-demo-red/assets/screenshot.png").getInputStream(), + pkg.resolve("assets").resolve("screenshot.png")); + } + if (withHead) { + Files.copy(new ClassPathResource("templates/themes/t-demo-red/fragments/head.html").getInputStream(), + pkg.resolve("fragments").resolve("head.html")); + } + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bos)) { + Files.walk(staging).filter(Files::isRegularFile).forEach(p -> { + try { + zip.putNextEntry(new ZipEntry(staging.relativize(p).toString().replace('\\', '/'))); + zip.write(Files.readAllBytes(p)); + zip.closeEntry(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + }); + } + return new MockMultipartFile("file", pkgName + ".zip", "application/zip", bos.toByteArray()); + } + + @Test + @DisplayName("T-W1-05: 合规 zip 上传成功——落盘结构完整、返回 ThemeInfo、registry 含之") + void uploadValidZipSucceeds() throws Exception { + ThemeInfo info = service.upload(TestUsers.superAdmin(), zipOf("t-zip-ok", "t-zip-ok", true, true, true, true)); + assertThat(info.getName()).isEqualTo("t-zip-ok"); + assertThat(registry.contains("t-zip-ok")).isTrue(); + assertThat(registry.sourceOf("t-zip-ok")).isEqualTo("external"); + assertThat(Files.exists(themeDir.resolve("t-zip-ok").resolve("theme.json"))).isTrue(); + assertThat(Files.exists(themeDir.resolve("t-zip-ok").resolve("assets").resolve("screenshot.png"))).isTrue(); + assertThat(Files.exists(themeDir.resolve("t-zip-ok").resolve("fragments").resolve("head.html"))).isTrue(); + } + + @Test + @DisplayName("T-W1-06: zip-slip 条目(../evil.html)拒绝且目标目录无残留") + void zipSlipEntryRejected() throws Exception { + MockMultipartFile file = zipOf("t-slip", "t-slip", true, true, true, true); + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(bos)) { + zip.putNextEntry(new ZipEntry("../evil.html")); + zip.write("

evil

".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + MockMultipartFile evil = new MockMultipartFile("file", "evil.zip", "application/zip", + bos.toByteArray()); + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), evil)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("非法路径"); + // 合法包同批校验防误伤 + service.upload(TestUsers.superAdmin(), file); + assertThat(Files.exists(themeDir.resolve("t-slip"))).isTrue(); + assertThat(Files.exists(themeDir.getParent().resolve("evil.html"))).isFalse(); + } + + @Test + @DisplayName("T-W1-07: 超大包(>20MB)拒绝") + void oversizePackageRejected() throws Exception { + byte[] big = new byte[21 * 1024 * 1024]; + java.util.Arrays.fill(big, (byte) 'x'); + MockMultipartFile file = new MockMultipartFile("file", "big.zip", "application/zip", big); + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), file)) + .hasMessageContaining("20MB"); + } + + @Test + @DisplayName("T-W1-08: 缺 theme.json 拒绝(报告含'缺 theme.json')") + void missingThemeJsonRejected() throws Exception { + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), + zipOf("t-nojson", "t-nojson", false, true, true, true))) + .hasMessageContaining("缺 theme.json"); + } + + @Test + @DisplayName("T-W1-09: name 与目录不一致拒绝(报告含对应条目)") + void nameDirMismatchRejected() throws Exception { + // zip 根目录 t-other 与 theme.json name=t-nojson 不一致 + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), + zipOf("t-nojson", "t-other", true, true, true, true))) + .hasMessageContaining("name 与目录名不一致"); + } + + @Test + @DisplayName("T-W1-10: 缺必备模板/片段/截图拒绝(报告逐条列出)") + void missingTemplatesRejected() throws Exception { + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), + zipOf("t-nolist", "t-nolist", true, false, false, false))) + .hasMessageContaining("list.html") + .hasMessageContaining("fragments/head.html") + .hasMessageContaining("screenshot"); + } + + @Test + @DisplayName("T-W1-11: 与内置同名拒绝(name=default),内置包不受影响") + void builtinNameRejected() throws Exception { + assertThatThrownBy(() -> service.upload(TestUsers.superAdmin(), + zipOf("default", "default", true, true, true, true))) + .hasMessageContaining("内置"); + assertThat(registry.sourceOf("default")).isEqualTo("builtin"); + assertThat(registry.get("default").getTitle()).isNotBlank(); + } + + @Test + @DisplayName("T-W1-12: 外部同名=覆盖更新(目录替换、registry schema 更新)") + void externalSameNameOverwrites() throws Exception { + service.upload(TestUsers.superAdmin(), zipOf("t-zip-ov", "t-zip-ov", true, true, true, true)); + ThemeInfo first = registry.get("t-zip-ov"); + String oldTitle = first.getTitle(); + + MockMultipartFile v2 = zipOf("t-zip-ov", "t-zip-ov", true, true, true, true); + // 修改 title 便于断言覆盖生效 + service.updateMeta(TestUsers.superAdmin(), "t-zip-ov", oldTitle + "-v2", null); + ThemeInfo updated = registry.get("t-zip-ov"); + assertThat(updated.getTitle()).isEqualTo(oldTitle + "-v2"); + + // 重新上传同包 → 目录被替换、注册仍有效(schema 更新语义) + service.upload(TestUsers.superAdmin(), v2); + assertThat(registry.contains("t-zip-ov")).isTrue(); + assertThat(Files.exists(themeDir.resolve("t-zip-ov").resolve("theme.json"))).isTrue(); + } + + /** JDK8 递归删除(测试临时目录专用) */ + private static void deleteRecursively(Path path) { + if (!Files.exists(path)) { + return; + } + try (java.util.stream.Stream walk = Files.walk(path)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + Files.delete(p); + } catch (java.io.IOException ignored) { + } + }); + } catch (java.io.IOException ignored) { + } + } +} diff --git a/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java b/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java index ef13992..0c97266 100644 --- a/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java +++ b/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java @@ -152,4 +152,120 @@ class ThemeRegistryTest { .as("旧 /upload/** 值应被过滤丢弃,不落库") .doesNotContainKey("heroImage"); } + + // ===== W1 外部主题目录(方案 §5.3 T-W1-01..04)===== + + /** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */ + private java.nio.file.Path writeExternalPackage(java.nio.file.Path dir, String name, + java.util.function.UnaryOperator themeJsonMutator) throws Exception { + java.nio.file.Path pkg = dir.resolve(name); + java.nio.file.Files.createDirectories(pkg.resolve("assets")); + java.nio.file.Files.createDirectories(pkg.resolve("fragments")); + org.springframework.util.FileCopyUtils.copy( + new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/assets/screenshot.png").getInputStream(), + java.nio.file.Files.newOutputStream(pkg.resolve("assets").resolve("screenshot.png"))); + for (String t : new String[] {"home.html", "list.html", "detail.html", "single.html"}) { + org.springframework.util.FileCopyUtils.copy( + new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/" + t).getInputStream(), + java.nio.file.Files.newOutputStream(pkg.resolve(t))); + } + org.springframework.util.FileCopyUtils.copy( + new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/fragments/head.html").getInputStream(), + java.nio.file.Files.newOutputStream(pkg.resolve("fragments").resolve("head.html"))); + String json = org.springframework.util.FileCopyUtils.copyToString( + new java.io.InputStreamReader(new org.springframework.core.io.ClassPathResource( + "templates/themes/t-demo-red/theme.json").getInputStream(), + java.nio.charset.StandardCharsets.UTF_8)); + json = json.replace("\"name\": \"t-demo-red\"", "\"name\": \"" + name + "\"") + .replace("\"name\":\"t-demo-red\"", "\"name\":\"" + name + "\""); + if (themeJsonMutator != null) { + json = themeJsonMutator.apply(json); + } + java.nio.file.Files.write(pkg.resolve("theme.json"), json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return pkg; + } + + @Test + @DisplayName("T-W1-01: 外部目录包被扫描注册(source=external)") + void externalDirPackageScanned() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); + writeExternalPackage(dir, "t-ext-ok", null); + ThemeRegistry r = new ThemeRegistry(); + r.setThemeDir(dir.toString()); + r.load(); + assertThat(r.contains("t-ext-ok")).isTrue(); + assertThat(r.sourceOf("t-ext-ok")).isEqualTo("external"); + assertThat(r.sourceOf("default")).isEqualTo("builtin"); + deleteRecursively(dir); + } + + @Test + @DisplayName("T-W1-02: 运行期放入新包 → reload() 热生效,无需重启") + void reloadPicksUpNewPackage() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); + ThemeRegistry r = new ThemeRegistry(); + r.setThemeDir(dir.toString()); + r.load(); + assertThat(r.contains("t-ext-late")).isFalse(); + writeExternalPackage(dir, "t-ext-late", null); + r.reload(); + assertThat(r.contains("t-ext-late")).isTrue(); + deleteRecursively(dir); + } + + @Test + @DisplayName("T-W1-03: 内置优先于同名外部(外部放 name=default 被拒且留 warn)") + void builtinOverridesExternalSameName() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); + writeExternalPackage(dir, "default", null); // 试图用外部 default 覆盖 + ThemeRegistry r = new ThemeRegistry(); + r.setThemeDir(dir.toString()); + r.load(); + assertThat(r.contains("default")).isTrue(); + assertThat(r.sourceOf("default")).as("注册结果仍为内置 default").isEqualTo("builtin"); + deleteRecursively(dir); + } + + @Test + @DisplayName("T-W1-04: reload 缺 default 保护——外部-only 场景破坏 default 后 reload 抛异常且旧集合保持") + void reloadWithoutDefaultKeepsOldSet() throws Exception { + java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); + writeExternalPackage(dir, "t-ext-a", null); + writeExternalPackage(dir, "default", null); + // 外部-only 构造:屏蔽 classpath 扫描 + ThemeRegistry r = new ThemeRegistry() { + @Override + protected java.util.List scanClasspathResources() { + return java.util.Collections.emptyList(); + } + }; + r.setThemeDir(dir.toString()); + r.load(); + int before = r.list().size(); + assertThat(r.contains("default")).isTrue(); + + deleteRecursively(dir.resolve("default")); + org.assertj.core.api.Assertions.assertThatThrownBy(r::reload) + .as("default 缺失时 reload 抛业务异常") + .isInstanceOf(IllegalStateException.class); + assertThat(r.list().size()).as("reload 失败不回滚旧集合").isEqualTo(before); + assertThat(r.contains("t-ext-a")).isTrue(); + deleteRecursively(dir); + } + + /** JDK8 递归删除(测试临时目录专用) */ + private static void deleteRecursively(java.nio.file.Path path) { + if (!java.nio.file.Files.exists(path)) { + return; + } + try (java.util.stream.Stream walk = java.nio.file.Files.walk(path)) { + walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { + try { + java.nio.file.Files.delete(p); + } catch (java.io.IOException ignored) { + } + }); + } catch (java.io.IOException ignored) { + } + } }