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

This commit is contained in:
chenxing
2026-09-08 14:52:29 +08:00
parent 536bd6cea4
commit 338ffb56dd
16 changed files with 1548 additions and 71 deletions
@@ -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.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import java.util.regex.Pattern;
/**
* 主题注册表(方案 §5.1):启动时按 SCAN_PATTERN 扫描各主题包的 theme.json,
* 主题注册表(方案 §5.1;W1 扩展:外部主题目录 + 热注册,方案 §5.4 步骤 1):
* 启动时扫描 classpath 内置包与 {@code cms.theme.dir} 外部包(默认 ${user.dir}/data/themes),
* 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。
* 运行时不支持热加载,皮肤包随版本发布(zip 上传是 P3 项)。
*
* <p>W1 语义(D1):内置只读、外部可管;内置优先于同名外部(冲突留 warn);{@link #reload()}
* 运行期热重扫(default 缺失时抛异常且不回滚旧集合,T-W1-04);校验语义收敛于
* {@link ThemePackageValidator}(Registry 与 PackageService 共用,杜绝两套校验漂移)。
*/
@Slf4j
@Component
public class ThemeRegistry {
public static final String DEFAULT_THEME = "default";
public static final String SOURCE_BUILTIN = "builtin";
public static final String SOURCE_EXTERNAL = "external";
/** 包名与目录名约束(方案 §4.1) */
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,30}[a-z0-9]$");
@@ -43,30 +55,117 @@ public class ThemeRegistry {
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final Map<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
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, String> loadedSources = new LinkedHashMap<>();
int rejected = 0;
for (Resource resource : scanResources()) {
// ① 内置 classpath 包(先注册,优先级高)
for (Resource resource : scanClasspathResources()) {
String dirName = dirNameOf(resource);
try {
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
validate(info, dirName);
ThemePackageValidator.validate(info, dirName, rel ->
new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists());
loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_BUILTIN);
} catch (Exception e) {
rejected++;
log.warn("主题包 {} 注册失败被剔除:{}", dirName, e.getMessage());
}
}
// ② 外部目录包(同名让位内置,warn 留痕;T-W1-03)
for (Path pkg : scanExternalPackages()) {
String dirName = pkg.getFileName().toString();
Path json = pkg.resolve("theme.json");
try {
ThemeInfo info = MAPPER.readValue(json.toFile(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName, rel -> Files.exists(pkg.resolve(rel)));
if (loaded.containsKey(info.getName())) {
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
continue;
}
loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_EXTERNAL);
} catch (Exception e) {
rejected++;
log.warn("外部主题包 {} 注册失败被剔除:{}", dirName, e.getMessage());
}
}
List<ThemeInfo> sorted = new ArrayList<>(loaded.values());
sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName())
? "" : info.getName()));
Map<String, ThemeInfo> newThemes = new LinkedHashMap<>();
Map<String, String> newSources = new LinkedHashMap<>();
for (ThemeInfo info : sorted) {
themes.put(info.getName(), info);
newThemes.put(info.getName(), info);
newSources.put(info.getName(), loadedSources.get(info.getName()));
}
if (!newThemes.containsKey(DEFAULT_THEME)) {
if (startup) {
throw new IllegalStateException("默认主题包 default 缺失或非法,拒绝启动(方案 §5.1)");
}
throw new IllegalStateException("默认主题包 default 缺失或非法,reload 已中止(旧集合保持不变,T-W1-04)");
}
themes.clear();
themes.putAll(newThemes);
sources.clear();
sources.putAll(newSources);
for (Runnable listener : reloadListeners) {
try {
listener.run();
} catch (Exception e) {
log.warn("主题注册表 reload 回调失败:{}", e.getMessage());
}
}
verifyDefaultPresent();
log.info("主题注册表加载完成:可用 {} {},剔除 {} 个坏包", themes.size(), themes.keySet(), rejected);
}
@@ -90,66 +189,38 @@ public class ThemeRegistry {
return name == null ? null : themes.get(name);
}
// ===== 包规范校验(方案 §4.1)=====
private void validate(ThemeInfo info, String dirName) {
if (info == null) {
throw new IllegalArgumentException("清单为空");
}
if (info.getSpecVersion() == null || info.getSpecVersion() != 1) {
throw new IllegalArgumentException("specVersion 必须为 1");
}
if (info.getName() == null || !NAME_PATTERN.matcher(info.getName()).matches()) {
throw new IllegalArgumentException("name 缺失或不符合命名规范:" + info.getName());
}
if (!info.getName().equals(dirName)) {
throw new IllegalArgumentException("name 与目录名不一致:" + dirName + " != " + info.getName());
}
for (String template : REQUIRED_TEMPLATES) {
if (!new ClassPathResource("templates/themes/" + dirName + "/" + template).exists()) {
throw new IllegalArgumentException("缺必备模板 " + template);
}
}
// fragments/ 目录存在性以 head.html 片段代替(jar 内目录项不可靠)
if (!new ClassPathResource("templates/themes/" + dirName + "/fragments/head.html").exists()) {
throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
}
validateParams(info);
String shot = "templates/themes/" + dirName + "/" + info.screenshotPath();
if (!new ClassPathResource(shot).exists()) {
throw new IllegalArgumentException("声明的 screenshot 不存在:" + info.screenshotPath());
}
/** W1:包来源(builtin/external);未注册返回 null */
public String sourceOf(String name) {
return name == null ? null : sources.get(name);
}
private void validateParams(ThemeInfo info) {
List<String> seen = new ArrayList<>();
for (ThemeParam p : info.getParams()) {
if (p.getKey() == null || !ThemeParam.KEY_PATTERN.matcher(p.getKey()).matches()) {
throw new IllegalArgumentException("param key 非法:" + p.getKey());
}
if (seen.contains(p.getKey())) {
throw new IllegalArgumentException("param key 重复:" + p.getKey());
}
seen.add(p.getKey());
String type = p.getType();
boolean knownType = ThemeParam.TYPE_COLOR.equals(type) || ThemeParam.TYPE_IMAGE.equals(type)
|| ThemeParam.TYPE_SELECT.equals(type) || ThemeParam.TYPE_TEXT.equals(type);
if (!knownType) {
throw new IllegalArgumentException("param " + p.getKey() + " 类型非法:" + type);
}
if (p.getCssVar() != null && !p.getCssVar().isEmpty() && !ThemeParam.TYPE_COLOR.equals(type)) {
throw new IllegalArgumentException("cssVar 仅允许 color 类型参数:" + p.getKey());
}
if (ThemeParam.TYPE_SELECT.equals(type)
&& (p.getOptions() == null || p.getOptions().isEmpty())) {
throw new IllegalArgumentException("select 参数缺 options:" + p.getKey());
}
/** W1:包内相对资源定位(内置→ClassPathResource;外部→FileSystemResource,exists 即时判定) */
public Resource resource(String name, String relativePath) {
String rel = relativePath == null ? "" : relativePath;
while (rel.startsWith("/")) {
rel = rel.substring(1);
}
if (SOURCE_EXTERNAL.equals(sources.get(name))) {
return new FileSystemResource(Paths.get(themeDir, name, rel).toFile());
}
return new ClassPathResource("templates/themes/" + name + "/" + rel);
}
// ===== 包规范校验(§4.1,语义收敛于 ThemePackageValidator)=====
/** 包名规范(PackageService 上传/派生共用) */
public static boolean isValidName(String name) {
return name != null && NAME_PATTERN.matcher(name).matches();
}
public static List<String> requiredTemplates() {
return REQUIRED_TEMPLATES;
}
// ===== 资源定位 =====
private List<Resource> scanResources() {
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
protected List<Resource> scanClasspathResources() {
try {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
return Arrays.asList(resources);
@@ -158,6 +229,27 @@ public class ThemeRegistry {
}
}
/** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */
protected List<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 截取所属包目录名 */
private String dirNameOf(Resource resource) {
try {