feat(W1): 皮肤包完整管理-外部主题目录+热注册,zip上传/派生/编辑/删除,ThemePackageValidator收敛,Thymeleaf文件解析器,list增source/inUseCount/editable/usedBySiteNames,T-W1-01..20 全绿299/299
build / build-push (push) Successful in 52s

This commit is contained in:
chenxing
2026-09-08 14:52:29 +08:00
parent 536bd6cea4
commit 338ffb56dd
16 changed files with 1548 additions and 71 deletions
@@ -152,4 +152,120 @@ class ThemeRegistryTest {
.as("旧 /upload/** 值应被过滤丢弃,不落库")
.doesNotContainKey("heroImage");
}
// ===== W1 外部主题目录(方案 §5.3 T-W1-01..04)=====
/** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */
private java.nio.file.Path writeExternalPackage(java.nio.file.Path dir, String name,
java.util.function.UnaryOperator<String> themeJsonMutator) throws Exception {
java.nio.file.Path pkg = dir.resolve(name);
java.nio.file.Files.createDirectories(pkg.resolve("assets"));
java.nio.file.Files.createDirectories(pkg.resolve("fragments"));
org.springframework.util.FileCopyUtils.copy(
new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/assets/screenshot.png").getInputStream(),
java.nio.file.Files.newOutputStream(pkg.resolve("assets").resolve("screenshot.png")));
for (String t : new String[] {"home.html", "list.html", "detail.html", "single.html"}) {
org.springframework.util.FileCopyUtils.copy(
new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/" + t).getInputStream(),
java.nio.file.Files.newOutputStream(pkg.resolve(t)));
}
org.springframework.util.FileCopyUtils.copy(
new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/fragments/head.html").getInputStream(),
java.nio.file.Files.newOutputStream(pkg.resolve("fragments").resolve("head.html")));
String json = org.springframework.util.FileCopyUtils.copyToString(
new java.io.InputStreamReader(new org.springframework.core.io.ClassPathResource(
"templates/themes/t-demo-red/theme.json").getInputStream(),
java.nio.charset.StandardCharsets.UTF_8));
json = json.replace("\"name\": \"t-demo-red\"", "\"name\": \"" + name + "\"")
.replace("\"name\":\"t-demo-red\"", "\"name\":\"" + name + "\"");
if (themeJsonMutator != null) {
json = themeJsonMutator.apply(json);
}
java.nio.file.Files.write(pkg.resolve("theme.json"), json.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return pkg;
}
@Test
@DisplayName("T-W1-01: 外部目录包被扫描注册(source=external)")
void externalDirPackageScanned() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
writeExternalPackage(dir, "t-ext-ok", null);
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("t-ext-ok")).isTrue();
assertThat(r.sourceOf("t-ext-ok")).isEqualTo("external");
assertThat(r.sourceOf("default")).isEqualTo("builtin");
deleteRecursively(dir);
}
@Test
@DisplayName("T-W1-02: 运行期放入新包 → reload() 热生效,无需重启")
void reloadPicksUpNewPackage() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("t-ext-late")).isFalse();
writeExternalPackage(dir, "t-ext-late", null);
r.reload();
assertThat(r.contains("t-ext-late")).isTrue();
deleteRecursively(dir);
}
@Test
@DisplayName("T-W1-03: 内置优先于同名外部(外部放 name=default 被拒且留 warn)")
void builtinOverridesExternalSameName() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
writeExternalPackage(dir, "default", null); // 试图用外部 default 覆盖
ThemeRegistry r = new ThemeRegistry();
r.setThemeDir(dir.toString());
r.load();
assertThat(r.contains("default")).isTrue();
assertThat(r.sourceOf("default")).as("注册结果仍为内置 default").isEqualTo("builtin");
deleteRecursively(dir);
}
@Test
@DisplayName("T-W1-04: reload 缺 default 保护——外部-only 场景破坏 default 后 reload 抛异常且旧集合保持")
void reloadWithoutDefaultKeepsOldSet() throws Exception {
java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes");
writeExternalPackage(dir, "t-ext-a", null);
writeExternalPackage(dir, "default", null);
// 外部-only 构造:屏蔽 classpath 扫描
ThemeRegistry r = new ThemeRegistry() {
@Override
protected java.util.List<org.springframework.core.io.Resource> scanClasspathResources() {
return java.util.Collections.emptyList();
}
};
r.setThemeDir(dir.toString());
r.load();
int before = r.list().size();
assertThat(r.contains("default")).isTrue();
deleteRecursively(dir.resolve("default"));
org.assertj.core.api.Assertions.assertThatThrownBy(r::reload)
.as("default 缺失时 reload 抛业务异常")
.isInstanceOf(IllegalStateException.class);
assertThat(r.list().size()).as("reload 失败不回滚旧集合").isEqualTo(before);
assertThat(r.contains("t-ext-a")).isTrue();
deleteRecursively(dir);
}
/** JDK8 递归删除(测试临时目录专用) */
private static void deleteRecursively(java.nio.file.Path path) {
if (!java.nio.file.Files.exists(path)) {
return;
}
try (java.util.stream.Stream<java.nio.file.Path> walk = java.nio.file.Files.walk(path)) {
walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
try {
java.nio.file.Files.delete(p);
} catch (java.io.IOException ignored) {
}
});
} catch (java.io.IOException ignored) {
}
}
}