feat(theme): 皮肤包管理收敛与命名批次-兜底catch经wrapFailure先log.error记原始堆栈再包装(upload/derive/updateMeta三处,extractZip/deleteRecursively静默吞改warn/debug);内置包改显示名走.meta覆盖层(ThemeRegistry加载后覆写title/description,updateMeta放开内置:全空=恢复默认,params拒改);derive/upload支持表单命名(theme.json改写regex换ObjectMapper防误替换,upload落位前改staging失败不落位);resolveTheme回退warn补站点编码;ThemeVo.editable语义改为可编辑元数据;附内置包mint-breeze/fresh-mint2与R1学校主站编码迁移SQL;308/308全绿
build / build-push (push) Successful in 48s

This commit is contained in:
chenxing
2026-09-08 20:25:43 +08:00
parent 12a492fc55
commit a666e13f56
39 changed files with 2343 additions and 72 deletions
@@ -48,27 +48,34 @@ public class ThemeController {
return themeService.list();
}
/** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册 */
/** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册。
* 任务四:title/description 选填,落位前改写包内 theme.json(留空沿用包内声明) */
@PostMapping("/upload")
public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) {
ThemeInfo info = themePackageService.upload(authUserWithDept, file);
public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "title", required = false) String title,
@RequestParam(value = "description", required = false) String description) {
ThemeInfo info = themePackageService.upload(authUserWithDept, file, title, description);
return themeService.voOf(info);
}
/** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除) */
/** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除);任务四支持同步命名 */
@PostMapping("/derive")
public com.yunhai.cms.model.vo.ThemeVo derive(BoaUser authUserWithDept,
@RequestBody com.yunhai.cms.model.dto.ThemeDeriveRequest req) {
ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName());
ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName(),
req.getTitle(), req.getDescription());
return themeService.voOf(info);
}
/** W1(G4):编辑外部包 theme.json 的 title 与参数 schema(保存后引用站点立即生效) */
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效 */
@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.getParams());
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
req.getTitle(), req.getDescription(), req.getParams());
return themeService.voOf(info);
}
@@ -5,7 +5,7 @@ import lombok.Data;
import java.util.List;
/** W1 皮肤包派生请求(POST /api/theme/derive) */
/** W1 皮肤包派生请求(POST /api/theme/derive);任务四增选填 title/description(写入新包 theme.json) */
@Data
public class ThemeDeriveRequest {
@@ -14,4 +14,10 @@ public class ThemeDeriveRequest {
/** 新包名(外部包目录名,theme.json name 同步改写) */
private String name;
/** 显示名称(选填;留空沿用源包 title) */
private String title;
/** 描述(选填;null=沿用源包,""=清空) */
private String description;
}
@@ -5,12 +5,15 @@ import lombok.Data;
import java.util.List;
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta):title/参数 schema,保留其余 theme.json 字段 */
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */
@Data
public class ThemeMetaRequest {
private String title;
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改 */
/** 描述(null=不改;""=清空) */
private String description;
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */
private List<ThemeParam> params;
}
@@ -75,7 +75,7 @@ public class PortalQueryService {
return null;
}
PortalSiteSnapshot snapshot = PortalSiteSnapshot.of(site);
snapshot.setTheme(resolveTheme(snapshot.getTheme()));
snapshot.setTheme(resolveTheme(snapshot.getTheme(), snapshot.getCode()));
derivePublicBaseUrl(site, snapshot);
// 微调参数注入(方案 §5.3):按已解析主题 schema 过滤,回退主题与参数过滤天然一致
com.yunhai.cms.theme.ThemeInfo themeInfo = themeRegistry.get(snapshot.getTheme());
@@ -279,11 +279,11 @@ public class PortalQueryService {
}
/** 皮肤包存在性检查:委托 ThemeRegistry(theme.json + 4 模板齐全校验,方案 §5.1 重构,缺省回退 default) */
private String resolveTheme(String theme) {
private String resolveTheme(String theme, String siteCode) {
if (themeRegistry.contains(theme)) {
return theme;
}
log.warn("皮肤包 {} 未注册,回退 default", theme);
log.warn("皮肤包 {} 未注册(site={}),回退 default", theme, siteCode);
return com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME;
}
@@ -43,7 +43,8 @@ public class ThemeService {
usedNames.add(s.getName());
}
vo.setUsedBySiteNames(usedNames);
vo.setEditable(ThemeRegistry.SOURCE_EXTERNAL.equals(source));
// 任务四:语义改为「可编辑元数据」——内置包亦可改显示名/描述(.meta 覆盖层),内容仍只读
vo.setEditable(true);
return vo;
}
@@ -74,7 +74,7 @@ public class ThemePackageService {
// ===== 上传(T-W1-05..12)=====
public ThemeInfo upload(BoaUser user, MultipartFile file) {
public ThemeInfo upload(BoaUser user, MultipartFile file, String title, String description) {
requireSuperAdmin(user);
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件为空");
@@ -108,6 +108,8 @@ public class ThemePackageService {
if (!report.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包校验未通过:" + String.join(";", report));
}
// 上传表单命名(任务四):落位前改写 staging 内 theme.json,失败即整体失败不落位
applyStagedNaming(rootDir == null ? staging : staging.resolve(rootDir), title, description);
// 原子替换/落位(同名外部包=覆盖更新,T-W1-12)
Path target = Paths.get(themeDir, pkgName);
if (Files.exists(target)) {
@@ -122,7 +124,7 @@ public class ThemePackageService {
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包处理失败:" + e.getMessage());
throw wrapFailure("上传", "皮肤包处理失败:", e);
} finally {
if (staging != null) {
deleteRecursively(staging);
@@ -142,9 +144,26 @@ public class ThemePackageService {
return null;
}
/** 上传表单命名(任务四):落位前改写 staging 内 theme.json 的 title/description(空值跳过对应字段) */
private void applyStagedNaming(Path pkgDir, String title, String description) throws IOException {
if (!StringUtils.hasText(title) && description == null) {
return;
}
Path json = pkgDir.resolve("theme.json");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
if (StringUtils.hasText(title)) {
root.put("title", title.trim());
}
if (description != null) {
root.put("description", description);
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
/** 解包到 staging;返回 theme.json 声明的 name(防御:zip-slip/条目数/体积/扩展名白名单) */
private String extractZip(MultipartFile file, Path staging, List<String> report) throws IOException {
String declaredName = null;
private String extractZip(MultipartFile file, Path staging, List<String> report) throws IOException { String declaredName = null;
int entries = 0;
long total = 0;
try (ZipInputStream zip = new ZipInputStream(file.getInputStream())) {
@@ -198,6 +217,7 @@ public class ThemePackageService {
declaredName = new com.fasterxml.jackson.databind.ObjectMapper()
.readTree(json).path("name").asText(null);
} catch (Exception e) {
log.warn("zip 内 theme.json 解析失败,忽略声明包名:{}", e.getMessage());
declaredName = null;
}
}
@@ -253,7 +273,7 @@ public class ThemePackageService {
// ===== 派生(T-W1-17)=====
public ThemeInfo derive(BoaUser user, String sourceName, String newName) {
public ThemeInfo derive(BoaUser user, String sourceName, String newName, String title, String description) {
requireSuperAdmin(user);
if (!themeRegistry.contains(sourceName)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + sourceName);
@@ -272,12 +292,20 @@ public class ThemePackageService {
} else {
copyClasspathPackage(sourceName, target);
}
// 改写 theme.json:name=newName(title 追加副名,其余保留)
// 改写 theme.json(任务四「添加皮肤包」表单):name=newName;显示名/描述按表单覆盖。
// ObjectNode 写回取代旧 regex replaceAll——源包名出现在其他字段值(如描述)时不再误替换
Path json = target.resolve("theme.json");
String content = new String(Files.readAllBytes(json), java.nio.charset.StandardCharsets.UTF_8);
content = content.replaceAll("(\"name\"\\s*:\\s*\")" + sourceName + "(\")",
"$1" + newName + "$2");
Files.write(json, content.getBytes(java.nio.charset.StandardCharsets.UTF_8));
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
root.put("name", newName);
if (StringUtils.hasText(title)) {
root.put("title", title.trim());
}
if (description != null) {
root.put("description", description);
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
themeRegistry.reload();
publishThemeChanged();
log.info("皮肤包 {} 派生为 {}(外部包)", sourceName, newName);
@@ -286,7 +314,7 @@ public class ThemePackageService {
throw e;
} catch (Exception e) {
deleteRecursively(target);
throw new BizException(ErrorCode.PARAM_ERROR, "派生皮肤包失败:" + e.getMessage());
throw wrapFailure("派生", "派生皮肤包失败:", e);
}
}
@@ -321,46 +349,94 @@ public class ThemePackageService {
}
}
// ===== 编辑(T-W1-17:title/参数 schema;保留 specVersion/模板清单字段)=====
// ===== 编辑(T-W1-17 + 任务四:title/描述/参数 schema;保留 specVersion/模板清单字段)=====
public ThemeInfo updateMeta(BoaUser user, String name, String title, List<ThemeParam> params) {
/**
* 元数据编辑(任务四放开内置包):外部包直写 theme.json;内置包 theme.json 在 jar 内只读,
* 显示名/描述写 {@link ThemeRegistry} 的 .meta 覆盖层。内容(模板/资产)两种来源都不可经此修改。
*/
public ThemeInfo updateMeta(BoaUser user, String name, String title, String description,
List<ThemeParam> params) {
requireSuperAdmin(user);
if (!themeRegistry.contains(name)) {
throw new BizException(ErrorCode.PARAM_ERROR, "皮肤包未注册:" + name);
}
if (!ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name))) {
throw new BizException(ErrorCode.FORBIDDEN, "内置包不可编辑(D1:内置只读)");
boolean external = ThemeRegistry.SOURCE_EXTERNAL.equals(themeRegistry.sourceOf(name));
if (!external && params != null) {
throw new BizException(ErrorCode.PARAM_ERROR, "内置包参数 schema 不可修改(仅显示名称/描述可改)");
}
Path json = Paths.get(themeDir, name, "theme.json");
try {
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
if (StringUtils.hasText(title)) {
root.put("title", title);
}
if (params != null) {
ThemeInfo probe = new ThemeInfo();
probe.setParams(params);
try {
ThemePackageValidator.validateParams(probe);
} catch (IllegalArgumentException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数 schema 非法:" + e.getMessage());
if (external) {
Path json = Paths.get(themeDir, name, "theme.json");
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root =
(com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(json.toFile());
if (StringUtils.hasText(title)) {
root.put("title", title);
}
root.set("params", mapper.valueToTree(params));
if (description != null) {
root.put("description", description);
}
if (params != null) {
ThemeInfo probe = new ThemeInfo();
probe.setParams(params);
try {
ThemePackageValidator.validateParams(probe);
} catch (IllegalArgumentException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "参数 schema 非法:" + e.getMessage());
}
root.set("params", mapper.valueToTree(params));
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
} else {
writeBuiltinMetaOverride(name, title, description);
}
Files.write(json, mapper.writerWithDefaultPrettyPrinter()
.writeValueAsBytes(root));
themeRegistry.reload();
publishThemeChanged();
return themeRegistry.get(name);
} catch (BizException e) {
throw e;
} catch (IOException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "theme.json 写入失败:" + e.getMessage());
throw wrapFailure("元数据写入", "theme.json 写入失败:", e);
}
}
/**
* 内置包元数据覆盖层写入(任务四):title/description 全空 = 删除覆盖层(恢复默认),
* 否则合并写入 {themeDir}/.meta/{name}.json(title 空且 description 非空时保留已有 title)。
*/
private void writeBuiltinMetaOverride(String name, String title, String description) throws IOException {
Path overlay = Paths.get(themeDir, ".meta", name + ".json");
if (!StringUtils.hasText(title) && (description == null || description.trim().isEmpty())) {
try {
Files.deleteIfExists(overlay);
} catch (IOException e) {
log.warn("元数据覆盖层 {} 删除失败:{}", overlay, e.getMessage());
}
return;
}
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
com.fasterxml.jackson.databind.node.ObjectNode root;
if (Files.exists(overlay)) {
try {
root = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(overlay.toFile());
} catch (Exception e) {
log.warn("元数据覆盖层 {} 损坏,重建:{}", overlay, e.getMessage());
root = mapper.createObjectNode();
}
} else {
root = mapper.createObjectNode();
}
if (StringUtils.hasText(title)) {
root.put("title", title.trim());
}
if (description != null) {
root.put("description", description);
}
Files.createDirectories(overlay.getParent());
Files.write(overlay, mapper.writerWithDefaultPrettyPrinter().writeValueAsBytes(root));
}
// ===== 删除(T-W1-15/16)=====
public void remove(BoaUser user, String name) {
@@ -381,6 +457,8 @@ public class ThemePackageService {
"皮肤包正被站点使用,无法删除:" + String.join("、", names));
}
deleteRecursively(Paths.get(themeDir, name));
// 元数据覆盖层一并清理(任务四;外部包通常没有,best-effort)
deleteRecursively(Paths.get(themeDir, ".meta", name + ".json"));
themeRegistry.reload();
publishThemeChanged();
log.info("皮肤包 {} 已删除并热注销", name);
@@ -395,6 +473,16 @@ public class ThemePackageService {
// ===== 内部 =====
/**
* 兜底异常包装(C 级修复):先以 error 级记录原始堆栈再转业务异常——
* 线上(/app 容器)只能看到接口文案,没有这一步则 FileSystemException 等
* 只剩裸路径无从排查。userMessage 保持既有文案格式(含冒号后缀)。
*/
static BizException wrapFailure(String logAction, String userMessage, Exception e) {
log.error("皮肤包{}失败", logAction, e);
return new BizException(ErrorCode.PARAM_ERROR, userMessage + e.getMessage());
}
private void requireSuperAdmin(BoaUser user) {
if (user == null || user.getType() == null || user.getType() != 1) {
throw new BizException(ErrorCode.FORBIDDEN, "仅平台超管可执行该操作");
@@ -452,10 +540,12 @@ public class ThemePackageService {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.delete(p);
} catch (IOException ignored) {
} catch (IOException e) {
log.debug("清理失败(best-effort):{}", p, e);
}
});
} catch (IOException ignored) {
} catch (IOException e) {
log.debug("清理失败(best-effort):{}", path, e);
}
}
}
@@ -50,6 +50,9 @@ public class ThemeRegistry {
private static final String SCAN_PATTERN = "classpath*:templates/themes/*/theme.json";
/** 元数据覆盖层目录(任务四):{cms.theme.dir}/.meta/{name}.json;外部包扫描按「含 theme.json 的目录」过滤,天然跳过 */
private static final String META_OVERRIDE_DIR = ".meta";
/** 未知字段忽略、已知字段兼容(方案 §4.2 specVersion 演进策略) */
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@@ -112,6 +115,7 @@ public class ThemeRegistry {
ThemeInfo info = MAPPER.readValue(resource.getInputStream(), ThemeInfo.class);
ThemePackageValidator.validate(info, dirName, rel ->
new ClassPathResource("templates/themes/" + dirName + "/" + rel).exists());
applyMetaOverride(info);
loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_BUILTIN);
} catch (Exception e) {
@@ -131,6 +135,7 @@ public class ThemeRegistry {
log.warn("外部主题包 {} 与内置包同名,忽略外部包(内置只读、优先,D1)", dirName);
continue;
}
applyMetaOverride(info);
loaded.put(info.getName(), info);
loadedSources.put(info.getName(), SOURCE_EXTERNAL);
} catch (Exception e) {
@@ -229,9 +234,33 @@ public class ThemeRegistry {
}
}
/**
* 元数据覆盖层(任务四):包加载后读 {themeDir}/.meta/{name}.json 覆写 title/description——
* 内置包 theme.json 在 jar 内只读,显示名/描述改名走此通道;损坏的覆盖层忽略(按原始元数据注册)。
*/
private void applyMetaOverride(ThemeInfo info) {
if (themeDir == null || themeDir.isEmpty() || info.getName() == null) {
return;
}
Path overrideFile = Paths.get(themeDir, META_OVERRIDE_DIR, info.getName() + ".json");
if (!Files.isRegularFile(overrideFile)) {
return;
}
try {
ThemeInfo meta = MAPPER.readValue(overrideFile.toFile(), ThemeInfo.class);
if (meta.getTitle() != null && !meta.getTitle().trim().isEmpty()) {
info.setTitle(meta.getTitle().trim());
}
if (meta.getDescription() != null) {
info.setDescription(meta.getDescription());
}
} catch (Exception e) {
log.warn("元数据覆盖层 {} 读取失败,按原始元数据注册:{}", overrideFile, e.getMessage());
}
}
/** W1:外部目录包扫描(themeDir 下各包的 theme.json;目录不存在视为空) */
protected List<Path> scanExternalPackages() {
List<Path> packages = new ArrayList<>();
protected List<Path> scanExternalPackages() { List<Path> packages = new ArrayList<>();
if (themeDir == null || themeDir.isEmpty()) {
return packages;
}