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)。 * *

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 DEPRECATED_ALIASES = java.util.Collections.unmodifiableMap( new java.util.LinkedHashMap() {{ // 色皮档 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 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 themes = new LinkedHashMap<>(); private final Map sources = new LinkedHashMap<>(); private final List 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 loaded = new LinkedHashMap<>(); Map 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 sorted = new ArrayList<>(loaded.values()); sorted.sort(Comparator.comparing(info -> DEFAULT_THEME.equals(info.getName()) ? "" : info.getName())); Map newThemes = new LinkedHashMap<>(); Map 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 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 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 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 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 scanExternalPackages() { List 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 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"; } } }