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,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();
}
}