feat(theme): S5 学院蓝主题改造——home-blocks/service/guide页+区块CSS+ThemeRegistry增强
build / build-push (push) Successful in 53s
build / build-push (push) Successful in 53s
This commit is contained in:
@@ -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/<目录名>/ 字面两种写法),该文件就必须存在并定义被引用的
|
||||
* 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()) {
|
||||
|
||||
Reference in New Issue
Block a user