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:
@@ -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}(外链样式注入)、
|
||||
|
||||
Reference in New Issue
Block a user