init local code
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package com.yunhai.cms.theme;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 主题包元数据(theme.json 解析结果,方案 §4.2)。
|
||||
* 仅含包声明信息;注册与校验在 {@link ThemeRegistry}。
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ThemeInfo {
|
||||
|
||||
private Integer specVersion;
|
||||
|
||||
private String name;
|
||||
|
||||
private String title;
|
||||
|
||||
private String version;
|
||||
|
||||
private String author;
|
||||
|
||||
private String description;
|
||||
|
||||
/** 预览图相对包路径(缺省 assets/screenshot.png,方案 §4.1/§5.2) */
|
||||
private String screenshot;
|
||||
|
||||
/** 轮播图设计尺寸约定(D2 决策点),如 1920x560 */
|
||||
private String bannerSize;
|
||||
|
||||
private List<ThemeParam> params = new ArrayList<>();
|
||||
|
||||
public String screenshotPath() {
|
||||
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
|
||||
}
|
||||
|
||||
public ThemeParam param(String key) {
|
||||
for (ThemeParam p : params) {
|
||||
if (p.getKey() != null && p.getKey().equals(key)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 schema 过滤 + 校验原始参数(方案 §5.3 合并语义):
|
||||
* 未声明 key 丢弃、非法值丢弃;返回仅含合法声明的有序 Map。
|
||||
*/
|
||||
public Map<String, String> filterValidValues(Map<?, ?> raw) {
|
||||
Map<String, String> result = new LinkedHashMap<>();
|
||||
if (raw == null) {
|
||||
return result;
|
||||
}
|
||||
for (Map.Entry<?, ?> entry : raw.entrySet()) {
|
||||
Object rawKey = entry.getKey();
|
||||
Object rawValue = entry.getValue();
|
||||
if (rawKey == null || rawValue == null) {
|
||||
continue;
|
||||
}
|
||||
ThemeParam schema = param(rawKey.toString());
|
||||
String value = rawValue.toString();
|
||||
if (schema != null && schema.isValidValue(value)) {
|
||||
result.put(schema.getKey(), value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.yunhai.cms.theme;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 主题包可调参数声明(theme.json params[],方案 §4.2)。
|
||||
* 类型与取值约束:color 色值正则 / image 仅 /cms/upload/** / select 枚举 / text 短文本。
|
||||
*/
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class ThemeParam {
|
||||
|
||||
public static final String TYPE_COLOR = "color";
|
||||
public static final String TYPE_IMAGE = "image";
|
||||
public static final String TYPE_SELECT = "select";
|
||||
public static final String TYPE_TEXT = "text";
|
||||
|
||||
/** color 合法值:#RGB/#RRGGBB/#RRGGBBAA(方案 §4.2,防 CSS 上下文注入) */
|
||||
public static final Pattern COLOR_PATTERN = Pattern.compile("^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$");
|
||||
|
||||
/** param key 约束(方案 §4.1) */
|
||||
public static final Pattern KEY_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]{0,31}$");
|
||||
|
||||
private String key;
|
||||
|
||||
/** 聚合进 :root{} 的 CSS 变量名(仅 color 类型允许,方案 §4.1/§9) */
|
||||
private String cssVar;
|
||||
|
||||
private String label;
|
||||
|
||||
private String type;
|
||||
|
||||
@JsonProperty("default")
|
||||
private String defaultValue;
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@Data
|
||||
public static class Option {
|
||||
private String value;
|
||||
private String label;
|
||||
}
|
||||
|
||||
private List<Option> options;
|
||||
|
||||
/** 值是否合法(按参数类型;null/空一律不合法,方案 §5.3) */
|
||||
public boolean isValidValue(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
switch (type == null ? "" : type) {
|
||||
case TYPE_COLOR:
|
||||
return COLOR_PATTERN.matcher(value).matches();
|
||||
case TYPE_IMAGE:
|
||||
// B-7:与应用真实可达路径对齐——附件 webUrl 相对形态为 /cms/upload/**(含 context-path),
|
||||
// 旧约定 /upload/** 在 context-path=/cms 部署下渲染必 404
|
||||
return value.startsWith("/cms/upload/");
|
||||
case TYPE_SELECT:
|
||||
return options != null && options.stream().anyMatch(o -> value.equals(o.getValue()));
|
||||
case TYPE_TEXT:
|
||||
return value.length() <= 256;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否聚合为 CSS 变量(仅 color 且声明了 cssVar,方案 §4.2) */
|
||||
public boolean isCssVarParam() {
|
||||
return cssVar != null && !cssVar.isEmpty() && TYPE_COLOR.equals(type);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yunhai.cms.theme;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* themeVarsCss 构建器(方案 §5.3,对应评审 R4):
|
||||
* 服务端预构建 :root{...} 覆盖串,只拼通过 color 正则校验的声明值——
|
||||
* 模板仅做 th:utext 输出,安全性由"服务端只拼校验过的值"保证(可单测)。
|
||||
*/
|
||||
public final class ThemeVarsCss {
|
||||
|
||||
private ThemeVarsCss() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主题 schema 将参数值聚合为 CSS 变量声明串,形如
|
||||
* {@code :root{--primary:#b01a2e;--bg:#f7f5f2;}};无有效声明返回空串。
|
||||
* 输入应为已按 schema 过滤的合法值({@link ThemeInfo#filterValidValues})。
|
||||
*/
|
||||
public static String build(ThemeInfo theme, Map<String, String> params) {
|
||||
if (theme == null || params == null || params.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder(":root{");
|
||||
for (ThemeParam p : theme.getParams()) {
|
||||
if (!p.isCssVarParam()) {
|
||||
continue;
|
||||
}
|
||||
String value = params.get(p.getKey());
|
||||
// 双重校验(方案 §9 注入面):入口已过滤,此处仍只拼通过 color 正则的值
|
||||
if (value == null || !p.isValidValue(value)) {
|
||||
continue;
|
||||
}
|
||||
sb.append(p.getCssVar()).append(':').append(value).append(';');
|
||||
}
|
||||
return sb.length() == 6 ? "" : sb.append('}').toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user