feat(theme): S5 学院蓝主题改造——home-blocks/service/guide页+区块CSS+ThemeRegistry增强
build / build-push (push) Successful in 53s

This commit is contained in:
chenxing
2026-09-22 17:35:38 +08:00
parent 82d6ff4602
commit bc794ccce3
28 changed files with 1818 additions and 86 deletions
@@ -69,13 +69,14 @@ public class ThemeController {
}
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效 */
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效。
* 工作项 T:paramValues(包级参数值)两种来源统一写 .meta 覆盖层,null=不改 */
@PutMapping("/{name}/meta")
public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept,
@PathVariable("name") String name,
@RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) {
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
req.getTitle(), req.getDescription(), req.getParams());
req.getTitle(), req.getDescription(), req.getParams(), req.getParamValues());
return themeService.voOf(info);
}
@@ -23,6 +23,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
@@ -288,6 +289,8 @@ public class PortalPageRenderer {
private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/** 区块内手填链接:http(s) 绝对地址或站内根相对路径(与 SiteService 保存期校验同契约,拒绝伪协议) */
private static final Pattern LAYOUT_LINK_URL = Pattern.compile("^(https?://\\S+|/.+)$");
/**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
@@ -341,6 +344,10 @@ public class PortalPageRenderer {
return buildGalleryBlock(site, spec, url);
case "links":
return buildLinksBlock(site, spec);
case "quick_links":
return buildQuickLinksBlock(spec);
case "notice_bar":
return buildNoticeBarBlock(spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
@@ -463,6 +470,48 @@ public class PortalPageRenderer {
return block;
}
/**
* 快捷入口宫格(v2 手册 §2.8 类型 A):数据是区块内联数组,不读任何外部表。
* 保存期已校验 url 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。
*/
private Map<String, Object> buildQuickLinksBlock(Map<String, Object> spec) {
List<Map<String, Object>> links = new ArrayList<>();
Object raw = spec.get("items");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String name = str(item.get("name"), "").trim();
String linkUrl = str(item.get("url"), "").trim();
if (name.isEmpty() || !LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
continue;
}
Map<String, Object> link = new HashMap<>();
link.put("name", name);
link.put("url", linkUrl);
links.add(link);
}
}
Map<String, Object> block = baseBlock(spec, "quick_links", "快捷通道");
block.put("quickLinks", links);
return block;
}
/** 公告条(v2 手册 §2.8 类型 B):站点单条手填通告;level 白名单外归一 info,url 不合法则退化为纯文本 */
private Map<String, Object> buildNoticeBarBlock(Map<String, Object> spec) {
String linkUrl = str(spec.get("url"), "").trim();
if (!LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
linkUrl = "";
}
Map<String, Object> block = baseBlock(spec, "notice_bar", "公告");
block.put("noticeText", str(spec.get("text"), "").trim());
block.put("noticeUrl", linkUrl);
block.put("noticeLevel", "warn".equals(str(spec.get("level"), "").trim()) ? "warn" : "info");
return block;
}
// ===== 布局装配工具 =====
private static String str(Object v, String fallback) {
@@ -44,9 +44,12 @@ public class SiteSaveRequest {
private String configJson;
/**
* 主题微调参数(方案 §5.3):仅接收所选主题 theme.json 声明过的 key;
* 服务端校验后合并进 config_json.theme_params(保留其余键),非法 key/值服务端丢弃。
* 主题微调参数(方案 §5.3)。
*
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
*/
@Deprecated
private Map<String, String> themeParams;
/** 0=停用 1=启用 */
@@ -4,6 +4,7 @@ import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */
@Data
@@ -16,4 +17,10 @@ public class ThemeMetaRequest {
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */
private List<ThemeParam> params;
/**
* 包级参数值(工作项 T:参数微调迁皮肤包级):null=不改,非 null=整体替换(空 Map=恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃;内置包与外部包统一写 .meta 覆盖层。
*/
private Map<String, String> paramValues;
}
@@ -5,6 +5,7 @@ import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 主题包 VO(管理端主题列表,方案 §5.2)。
@@ -30,6 +31,12 @@ public class ThemeVo {
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
private List<ThemeParamVo> params;
/**
* 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key;
* 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。
*/
private Map<String, String> paramValues;
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
private String source;
@@ -82,6 +89,8 @@ public class ThemeVo {
for (ThemeParam p : info.getParams()) {
vo.getParams().add(ThemeParamVo.of(p));
}
vo.setParamValues(info.getParamValues() == null
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
return vo;
}
}
@@ -289,9 +289,12 @@ public class PortalQueryService {
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
}
/** config_json.theme_params 原始值(非对象形态按无参数处理) */
/**
* 参数原始值(工作项 T:参数微调上移为皮肤包级):取当前主题的包级 {@code paramValues}
* ({@code themes/.meta/{name}.json} 覆盖层)。站点 {@code config_json.theme_params} 已退役、不再读取
* (存量残留由 SiteService 的 RESERVED 键容忍,不影响门户)。
*/
private Map<?, ?> rawParamsMap(PortalSiteSnapshot snapshot) {
Object raw = snapshot.getConfig() == null ? null : snapshot.getConfig().get("theme_params");
return raw instanceof Map ? (Map<?, ?>) raw : null;
return themeRegistry.paramValuesOf(snapshot.getTheme());
}
}
@@ -60,15 +60,25 @@ public class SiteService {
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("theme_params")));
private static final int CONFIG_VALUE_MAX_LEN = 500;
/** home_layout(docs/16 §4.6)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
/** home_layout(docs/16 §4.6 + v2 手册 §2.8)结构约束:类型白名单与规模上限(与前端 homeLayout.ts 同契约) */
private static final Set<String> HOME_LAYOUT_BLOCK_TYPES =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList(
"article_list", "ticker", "gallery", "links")));
"article_list", "ticker", "gallery", "links", "quick_links", "notice_bar")));
private static final int HOME_LAYOUT_MAX_BLOCKS = 30;
private static final int HOME_LAYOUT_MAX_BLOCK_KEYS = 32;
private static final int HOME_LAYOUT_MAX_TEXT = 100;
private static final int HOME_LAYOUT_SIZE_MIN = 1;
private static final int HOME_LAYOUT_SIZE_MAX = 20;
/** quick_links(§2.8 类型 A):内联入口宫格上限与单项 name 长度 */
private static final int HOME_LAYOUT_MAX_QUICK_LINKS = 12;
private static final int HOME_LAYOUT_MAX_LINK_NAME = 20;
/** notice_bar(§2.8 类型 B):公告正文上限 */
private static final int HOME_LAYOUT_MAX_NOTICE_TEXT = 200;
/** 区块内手填链接:仅放行 http(s) 绝对地址或站内根相对路径(拒绝 javascript: 等伪协议) */
private static final Pattern HOME_LAYOUT_LINK_PATTERN = Pattern.compile("^(https?://\\S+|/.+)$");
/** notice_bar 提示级别白名单(缺省 info) */
private static final Set<String> HOME_LAYOUT_NOTICE_LEVELS =
java.util.Collections.unmodifiableSet(new java.util.HashSet<>(java.util.Arrays.asList("info", "warn")));
/** custom_css 上限与黑名单(B3):服务端强校验,前端仅提示;黑名单为防御下限(方案风险表) */
private static final int CUSTOM_CSS_MAX_LEN = 10240;
@@ -174,7 +184,9 @@ public class SiteService {
}
site.setTheme(resolvedTheme);
validateConfigJson(req.getConfigJson(), site.getId());
site.setConfigJson(mergeThemeParams(req, resolvedTheme));
// 工作项 T:参数微调上移为皮肤包级,站点不再持有参数值——themeParams 入参直接忽略
// (config_json 原样入库;theme_params 仍是 RESERVED 键以容忍存量/老前端提交,不报错)
site.setConfigJson(req.getConfigJson());
site.setStatus(req.getStatus() == null ? 1 : req.getStatus());
site = siteRepository.save(site);
// B2 附件引用:config_json 的 logo/favicon 指向上传文件时回写场景引用(换图/清空自动解引用旧值)
@@ -240,29 +252,6 @@ public class SiteService {
}
}
/**
* themeParams 服务端合并(方案 §5.3,对应评审 R6):仅替换 config_json.theme_params 键,
* 保留 logo/contact/domain 等其余键;未声明 key 与非法值按所选主题 schema 丢弃(不回传错误)。
* SPA 不再手拼 configJson 中的该节点。
*/
private String mergeThemeParams(SiteSaveRequest req, String theme) {
if (req.getThemeParams() == null) {
return req.getConfigJson();
}
Map<String, Object> config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson()));
Map<String, String> filtered = themeRegistry.get(theme).filterValidValues(req.getThemeParams());
if (filtered.isEmpty()) {
config.remove("theme_params");
} else {
config.put("theme_params", filtered);
}
try {
return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(config);
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "站点配置序列化失败");
}
}
/**
* 院系子站层级校验(《院系子站方案》):上级必须存在、必须为顶层站(层级固定两层)、
* 与子站同校;子站缺省继承主站的学校信息;已挂子站的顶层站禁止改挂为子站(防锚点悬空)。
@@ -381,7 +370,8 @@ public class SiteService {
/**
* home_layout 结构校验(docs/16 §4.6):{version:int, blocks:array};blocks ≤30、每块 ≤32 键;
* type 白名单(article_list/ticker/gallery/links)、size 1~20、字符串字段(title/subtitle)≤100;
* type 白名单(article_list/ticker/gallery/links/quick_links/notice_bar)、size 1~20、字符串字段(title/subtitle)≤100;
* quick_links / notice_bar 另有分支校验(见 {@link #validateQuickLinksBlock} / {@link #validateNoticeBarBlock});
* categoryIds 数组且每项为正整数,且**必须属于本站点栏目树**(防跨站注入)。
* 新建站点(siteId=null)不允许携带 categoryIds(尚无栏目可引)。
*/
@@ -411,6 +401,11 @@ public class SiteService {
if (type == null || !type.isTextual() || !HOME_LAYOUT_BLOCK_TYPES.contains(type.asText())) {
throw new BizException(ErrorCode.PARAM_ERROR, "首页布局区块类型不合法:" + (type == null ? "缺省" : type.asText()));
}
if ("quick_links".equals(type.asText())) {
validateQuickLinksBlock(block);
} else if ("notice_bar".equals(type.asText())) {
validateNoticeBarBlock(block);
}
JsonNode size = block.get("size");
if (size != null && (!size.isNumber() || size.intValue() < HOME_LAYOUT_SIZE_MIN
|| size.intValue() > HOME_LAYOUT_SIZE_MAX)) {
@@ -452,6 +447,63 @@ public class SiteService {
}
}
/** quick_links(v2 手册 §2.8 类型 A):items 必填、1~12 项,每项 {name ≤20 字, url 合法} */
private void validateQuickLinksBlock(JsonNode block) {
JsonNode items = block.get("items");
if (items == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links 区块必须包含 items(入口列表)");
}
if (!items.isArray()) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 必须是数组");
}
if (items.size() == 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 至少 1 项");
}
if (items.size() > HOME_LAYOUT_MAX_QUICK_LINKS) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links items 不能超过 " + HOME_LAYOUT_MAX_QUICK_LINKS + " 项");
}
for (JsonNode item : items) {
if (!item.isObject()) {
throw new BizException(ErrorCode.PARAM_ERROR, "quick_links items 每项必须是对象({name, url})");
}
JsonNode name = item.get("name");
if (name == null || !name.isTextual() || name.asText().trim().isEmpty()
|| name.asText().trim().length() > HOME_LAYOUT_MAX_LINK_NAME) {
throw new BizException(ErrorCode.PARAM_ERROR,
"quick_links 项 name 须为不超过 " + HOME_LAYOUT_MAX_LINK_NAME + " 字的文本");
}
requireLayoutLink(item.get("url"), "quick_links 项");
}
}
/** notice_bar(v2 手册 §2.8 类型 B):text 必填 ≤200 字;url 可选;level 可选且限 info/warn */
private void validateNoticeBarBlock(JsonNode block) {
JsonNode text = block.get("text");
if (text == null || !text.isTextual() || text.asText().trim().isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar 区块必须填写 text(公告正文)");
}
if (text.asText().length() > HOME_LAYOUT_MAX_NOTICE_TEXT) {
throw new BizException(ErrorCode.PARAM_ERROR,
"notice_bar text 不能超过 " + HOME_LAYOUT_MAX_NOTICE_TEXT + " 字");
}
if (block.has("url")) {
requireLayoutLink(block.get("url"), "notice_bar");
}
JsonNode level = block.get("level");
if (level != null && (!level.isTextual() || !HOME_LAYOUT_NOTICE_LEVELS.contains(level.asText()))) {
throw new BizException(ErrorCode.PARAM_ERROR, "notice_bar level 只能是 info 或 warn");
}
}
/** 区块内手填链接统一校验(quick_links 项 / notice_bar url) */
private void requireLayoutLink(JsonNode url, String what) {
if (url == null || !url.isTextual() || !HOME_LAYOUT_LINK_PATTERN.matcher(url.asText().trim()).matches()) {
throw new BizException(ErrorCode.PARAM_ERROR,
what + " url 须为 http(s):// 地址或站内路径(/ 开头)");
}
}
/**
* custom_css 校验(B3,服务端强校验):长度 ≤10240;黑名单忽略大小写拒绝
* {@code </style}(逃逸内联 style 块)、{@code <script}、{@code @import}(外链样式注入)、
@@ -36,6 +36,13 @@ public class ThemeInfo {
private List<ThemeParam> params = new ArrayList<>();
/**
* 包级参数值(工作项 T:参数微调从站点级上移为皮肤包级)。
* 值不写 jar 内 theme.json,一律落 {@code {cms.theme.dir}/.meta/{name}.json} 覆盖层,
* 由 {@link ThemeRegistry#applyMetaOverride} 在装载时叠加;缺项回落 params[].defaultValue。
*/
private Map<String, String> paramValues = new LinkedHashMap<>();
public String screenshotPath() {
return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot;
}
@@ -24,6 +24,7 @@ import java.util.Arrays;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
@@ -306,6 +307,7 @@ public class ThemePackageService {
root.put("description", description);
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
copyMetaParamValues(sourceName, newName);
themeRegistry.reload();
publishThemeChanged();
log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName);
@@ -318,6 +320,28 @@ public class ThemePackageService {
}
}
/**
* 派生时复制源包的 paramValues(v1 §3.4):参数值存 <code>.meta/{name}.json</code> 覆盖层、
* 不在包目录内,复制包体天然带不过去 → 不显式带则新包回落 theme.json 默认值,
* "复制包做差异化" 就得重调一遍参数。仅复制 paramValues 节点(title/description 由派生表单写入新包 theme.json)。
*/
private void copyMetaParamValues(String sourceName, String newName) throws IOException {
Path source = Paths.get(themeDir, ".meta", sourceName + ".json");
if (!Files.isRegularFile(source)) {
return;
}
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.JsonNode values = mapper.readTree(source.toFile()).path("paramValues");
if (!values.isObject() || values.size() == 0) {
return;
}
Path target = Paths.get(themeDir, ".meta", newName + ".json");
Files.createDirectories(target.getParent());
com.fasterxml.jackson.databind.node.ObjectNode root = mapper.createObjectNode();
root.set("paramValues", values);
Files.write(target, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
/** 内置包(可能在 jar 内)复制:PathMatchingResourcePatternResolver 遍历其全部资源 */
private void copyClasspathPackage(String sourceName, Path target) throws IOException {
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
@@ -354,9 +378,25 @@ public class ThemePackageService {
/**
* 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读,
* 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。
* 兼容签名:不改包级参数值。
*/
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> params) {
return updateMeta(user, name, title, description, params, null);
}
/**
* 元数据 + 包级参数值编辑(工作项 T:参数微调从站点级迁到皮肤包级)。
*
* <p>title/description/params 语义与任务四一致(内置包 schema 拒改);{@code paramValues} 则
* <b>内置包与外部包统一写 .meta 覆盖层</b>——值不属于包体内容,包体(theme.json + 模板 + 资产)
* 保持只读语义不变,「同包不同配色」的差异化仍由复制皮肤包承担。
*
* @param paramValues null=不改;非 null=整体替换(空 Map=清空、恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃(同批改 schema 时以新 schema 为准)
*/
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> params, Map<String, String> paramValues) {
requireSuperAdmin(user);
if (!themeRegistry.contains(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
@@ -365,6 +405,15 @@ public class ThemePackageService {
if (!external && params != null) {
throw new BizException(ErrorCode.PARAM_ERROR, "内置包参数 schema 不可修改(仅显示名称/描述可改)");
}
Map<String, String> filteredValues = null;
if (paramValues != null) {
ThemeInfo schema = themeRegistry.get(name);
if (external && params != null) {
schema = new ThemeInfo();
schema.setParams(params);
}
filteredValues = schema.filterValidValues(paramValues);
}
try {
if (external) {
Path json = Paths.get(themeDir, name, "theme.json");
@@ -388,8 +437,11 @@ public class ThemePackageService {
root.set("params", mapper.valueToTree(params));
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
if (filteredValues != null) {
writeMetaOverride(name, null, null, filteredValues);
}
} else {
writeBuiltinMetaOverride(name, title, description);
writeMetaOverride(name, title, description, filteredValues);
}
themeRegistry.reload();
publishThemeChanged();
@@ -402,12 +454,16 @@ public class ThemePackageService {
}
/**
* 内置包元数据覆盖层写入(任务四):title/description 全空 = 删除覆盖层(恢复默认),
* 否则合并写入 {themeDir}/.meta/{name}.json(title 空且 description 非空时保留已有 title)。
* .meta 覆盖层写入:title/description/paramValues 合并进 {themeDir}/.meta/{name}.json。
* 三者全空且未提交 paramValues(即任务四的"恢复默认"手势)→ 删除覆盖层;
* 显式提交空 paramValues 只摘掉 paramValues 节点,保留覆盖层里既有的 title/description。
*/
private void writeBuiltinMetaOverride(String name, String title, String description) throws IOException {
private void writeMetaOverride(String name, String title, String description,
Map<String, String> paramValues) throws IOException {
Path overlay = Paths.get(themeDir, ".meta", name + ".json");
if (!StringUtils.hasText(title) && (description == null || description.trim().isEmpty())) {
boolean noTitle = !StringUtils.hasText(title);
boolean noDesc = description == null || description.trim().isEmpty();
if (noTitle && noDesc && paramValues == null) {
try {
Files.deleteIfExists(overlay);
} catch (IOException e) {
@@ -433,6 +489,13 @@ public class ThemePackageService {
if (description != null) {
root.put("description", description);
}
if (paramValues != null) {
if (paramValues.isEmpty()) {
root.remove("paramValues");
} else {
root.set("paramValues", mapper.valueToTree(paramValues));
}
}
Files.createDirectories(overlay.getParent());
Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
@@ -1,22 +1,40 @@
package com.yunhai.cms.theme;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 主题包规范校验(W1 重构点,方案 §5.5:校验语义收敛于本类,Registry 与 PackageService 共用,
* 杜绝两套校验漂移)。原 ThemeRegistry.validate 的规则原文平移:
* specVersion=1、name 规范、name=目录名、必备模板(home/list/detail/single)、
* fragments/head.html、param 规范、screenshot 存在。
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1)。
* fragments/head.html、首页区块引用一致性(§2.5 坑⑤)、param 规范、screenshot 存在。
* existsFn 判定资源存在:classpath 包用 ClassPathResource、外部包用 Files.exists(§5.4 步骤 1);
* readFn 读资源文本(UTF-8),读不到返回 null(不臆断,交由存在性校验与模板引擎兜底)。
*/
public final class ThemePackageValidator {
/** 本包首页区块模板(自研形态承载体) */
private static final String HOME_BLOCKS = "fragments/home-blocks.html";
/** Thymeleaf 模板引用对:~{<路径表达式> :: <fragment>( */
private static final Pattern TEMPLATE_REF =
Pattern.compile("~\\{([\\s\\S]*?)\\s*::\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*\\(");
/** fragment 定义:th:fragment="name(...)"(空格/逗号分隔的多签名一律取标识符) */
private static final Pattern FRAGMENT_DEF = Pattern.compile("th:fragment\\s*=\\s*\"([^\"]*)\"");
private ThemePackageValidator() {
}
/** 校验不通过抛 IllegalArgumentException(bad package 语义,Registry 剔除 + log.warn) */
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn) {
public static void validate(ThemeInfo info, String dirName, Predicate<String> existsFn,
Function<String, String> readFn) {
if (info == null) {
throw new IllegalArgumentException("清单为空");
}
@@ -38,6 +56,7 @@ public final class ThemePackageValidator {
if (!existsFn.test("fragments/head.html")) {
throw new IllegalArgumentException("缺 fragments/head.html 公共片段");
}
validateHomeBlockFragmentRefs(dirName, readFn);
validateParams(info);
String shot = info.screenshotPath();
if (!existsFn.test(shot)) {
@@ -72,4 +91,75 @@ public final class ThemePackageValidator {
}
}
}
/**
* 首页区块门禁(v2 手册 §2.5 坑⑤):home-blocks **不是**包的必需项——B 档包整段走
* themes/common 兜底骨架属正常形态,强制"8 形态齐全"会把它们剔除并静默回落 default(重大事故)。
* 只校验**引用一致性**:home.html 每把一种形态分发给本包 fragments/home-blocks
* (${themeBase} 拼接或 themes/&lt;目录名&gt;/ 字面两种写法),该文件就必须存在并定义被引用的
* fragment,否则门户首页渲染整页 500。缺项清单写进异常消息,由 Registry 剔除分支 log.warn 带出。
*/
static void validateHomeBlockFragmentRefs(String dirName, Function<String, String> readFn) {
String home = readFn.apply("home.html");
if (home == null || home.isEmpty()) {
return;
}
List<String> referenced = ownHomeBlockRefs(home, dirName);
if (referenced.isEmpty()) {
return;
}
String blocks = readFn.apply(HOME_BLOCKS);
if (blocks == null) {
throw new IllegalArgumentException("home.html 把区块分发给本包 " + HOME_BLOCKS
+ ",但该文件不存在(引用形态:" + join(referenced) + ")");
}
Set<String> defined = fragmentNamesOf(blocks);
List<String> missing = new ArrayList<>();
for (String name : referenced) {
if (!defined.contains(name)) {
missing.add(name);
}
}
if (!missing.isEmpty()) {
throw new IllegalArgumentException("本包 " + HOME_BLOCKS + " 缺 fragment 定义:" + join(missing)
+ "(home.html 已引用这些形态,门户首页渲染会整页 500)");
}
}
/** home.html 中指向**本包** home-blocks 的 fragment 名(按出现顺序去重;common 兜底引用不计入) */
static List<String> ownHomeBlockRefs(String homeHtml, String dirName) {
List<String> names = new ArrayList<>();
Matcher matcher = TEMPLATE_REF.matcher(homeHtml);
while (matcher.find()) {
String target = matcher.group(1);
if (!target.contains("/fragments/home-blocks") || target.contains("themes/common/")) {
continue;
}
boolean ownPackage = target.contains("themeBase") || target.contains("themes/" + dirName + "/");
String fragment = matcher.group(2);
if (ownPackage && !names.contains(fragment)) {
names.add(fragment);
}
}
return names;
}
/** 模板文本内声明的全部 fragment 名 */
static Set<String> fragmentNamesOf(String templateText) {
Set<String> names = new HashSet<>();
Matcher matcher = FRAGMENT_DEF.matcher(templateText);
while (matcher.find()) {
for (String signature : matcher.group(1).trim().split("[\\s,]+")) {
String name = signature.split("\\(")[0].trim();
if (!name.isEmpty()) {
names.add(name);
}
}
}
return names;
}
private static String join(List<String> names) {
return String.join("、", names);
}
}
@@ -9,9 +9,12 @@ 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;
@@ -113,8 +116,9 @@ public class ThemeRegistry {
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());
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);
@@ -130,7 +134,9 @@ public class ThemeRegistry {
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)));
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;
@@ -222,6 +228,24 @@ public class ThemeRegistry {
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) */
@@ -235,8 +259,8 @@ public class ThemeRegistry {
}
/**
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description——
* 内置包 theme.json 在 jar 内只读,显示名/描述改名走此通道;损坏的覆盖层忽略(按原始元数据注册)。
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description/paramValues——
* 内置包 theme.json 在 jar 内只读,显示名/描述改名与包级参数值均走此通道;损坏的覆盖层忽略(按原始元数据注册)。
*/
private void applyMetaOverride(ThemeInfo info) {
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
@@ -254,11 +278,26 @@ public class ThemeRegistry {
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<String, String> 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<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
if (themeDir == null || themeDir.isEmpty()) {