374 lines
16 KiB
Java
374 lines
16 KiB
Java
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";
|
||
}
|
||
}
|
||
}
|