diff --git a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java index 95e7291..ff62ddd 100644 --- a/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java +++ b/src/main/java/com/yunhai/svchall/service/impl/ServiceItemService.java @@ -146,7 +146,12 @@ public class ServiceItemService { public SvcItemEditVo adminGet(BoaUser user, String siteCode, Integer id) { SiteInfo site = siteGuard.requireSiteManager(user, siteCode); - return SvcItemEditVo.of(requireItem(site, id)); + ServiceItem item = requireItem(site, id); + SvcItemEditVo vo = SvcItemEditVo.of(item); + // 封面出参绝对化(与他端列表/详情口径一致),否则管理端编辑页 拿相对路径 404,看着像"封面没保存上"; + // 保存侧 toRelativeIfLocal 会再相对化,往返幂等 + vo.setCoverImage(FileUtil.absolutizePlain(item.getCoverImage())); + return vo; } /** 保存:itemCode 唯一/字典键归属/部门快照/applyUrl 白名单/表单与流程绑定校验;指南附件全量重绑 */ @@ -801,13 +806,13 @@ public class ServiceItemService { return StringUtils.hasText(value) ? value : null; } - /** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径) */ + /** 封面:本应用上传的相对路径原样入库;外部完整 URL 截取 /upload/ 相对段(与附件同口径);折叠历史叠加段自愈 */ private static String toRelativeIfLocal(String coverImage) { if (!StringUtils.hasText(coverImage)) { return null; } int idx = coverImage.indexOf("/upload/"); - return idx >= 0 ? coverImage.substring(idx) : coverImage; + return idx >= 0 ? FileUtil.squashDuplicateUploadSeg(coverImage.substring(idx)) : coverImage; } private static Map countMap(List rows) { diff --git a/src/main/java/com/yunhai/svchall/utils/FileUtil.java b/src/main/java/com/yunhai/svchall/utils/FileUtil.java index 0521240..89ce0f8 100644 --- a/src/main/java/com/yunhai/svchall/utils/FileUtil.java +++ b/src/main/java/com/yunhai/svchall/utils/FileUtil.java @@ -80,7 +80,23 @@ public final class FileUtil { } String base = publicBaseUrl.endsWith("/") ? publicBaseUrl : publicBaseUrl + "/"; String rel = relativeUrl.startsWith("/") ? relativeUrl.substring(1) : relativeUrl; - return base + rel; + return squashDuplicateUploadSeg(base + rel); + } + + /** + * 折叠连续 {@code /upload/} 叠加段。存储布局是 {@code /upload/{image|file}/uuid.ext}, + * "upload" 不是合法类型名,{@code /upload/upload/} 必为历史损伤:生产 public-base-url 以 + * /upload/ 结尾而库内相对值以 /upload/ 开头,旧拼接每经一轮"绝对化→存库"多叠一层 + * (实测部署态封面叠到四层致门户裂图)。出口统一折叠即可对已损伤库值自愈。 + */ + public static String squashDuplicateUploadSeg(String url) { + if (url == null || url.isEmpty()) { + return url; + } + while (url.contains("/upload/upload/")) { + url = url.replace("/upload/upload/", "/upload/"); + } + return url; } /** @@ -93,10 +109,11 @@ public final class FileUtil { return html; } String base = publicBaseUrl.endsWith("/") ? publicBaseUrl : publicBaseUrl + "/"; - return html.replace("src=\"/upload/", "src=\"" + base + "upload/") + String replaced = html.replace("src=\"/upload/", "src=\"" + base + "upload/") .replace("href=\"/upload/", "href=\"" + base + "upload/") .replace("src='/upload/", "src='" + base + "upload/") .replace("href='/upload/", "href='" + base + "upload/"); + return squashDuplicateUploadSeg(replaced); } /** 裸相对封面值 → 完整 URL(空值原样;绝对地址原样) */ diff --git a/src/test/java/com/yunhai/svchall/utils/FileUtilTest.java b/src/test/java/com/yunhai/svchall/utils/FileUtilTest.java new file mode 100644 index 0000000..e5e384e --- /dev/null +++ b/src/test/java/com/yunhai/svchall/utils/FileUtilTest.java @@ -0,0 +1,102 @@ +package com.yunhai.svchall.utils; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 文件 URL 拼接契约单测(2026-09-20 封面裂图修复): + * 生产 public-base-url 以 /upload/ 结尾(https://base.yunhaixingtong.com/svc-hall/upload/), + * 而库内相对值以 /upload/ 开头——拼接必须不产生 /upload/upload/ 叠加, + * 且对历史已叠加损伤值(/upload/upload/…,实测库里叠了三层导致门户裂图)具备折叠自愈能力。 + * 不变式:存储布局 /upload/{image|file}/uuid.ext,upload 不是合法类型名,/upload/upload/ 永远非法。 + */ +@DisplayName("FileUtil - 文件 URL 拼接与 /upload/ 段折叠") +class FileUtilTest { + + /** 生产形态:带 ctx 且以 /upload/ 结尾 */ + private static final String PROD_BASE = "http://base.yunhaixingtong.com/svc-hall/upload/"; + /** 本地形态:裸 host 以 /upload/ 结尾 */ + private static final String LOCAL_BASE = "http://localhost:8086/upload/"; + + @AfterEach + void reset() { + FileUtil.init("target/test-fileutil-root", "http://localhost:8086/upload/"); + } + + @Test + @DisplayName("buildUrl:生产 base+/upload/ 结尾,干净相对值不叠加") + void buildUrlProdBaseNoDouble() { + FileUtil.init("target/test-fileutil-root", PROD_BASE); + assertThat(FileUtil.buildUrl("/upload/image/e7df.png")) + .isEqualTo("http://base.yunhaixingtong.com/svc-hall/upload/image/e7df.png"); + } + + @Test + @DisplayName("buildUrl:本地 base+/upload/ 结尾,干净相对值不叠加") + void buildUrlLocalBaseNoDouble() { + FileUtil.init("target/test-fileutil-root", LOCAL_BASE); + assertThat(FileUtil.buildUrl("/upload/file/abc.pdf")) + .isEqualTo("http://localhost:8086/upload/file/abc.pdf"); + } + + @Test + @DisplayName("buildUrl:base 不带 /upload/ 尾时行为不变") + void buildUrlBaseWithoutUploadTail() { + FileUtil.init("target/test-fileutil-root", "http://localhost:8086"); + assertThat(FileUtil.buildUrl("/upload/image/a.png")) + .isEqualTo("http://localhost:8086/upload/image/a.png"); + } + + @Test + @DisplayName("buildUrl:历史损伤相对值(叠三层)出口折叠自愈——部署态门户裂图修复点") + void buildUrlSquashesCorruptedRelative() { + FileUtil.init("target/test-fileutil-root", PROD_BASE); + assertThat(FileUtil.buildUrl("/upload/upload/upload/image/e7df.png")) + .isEqualTo("http://base.yunhaixingtong.com/svc-hall/upload/image/e7df.png"); + } + + @Test + @DisplayName("absolutizePlain:绝对地址原样返回(含历史损伤值,不篡改外链)") + void absolutizePlainPassthrough() { + FileUtil.init("target/test-fileutil-root", PROD_BASE); + String external = "https://cdn.example.com/pic.png"; + assertThat(FileUtil.absolutizePlain(external)).isEqualTo(external); + } + + @Test + @DisplayName("absolutize:富文本内链绝对化不叠加 /upload/") + void absolutizeHtmlNoDouble() { + FileUtil.init("target/test-fileutil-root", PROD_BASE); + String html = "

§附件

"; + String out = FileUtil.absolutize(html); + assertThat(out).contains("src=\"http://base.yunhaixingtong.com/svc-hall/upload/image/a.png\""); + assertThat(out).contains("href=\"http://base.yunhaixingtong.com/svc-hall/upload/file/b.pdf\""); + assertThat(out).doesNotContain("/upload/upload/"); + } + + @Test + @DisplayName("squashDuplicateUploadSeg:任意层数叠加收敛为一层") + void squashCollapsesAnyDepth() { + assertThat(FileUtil.squashDuplicateUploadSeg("/upload/upload/upload/upload/image/x.png")) + .isEqualTo("/upload/image/x.png"); + assertThat(FileUtil.squashDuplicateUploadSeg("/upload/image/ok.png")) + .isEqualTo("/upload/image/ok.png"); + assertThat(FileUtil.squashDuplicateUploadSeg(null)).isNull(); + } + + @Test + @DisplayName("往返幂等:绝对化 → 存库相对化 → 再绝对化,值稳定不增长") + void roundTripIdempotent() { + FileUtil.init("target/test-fileutil-root", PROD_BASE); + String stored = "/upload/image/e7df.png"; + String out1 = FileUtil.absolutizePlain(stored); + // 模拟保存侧:取第一个 /upload/ 起的相对段(ServiceItemService.toRelativeIfLocal 同款逻辑) + String back = out1.substring(out1.indexOf("/upload/")); + String out2 = FileUtil.absolutizePlain(back); + assertThat(out2).isEqualTo(out1); + assertThat(out2).doesNotContain("/upload/upload/"); + } +}