init local code
This commit is contained in:
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user