Files
YschcmsService/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java
T

374 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 org.springframework.util.StreamUtils;
import javax.annotation.PostConstruct;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
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;W1 扩展:外部主题目录 + 热注册,方案 §5.4 步骤 1):
* 启动时扫描 classpath 内置包与 {@code cms.theme.dir} 外部包(默认 ${user.dir}/data/themes),
* 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。
*
* <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";
/**
* docs/20 E(激进清场)下架主题别名表:原主题名 → [保留主题, colorScheme 预设值]。
* 存量站点 theme 仍写下架名时按本表解析(R-46:门户不断渲染,不 404);
* 站点下次保存时由 SiteService 归一化为保留主题 + config.theme_params.colorScheme。
*/
private static final java.util.Map<String, String[]> DEPRECATED_ALIASES = java.util.Collections.unmodifiableMap(
new java.util.LinkedHashMap<String, String[]>() {{
// 色皮档 4 套 → default 预设
put("blue-white", new String[] {DEFAULT_THEME, "blue-white"});
put("classic-red", new String[] {DEFAULT_THEME, "classic-red"});
put("fresh-mint", new String[] {DEFAULT_THEME, "fresh-mint"});
put("warm-kinder", new String[] {DEFAULT_THEME, "warm-kinder"});
// 骨架克隆档 6 套 → scholar-blue 预设
put("academic-navy", new String[] {"scholar-blue", "navy"});
put("campus-azure", new String[] {"scholar-blue", "azure"});
put("poly-blue", new String[] {"scholar-blue", "poly"});
put("agri-forest", new String[] {"scholar-blue", "agri"});
put("jade-normal", new String[] {"scholar-blue", "jade"});
put("mint-breeze", new String[] {"scholar-blue", "mint"});
}});
/** 下架别名解析结果:target=保留主题名;colorScheme=对应预设值(可能为 null) */
public static final class AliasInfo {
public final String target;
public final String colorScheme;
AliasInfo(String target, String colorScheme) {
this.target = target;
this.colorScheme = colorScheme;
}
}
/** 下架别名解析:非别名返回 null */
public static AliasInfo aliasOf(String name) {
String[] mapped = name == null ? null : DEPRECATED_ALIASES.get(name);
return mapped == null ? null : new AliasInfo(mapped[0], mapped[1]);
}
/** 包名与目录名约束(方案 §4.1) */
private static final Pattern NAME_PATTERN = Pattern.compile("^[a-z][a-z0-9-]{1,30}[a-z0-9]$");
/** 必备页面模板(方案 §4.1,收紧 R12:缺任一即整包剔除) */
private static final List<String> REQUIRED_TEMPLATES =
Arrays.asList("home.html", "list.html", "detail.html", "single.html");
private static final String SCAN_PATTERN = "classpath*:templates/themes/*/theme.json";
/** 元数据覆盖层目录(任务四):{cms.theme.dir}/.meta/{name}.json;外部包扫描按「含 theme.json 的目录」过滤,天然跳过 */
private static final String META_OVERRIDE_DIR = ".meta";
/** 未知字段忽略、已知字段兼容(方案 §4.2 specVersion 演进策略) */
private static final ObjectMapper MAPPER = new ObjectMapper()
.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() {
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;
// ① 内置 classpath 包(先注册,优先级高)
for (Resource resource : scanClasspathResources()) {
String dirName = dirNameOf(resource);
try {
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName,
rel -> new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists(),
rel -> readClasspath("templates/themes/" + dirName + "/" + rel));
applyMetaOverride(info);
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)),
rel -> readTextFile(pkg.resolve(rel)));
if (loaded.containsKey(info.getName())) {
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
continue;
}
applyMetaOverride(info);
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) {
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());
}
}
log.info("主题注册表加载完成:可用 {} {},剔除 {} 个坏包", themes.size(), themes.keySet(), rejected);
}
/** default 包缺失或非法时抛 IllegalStateException 阻止启动(方案 §5.1 fail-fast) */
public void verifyDefaultPresent() {
if (!themes.containsKey(DEFAULT_THEME)) {
throw new IllegalStateException("默认主题包 default 缺失或非法,拒绝启动(方案 §5.1)");
}
}
/** 可用主题(default 优先,其余按名称排序) */
public List<ThemeInfo> list() {
return new ArrayList<>(themes.values());
}
public boolean contains(String name) {
return name != null && themes.containsKey(name);
}
public ThemeInfo get(String name) {
return name == null ? null : themes.get(name);
}
/** W1:包来源(builtin/external);未注册返回 null */
public String sourceOf(String name) {
return name == null ? null : sources.get(name);
}
/** 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;
}
/** 读 classpath 包内文本(UTF-8);读不到返回 null(区块门禁据此跳过,不臆断) */
private static String readClasspath(String location) {
try (InputStream in = new ClassPathResource(location).getInputStream()) {
return StreamUtils.copyToString(in, StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
/** 读外部包内文本(UTF-8);读不到返回 null */
private static String readTextFile(Path file) {
try {
return new String(Files.readAllBytes(file), StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}
// ===== 资源定位 =====
/** classpath 内置包扫描(protected:测试可屏蔽以构造外部-only 场景,T-W1-04) */
protected List<Resource> scanClasspathResources() {
try {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
return Arrays.asList(resources);
} catch (IOException e) {
throw new IllegalStateException("主题包扫描失败:" + SCAN_PATTERN, e);
}
}
/**
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
* 内置包 theme.json 在 jar 内只读,显示名/描述改名与包级参数值均走此通道;损坏的覆盖层忽略(按原始元数据注册)。
*/
private void applyMetaOverride(ThemeInfo info) {
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
return;
}
Path overrideFile = Paths.get(themeDir, META_OVERRIDE_DIR, info.getName() + ".json");
if (!Files.isRegularFile(overrideFile)) {
return;
}
try {
ThemeInfo meta = MAPPER.readValue(overrideFile.toFile(), ThemeInfo.class);
if (meta.getTitle() != null && !meta.getTitle().trim().isEmpty()) {
info.setTitle(meta.getTitle().trim());
}
if (meta.getDescription() != null) {
info.setDescription(meta.getDescription());
}
if (meta.getParamValues() != null && !meta.getParamValues().isEmpty()) {
info.setParamValues(new LinkedHashMap<>(meta.getParamValues()));
}
} catch (Exception e) {
log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage());
}
}
/**
* 包级参数值(工作项 T):主题不存在返回空 Map(调用方免判空)。
* 返回不可变视图——参数值的唯一写入口是 {@code ThemePackageService.updateMeta}。
*/
public Map<String, String> paramValuesOf(String name) {
ThemeInfo info = name == null ? null : themes.get(name);
if (info == null || info.getParamValues() == null) {
return java.util.Collections.emptyMap();
}
return java.util.Collections.unmodifiableMap(info.getParamValues());
}
/** 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 {
String url = resource.getURL().toString().replace('\\', '/');
int root = url.indexOf("templates/themes/");
if (root < 0) {
return url;
}
String rest = url.substring(root + "templates/themes/".length());
return rest.contains("/") ? rest.substring(0, rest.indexOf('/')) : rest;
} catch (IOException e) {
return "unknown";
}
}
}