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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user