diff --git a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java index 7be2901..b0ba184 100644 --- a/src/main/java/com/yunhai/cms/controller/api/ThemeController.java +++ b/src/main/java/com/yunhai/cms/controller/api/ThemeController.java @@ -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); } diff --git a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java index 6f8483b..b071ede 100644 --- a/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java +++ b/src/main/java/com/yunhai/cms/controller/portal/PortalPageRenderer.java @@ -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 buildQuickLinksBlock(Map spec) { + List> 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 link = new HashMap<>(); + link.put("name", name); + link.put("url", linkUrl); + links.add(link); + } + } + Map block = baseBlock(spec, "quick_links", "快捷通道"); + block.put("quickLinks", links); + return block; + } + + /** 公告条(v2 手册 §2.8 类型 B):站点单条手填通告;level 白名单外归一 info,url 不合法则退化为纯文本 */ + private Map buildNoticeBarBlock(Map spec) { + String linkUrl = str(spec.get("url"), "").trim(); + if (!LAYOUT_LINK_URL.matcher(linkUrl).matches()) { + linkUrl = ""; + } + Map 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) { diff --git a/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java b/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java index f651ed7..78e030c 100644 --- a/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java +++ b/src/main/java/com/yunhai/cms/model/dto/SiteSaveRequest.java @@ -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 themeParams; /** 0=停用 1=启用 */ diff --git a/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java b/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java index 40bff9f..f15027b 100644 --- a/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java +++ b/src/main/java/com/yunhai/cms/model/dto/ThemeMetaRequest.java @@ -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 params; + + /** + * 包级参数值(工作项 T:参数微调迁皮肤包级):null=不改,非 null=整体替换(空 Map=恢复 theme.json 默认值); + * 未声明 key 与非法值按包 schema 丢弃;内置包与外部包统一写 .meta 覆盖层。 + */ + private Map paramValues; } diff --git a/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java b/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java index d4a79e6..9823667 100644 --- a/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java +++ b/src/main/java/com/yunhai/cms/model/vo/ThemeVo.java @@ -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 params; + /** + * 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key; + * 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。 + */ + private Map 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; } } diff --git a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java index 65646a6..e4a29d9 100644 --- a/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java +++ b/src/main/java/com/yunhai/cms/service/impl/PortalQueryService.java @@ -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()); } } diff --git a/src/main/java/com/yunhai/cms/service/impl/SiteService.java b/src/main/java/com/yunhai/cms/service/impl/SiteService.java index 3d5126e..eb15552 100644 --- a/src/main/java/com/yunhai/cms/service/impl/SiteService.java +++ b/src/main/java/com/yunhai/cms/service/impl/SiteService.java @@ -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 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 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 config = new java.util.LinkedHashMap<>(ExtJsonUtils.parse(req.getConfigJson())); - Map 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 params = new ArrayList<>(); + /** + * 包级参数值(工作项 T:参数微调从站点级上移为皮肤包级)。 + * 值不写 jar 内 theme.json,一律落 {@code {cms.theme.dir}/.meta/{name}.json} 覆盖层, + * 由 {@link ThemeRegistry#applyMetaOverride} 在装载时叠加;缺项回落 params[].defaultValue。 + */ + private Map paramValues = new LinkedHashMap<>(); + public String screenshotPath() { return screenshot == null || screenshot.isEmpty() ? "assets/screenshot.png" : screenshot; } diff --git a/src/main/java/com/yunhai/cms/theme/ThemePackageService.java b/src/main/java/com/yunhai/cms/theme/ThemePackageService.java index c980557..f88dfc8 100644 --- a/src/main/java/com/yunhai/cms/theme/ThemePackageService.java +++ b/src/main/java/com/yunhai/cms/theme/ThemePackageService.java @@ -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):参数值存 .meta/{name}.json 覆盖层、 + * 不在包目录内,复制包体天然带不过去 → 不显式带则新包回落 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 params) { + return updateMeta(user, name, title, description, params, null); + } + + /** + * 元数据 + 包级参数值编辑(工作项 T:参数微调从站点级迁到皮肤包级)。 + * + *

title/description/params 语义与任务四一致(内置包 schema 拒改);{@code paramValues} 则 + * 内置包与外部包统一写 .meta 覆盖层——值不属于包体内容,包体(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 params, Map 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 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 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)); } diff --git a/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java b/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java index 4cbdd9e..ded426a 100644 --- a/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java +++ b/src/main/java/com/yunhai/cms/theme/ThemePackageValidator.java @@ -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 模板引用对:~{<路径表达式> :: ( */ + 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 existsFn) { + public static void validate(ThemeInfo info, String dirName, Predicate existsFn, + Function 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 readFn) { + String home = readFn.apply("home.html"); + if (home == null || home.isEmpty()) { + return; + } + List 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 defined = fragmentNamesOf(blocks); + List 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 ownHomeBlockRefs(String homeHtml, String dirName) { + List 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 fragmentNamesOf(String templateText) { + Set 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 names) { + return String.join("、", names); + } } diff --git a/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java b/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java index e0e9ef5..903f95c 100644 --- a/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java +++ b/src/main/java/com/yunhai/cms/theme/ThemeRegistry.java @@ -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 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 scanExternalPackages() { List packages = new ArrayList<>(); if (themeDir == null || themeDir.isEmpty()) { diff --git a/src/main/resources/templates/themes/common/fragments/home-blocks.html b/src/main/resources/templates/themes/common/fragments/home-blocks.html index 54dd804..e8c6665 100644 --- a/src/main/resources/templates/themes/common/fragments/home-blocks.html +++ b/src/main/resources/templates/themes/common/fragments/home-blocks.html @@ -4,7 +4,7 @@ 样式随 fragment 内联携带——themes/common 不注册为皮肤包(无 theme.json),主题资产通道不服务该目录; 颜色回落站点主题变量(--primary 等 themeVarsCss 注入),无变量时用内置缺省。 契约:block 为渲染端组装的同构 Map——type/title/category.name/homeStyle/items/moreUrl/subtitle/ - background/source/linkGroups,与回落模式 categoryBlocks 的块字段兼容。 + background/source/linkGroups/quickLinks/noticeText/noticeUrl/noticeLevel,与回落模式 categoryBlocks 的块字段兼容。 -->