- 生产 public-base-url 以 /upload/ 结尾而库内相对值以 /upload/ 开头, buildUrl 盲拼每轮"绝对化→存库"多叠一层(部署态实测叠到 4 层 404)。 - FileUtil.squashDuplicateUploadSeg:/upload/upload/ 永远非法(upload 非 法类型名),buildUrl/absolutize 出口与 toRelativeIfLocal 存库侧统一折叠—— 发版即恢复显示无需清库,库内损伤值下次保存永久收敛。 - adminGet 封面回显绝对化:修管理端编辑页相对路径 404 被误当"没保存上"。 - 新增 FileUtilTest 8 用例锁行为(不叠加/损伤自愈/透传/往返幂等)。
This commit is contained in:
@@ -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);
|
||||
// 封面出参绝对化(与他端列表/详情口径一致),否则管理端编辑页 <img> 拿相对路径 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<String, Long> countMap(List<Object[]> rows) {
|
||||
|
||||
@@ -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(空值原样;绝对地址原样) */
|
||||
|
||||
@@ -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 = "<p><img src=\"/upload/image/a.png\">§<a href=\"/upload/file/b.pdf\">附件</a></p>";
|
||||
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/");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user