init local code

This commit is contained in:
chenxing
2026-09-07 09:09:39 +08:00
commit 99cd26b3d2
259 changed files with 20554 additions and 0 deletions
@@ -0,0 +1,175 @@
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.core.io.ClassPathResource;
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.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.regex.Pattern;
/**
* 主题注册表(方案 §5.1):启动时按 SCAN_PATTERN 扫描各主题包的 theme.json,
* 按包规范(§4.1)校验,坏包剔除 + log.warn;default 缺失或非法 → 启动失败(fail-fast)。
* 运行时不支持热加载,皮肤包随版本发布(zip 上传是 P3 项)。
*/
@Slf4j
@Component
public class ThemeRegistry {
public static final String DEFAULT_THEME = "default";
/** 包名与目录名约束(方案 §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";
/** 未知字段忽略、已知字段兼容(方案 §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<>();
@PostConstruct
public void load() {
themes.clear();
Map<String, ThemeInfo> loaded = new LinkedHashMap<>();
int rejected = 0;
for (Resource resource : scanResources()) {
String dirName = dirNameOf(resource);
try {
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
validate(info, dirName);
loaded.put(info.getName(), info);
} 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()));
for (ThemeInfo info : sorted) {
themes.put(info.getName(), info);
}
verifyDefaultPresent();
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);
}
// ===== 包规范校验(方案 §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());
}
}
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());
}
}
}
// ===== 资源定位 =====
private List<Resource> scanResources() {
try {
Resource[] resources = new PathMatchingResourcePatternResolver().getResources(SCAN_PATTERN);
return Arrays.asList(resources);
} catch (IOException e) {
throw new IllegalStateException("主题包扫描失败:" + SCAN_PATTERN, e);
}
}
/** 从 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";
}
}
}