feat(W1): 皮肤包完整管理-外部主题目录+热注册,zip上传/派生/编辑/删除,ThemePackageValidator收敛,Thymeleaf文件解析器,list增source/inUseCount/editable/usedBySiteNames,T-W1-01..20 全绿299/299
build / build-push (push) Successful in 52s
build / build-push (push) Successful in 52s
This commit is contained in:
@@ -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 解析器处理)。
|
||||||
|
*
|
||||||
|
* <p>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)。
|
||||||
|
*
|
||||||
|
* <p>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<org.thymeleaf.TemplateEngine> engineProvider) {
|
||||||
|
FileTemplateResolver resolver = new FileTemplateResolver() {
|
||||||
|
@Override
|
||||||
|
protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
|
||||||
|
String ownerTemplate, String template,
|
||||||
|
String resourceName, String characterEncoding,
|
||||||
|
java.util.Map<String, Object> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.yunhai.cms.controller.api;
|
package com.yunhai.cms.controller.api;
|
||||||
|
|
||||||
import com.yunhai.cms.service.impl.ThemeService;
|
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 com.yunhai.cms.theme.ThemeRegistry;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.CacheControl;
|
import org.springframework.http.CacheControl;
|
||||||
import org.springframework.http.MediaType;
|
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.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
@@ -33,12 +41,53 @@ public class ThemeController {
|
|||||||
|
|
||||||
private final ThemeService themeService;
|
private final ThemeService themeService;
|
||||||
private final ThemeRegistry themeRegistry;
|
private final ThemeRegistry themeRegistry;
|
||||||
|
private final ThemePackageService themePackageService;
|
||||||
|
|
||||||
@GetMapping("/list")
|
@GetMapping("/list")
|
||||||
public List<com.yunhai.cms.model.vo.ThemeVo> list() {
|
public List<com.yunhai.cms.model.vo.ThemeVo> list() {
|
||||||
return themeService.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<String, Object> remove(BoaUser authUserWithDept, @PathVariable("name") String name) {
|
||||||
|
themePackageService.remove(authUserWithDept, name);
|
||||||
|
java.util.Map<String, Object> result = new java.util.HashMap<>();
|
||||||
|
result.put("ok", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** W1(G2/B4):热重载(运维直放目录后生效;default 缺失报错且旧集合不丢) */
|
||||||
|
@PostMapping("/reload")
|
||||||
|
public List<com.yunhai.cms.model.vo.ThemeVo> reload(BoaUser authUserWithDept) {
|
||||||
|
themePackageService.reload(authUserWithDept);
|
||||||
|
return themeService.list();
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{name}/asset/**")
|
@GetMapping("/{name}/asset/**")
|
||||||
public ResponseEntity<Resource> asset(@PathVariable("name") String name,
|
public ResponseEntity<Resource> asset(@PathVariable("name") String name,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
@@ -49,7 +98,7 @@ public class ThemeController {
|
|||||||
if (path == null || path.isEmpty() || path.contains("..")) {
|
if (path == null || path.isEmpty() || path.contains("..")) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
Resource resource = new ClassPathResource("templates/themes/" + name + "/assets/" + path);
|
Resource resource = themeRegistry.resource(name, "assets/" + path);
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest;
|
import javax.servlet.http.HttpServletRequest;
|
||||||
@@ -65,8 +64,8 @@ public class PortalAssetController {
|
|||||||
if (path == null || path.isEmpty() || path.contains("..")) {
|
if (path == null || path.isEmpty() || path.contains("..")) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
Resource resource = new ClassPathResource(
|
// W1(T-W1-20):内置→ClassPathResource、外部→FileSystemResource,统一走 Registry 定位
|
||||||
"templates/themes/" + theme + "/assets/" + path);
|
Resource resource = themeRegistry.resource(theme, "assets/" + path);
|
||||||
if (!resource.exists()) {
|
if (!resource.exists()) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<ThemeParam> params;
|
||||||
|
}
|
||||||
@@ -30,6 +30,18 @@ public class ThemeVo {
|
|||||||
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
|
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
|
||||||
private List<ThemeParamVo> params;
|
private List<ThemeParamVo> params;
|
||||||
|
|
||||||
|
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
/** 被用站点数(W1,G1:删除保护口径一致) */
|
||||||
|
private long inUseCount;
|
||||||
|
|
||||||
|
/** 是否可编辑/删除(W1:仅外部包 true) */
|
||||||
|
private boolean editable;
|
||||||
|
|
||||||
|
/** 引用站点名列表(W1:删除保护提示/前端 tooltip,与 inUseCount 同源) */
|
||||||
|
private List<String> usedBySiteNames;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class ThemeParamVo {
|
public static class ThemeParamVo {
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
|
|||||||
/** 皮肤包被用站点数(W1 删除保护共用) */
|
/** 皮肤包被用站点数(W1 删除保护共用) */
|
||||||
long countByTheme(String theme);
|
long countByTheme(String theme);
|
||||||
|
|
||||||
|
/** 使用指定皮肤包的站点(W1 删除保护:拒绝消息提示站点名) */
|
||||||
|
List<CmsSite> findByTheme(String theme);
|
||||||
|
|
||||||
/** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */
|
/** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */
|
||||||
List<CmsSite> findByStatusOrderByIdAsc(Integer status);
|
List<CmsSite> findByStatusOrderByIdAsc(Integer status);
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,35 @@ import java.util.List;
|
|||||||
public class ThemeService {
|
public class ThemeService {
|
||||||
|
|
||||||
private final ThemeRegistry themeRegistry;
|
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<ThemeVo> list() {
|
public List<ThemeVo> list() {
|
||||||
List<ThemeVo> result = new ArrayList<>();
|
List<ThemeVo> result = new ArrayList<>();
|
||||||
for (ThemeInfo info : themeRegistry.list()) {
|
for (ThemeInfo info : themeRegistry.list()) {
|
||||||
result.add(ThemeVo.of(info));
|
result.add(voOf(info));
|
||||||
}
|
}
|
||||||
return result;
|
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<com.yunhai.cms.entity.CmsSite> usedBy = siteRepository.findByTheme(info.getName());
|
||||||
|
vo.setInUseCount(usedBy.size());
|
||||||
|
java.util.List<String> 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(白名单判断用) */
|
/** 未注册主题返回 null(白名单判断用) */
|
||||||
public ThemeInfo get(String name) {
|
public ThemeInfo get(String name) {
|
||||||
return themeRegistry.get(name);
|
return themeRegistry.get(name);
|
||||||
|
|||||||
@@ -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<String> 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<String> 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<Path> entries = new ArrayList<>();
|
||||||
|
try (java.util.stream.Stream<Path> 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<String> 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<String> 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<ThemeParam> 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<CmsSite> inUse = siteRepository.findByTheme(name);
|
||||||
|
if (!inUse.isEmpty()) {
|
||||||
|
List<String> 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<Path> 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<Path> 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<Path> walk = Files.walk(path)) {
|
||||||
|
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
|
||||||
|
try {
|
||||||
|
Files.delete(p);
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> 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<ThemeParam> params = info.getParams();
|
||||||
|
List<String> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,31 +3,43 @@ package com.yunhai.cms.theme;
|
|||||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import javax.annotation.PostConstruct;
|
import javax.annotation.PostConstruct;
|
||||||
import java.io.IOException;
|
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.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.function.Predicate;
|
||||||
import java.util.regex.Pattern;
|
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)。
|
* 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。
|
||||||
* 运行时不支持热加载,皮肤包随版本发布(zip 上传是 P3 项)。
|
*
|
||||||
|
* <p>W1 语义(D1):内置只读、外部可管;内置优先于同名外部(冲突留 warn);{@link #reload()}
|
||||||
|
* 运行期热重扫(default 缺失时抛异常且不回滚旧集合,T-W1-04);校验语义收敛于
|
||||||
|
* {@link ThemePackageValidator}(Registry 与 PackageService 共用,杜绝两套校验漂移)。
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class ThemeRegistry {
|
public class ThemeRegistry {
|
||||||
|
|
||||||
public static final String DEFAULT_THEME = "default";
|
public static final String DEFAULT_THEME = "default";
|
||||||
|
public static final String SOURCE_BUILTIN = "builtin";
|
||||||
|
public static final String SOURCE_EXTERNAL = "external";
|
||||||
|
|
||||||
/** 包名与目录名约束(方案 §4.1) */
|
/** 包名与目录名约束(方案 §4.1) */
|
||||||
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,30}[a-z0-9]$");
|
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);
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
private final Map<String, ThemeInfo> themes = new LinkedHashMap<>();
|
private final Map<String, ThemeInfo> themes = new LinkedHashMap<>();
|
||||||
|
private final Map<String, String> sources = new LinkedHashMap<>();
|
||||||
|
private final List<Runnable> 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
|
@PostConstruct
|
||||||
public void load() {
|
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<String, ThemeInfo> loaded = new LinkedHashMap<>();
|
Map<String, ThemeInfo> loaded = new LinkedHashMap<>();
|
||||||
|
Map<String, String> loadedSources = new LinkedHashMap<>();
|
||||||
int rejected = 0;
|
int rejected = 0;
|
||||||
for (Resource resource : scanResources()) {
|
|
||||||
|
// ① 内置 classpath 包(先注册,优先级高)
|
||||||
|
for (Resource resource : scanClasspathResources()) {
|
||||||
String dirName = dirNameOf(resource);
|
String dirName = dirNameOf(resource);
|
||||||
try {
|
try {
|
||||||
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
|
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);
|
loaded.put(info.getName(), info);
|
||||||
|
loadedSources.put(info.getName(), SOURCE_BUILTIN);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
rejected++;
|
rejected++;
|
||||||
log.warn("主题包 {} 注册失败被剔除:{}", dirName, e.getMessage());
|
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<ThemeInfo> sorted = new ArrayList<>(loaded.values());
|
List<ThemeInfo> sorted = new ArrayList<>(loaded.values());
|
||||||
sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName())
|
sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName())
|
||||||
? "" : info.getName()));
|
? "" : info.getName()));
|
||||||
|
Map<String, ThemeInfo> newThemes = new LinkedHashMap<>();
|
||||||
|
Map<String, String> newSources = new LinkedHashMap<>();
|
||||||
for (ThemeInfo info : sorted) {
|
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);
|
log.info("主题注册表加载完成:可用 {} {},剔除 {} 个坏包", themes.size(), themes.keySet(), rejected);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,66 +189,38 @@ public class ThemeRegistry {
|
|||||||
return name == null ? null : themes.get(name);
|
return name == null ? null : themes.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 包规范校验(方案 §4.1)=====
|
/** W1:包来源(builtin/external);未注册返回 null */
|
||||||
|
public String sourceOf(String name) {
|
||||||
private void validate(ThemeInfo info, String dirName) {
|
return name == null ? null : sources.get(name);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validateParams(ThemeInfo info) {
|
/** W1:包内相对资源定位(内置→ClassPathResource;外部→FileSystemResource,exists 即时判定) */
|
||||||
List<String> seen = new ArrayList<>();
|
public Resource resource(String name, String relativePath) {
|
||||||
for (ThemeParam p : info.getParams()) {
|
String rel = relativePath == null ? "" : relativePath;
|
||||||
if (p.getKey() == null || !ThemeParam.KEY_PATTERN.matcher(p.getKey()).matches()) {
|
while (rel.startsWith("/")) {
|
||||||
throw new IllegalArgumentException("param key 非法:" + p.getKey());
|
rel = rel.substring(1);
|
||||||
}
|
}
|
||||||
if (seen.contains(p.getKey())) {
|
if (SOURCE_EXTERNAL.equals(sources.get(name))) {
|
||||||
throw new IllegalArgumentException("param key 重复:" + p.getKey());
|
return new FileSystemResource(Paths.get(themeDir, name, rel).toFile());
|
||||||
}
|
}
|
||||||
seen.add(p.getKey());
|
return new ClassPathResource("templates/themes/" + name + "/" + rel);
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 包规范校验(§4.1,语义收敛于 ThemePackageValidator)=====
|
||||||
|
|
||||||
|
/** 包名规范(PackageService 上传/派生共用) */
|
||||||
|
public static boolean isValidName(String name) {
|
||||||
|
return name != null && NAME_PATTERN.matcher(name).matches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static List<String> requiredTemplates() {
|
||||||
|
return REQUIRED_TEMPLATES;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 资源定位 =====
|
// ===== 资源定位 =====
|
||||||
|
|
||||||
private List<Resource> scanResources() {
|
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
|
||||||
|
protected List<Resource> scanClasspathResources() {
|
||||||
try {
|
try {
|
||||||
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
|
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
|
||||||
return Arrays.asList(resources);
|
return Arrays.asList(resources);
|
||||||
@@ -158,6 +229,27 @@ public class ThemeRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */
|
||||||
|
protected List<Path> scanExternalPackages() {
|
||||||
|
List<Path> 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<Path> 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 截取所属包目录名 */
|
/** 从 theme.json 资源 URL 截取所属包目录名 */
|
||||||
private String dirNameOf(Resource resource) {
|
private String dirNameOf(Resource resource) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.yunhai.cms.portal;
|
|||||||
|
|
||||||
import com.yunhai.cms.entity.CmsSite;
|
import com.yunhai.cms.entity.CmsSite;
|
||||||
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;
|
||||||
@@ -9,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
|||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
import org.springframework.test.web.servlet.MvcResult;
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
@@ -25,25 +27,47 @@ class ThemeRenderIT extends CmsIntegrationTestBase {
|
|||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
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
|
@BeforeEach
|
||||||
void seed() {
|
void seed() throws Exception {
|
||||||
purgeTables();
|
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
|
@AfterEach
|
||||||
void clean() {
|
void clean() {
|
||||||
purgeTables();
|
purgeTables();
|
||||||
|
com.yunhai.cms.support.ThemePackageTestSupport.deleteRecursively(themeDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persistSiteWithConfig(String code, String configJson) {
|
private void persistSiteWithConfig(String code, String configJson) {
|
||||||
|
persistSiteWithTheme(code, "default", configJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistSiteWithTheme(String code, String theme, String configJson) {
|
||||||
CmsSite site = new CmsSite();
|
CmsSite site = new CmsSite();
|
||||||
site.setCode(code);
|
site.setCode(code);
|
||||||
site.setPath(code); // W4:顶层 path=code(与 persistSite 同口径)
|
site.setPath(code); // W4:顶层 path=code(与 persistSite 同口径)
|
||||||
site.setSchId(1);
|
site.setSchId(1);
|
||||||
site.setSchName("学校-1");
|
site.setSchName("学校-1");
|
||||||
site.setName("注入测试门户");
|
site.setName("注入测试门户");
|
||||||
site.setTheme("default");
|
site.setTheme(theme);
|
||||||
site.setConfigJson(configJson);
|
site.setConfigJson(configJson);
|
||||||
site.setStatus(1);
|
site.setStatus(1);
|
||||||
siteRepository.saveAndFlush(site);
|
siteRepository.saveAndFlush(site);
|
||||||
@@ -56,6 +80,43 @@ class ThemeRenderIT extends CmsIntegrationTestBase {
|
|||||||
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
return result.getResponse().getContentAsString(StandardCharsets.UTF_8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== W1 外部包门户 SSR(方案 §5.3 T-W1-19/20)=====
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private java.util.List<org.thymeleaf.templateresolver.ITemplateResolver> 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
|
@Test
|
||||||
@DisplayName("T13: 微调参数注入 CSS 变量且位于皮肤 CSS link 之后")
|
@DisplayName("T13: 微调参数注入 CSS 变量且位于皮肤 CSS link 之后")
|
||||||
void themeParamsInjectedAfterSkinCss() throws Exception {
|
void themeParamsInjectedAfterSkinCss() throws Exception {
|
||||||
|
|||||||
@@ -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<Path> 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("</body>", "<p class=\"ext-marker\">" + homeMarker + "</p></body>");
|
||||||
|
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<Path> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
package com.yunhai.cms.theme;
|
package com.yunhai.cms.theme;
|
||||||
|
|
||||||
import com.yunhai.cms.controller.api.ThemeController;
|
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.service.impl.ThemeService;
|
||||||
import com.yunhai.cms.support.CmsIntegrationTestBase;
|
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.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;
|
||||||
@@ -14,6 +19,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
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.request.MockMvcRequestBuilders.get;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -32,14 +38,33 @@ class ThemeApiIT extends CmsIntegrationTestBase {
|
|||||||
private ThemeService themeService;
|
private ThemeService themeService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private ThemeRegistry themeRegistry;
|
private ThemeRegistry themeRegistry;
|
||||||
|
@Autowired
|
||||||
|
private ThemePackageService themePackageService;
|
||||||
|
@Autowired
|
||||||
|
private SiteService siteService;
|
||||||
|
@Autowired
|
||||||
|
private PortalQueryService portalQueryService;
|
||||||
|
@Autowired
|
||||||
|
private CmsSiteRepository siteRepository;
|
||||||
|
|
||||||
private MockMvc standalone;
|
private MockMvc standalone;
|
||||||
|
private java.nio.file.Path themeDir;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() throws Exception {
|
||||||
purgeTables();
|
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
|
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
|
@AfterEach
|
||||||
@@ -109,4 +134,139 @@ class ThemeApiIT extends CmsIntegrationTestBase {
|
|||||||
.andExpect(header().string("Cache-Control", "max-age=3600, public"));
|
.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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Path> 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("<p>evil</p>".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<Path> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,4 +152,120 @@ class ThemeRegistryTest {
|
|||||||
.as("旧 /upload/** 值应被过滤丢弃,不落库")
|
.as("旧 /upload/** 值应被过滤丢弃,不落库")
|
||||||
.doesNotContainKey("heroImage");
|
.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<String> 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<org.springframework.core.io.Resource> 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<java.nio.file.Path> 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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user