diff --git a/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java b/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java index 3b4f560..bbd2de0 100644 --- a/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java +++ b/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.yunhai.platform.openapi.base.OpenApiAppClient; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -39,8 +38,12 @@ public class WidgetLinkResolver { /** 注册表不可达之后的静默期 */ private static final long PC_URL_DOWN_MS = 30_000L; - @Autowired - private OpenApiAppClient appClient; + /** 注册表应用域客户端(SDK 自动装配,见 yh-openapi-spring-boot-starter 的 OpenApiAutoConfiguration) */ + private final OpenApiAppClient appClient; + + public WidgetLinkResolver(OpenApiAppClient appClient) { + this.appClient = appClient; + } /** app_code → pc_url(空串 = 查无,避免脏 code 反复出站) */ private final ConcurrentHashMap pcUrlCache = new ConcurrentHashMap<>(); @@ -80,6 +83,14 @@ public class WidgetLinkResolver { if (!StringUtils.hasText(base)) { return null; } + // hash 路由只能拼在「基址」之后:目录形态必须补回斜杠(…/front/ + #/weekplan), + // 文件形态直接拼(…/front/index.html + #/list)。缺斜杠会被反代 301 到带斜杠路径, + // 且逐跳有丢 fragment 的风险(注册表两种形态并存:work-week-plan 是目录、notice 是 index.htm)。 + int slash = base.lastIndexOf('/'); + String lastSegment = slash >= 0 ? base.substring(slash + 1) : base; + if (lastSegment.indexOf('.') < 0) { + base = base + "/"; + } return base + "#" + path; } diff --git a/src/test/java/com/yunhai/userhome/service/WidgetLinkResolverTest.java b/src/test/java/com/yunhai/userhome/service/WidgetLinkResolverTest.java new file mode 100644 index 0000000..f9a718f --- /dev/null +++ b/src/test/java/com/yunhai/userhome/service/WidgetLinkResolverTest.java @@ -0,0 +1,132 @@ +package com.yunhai.userhome.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.openapi.base.OpenApiAppClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 小组件外跳地址解析单测:冻结「注册表 pc_url → 绝对跳转地址」的拼接口径。 + * + *

关键回归点(2026-10-15 生产实锤):注册表两种 pc_url 形态并存—— + * 目录形态({@code …/work-week-plan/front/})与文件形态({@code …/notice/front/index.htm})。 + * 目录形态**必须保留斜杠**({@code …/front/#/weekplan}),曾因「去尾斜杠」削成 {@code …/front#/weekplan} + * 被反代 301,且有丢 fragment 风险;文件形态直接拼 {@code #} 不能补斜杠。

+ */ +class WidgetLinkResolverTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private OpenApiAppClient appClient; + private WidgetLinkResolver resolver; + + @BeforeEach + void setUp() { + appClient = mock(OpenApiAppClient.class); + resolver = new WidgetLinkResolver(appClient); + } + + private void stubPcUrl(String appCode, String pcUrl) throws Exception { + JsonNode node = pcUrl == null ? MAPPER.readTree("{}") : MAPPER.readTree("{\"pcUrl\":\"" + pcUrl + "\"}"); + when(appClient.getAppByCode(eq(appCode))).thenReturn(node); + } + + @Test + @DisplayName("目录形态 pc_url:尾斜杠保留,路由拼在斜杠之后") + void dirFormKeepsTrailingSlash() throws Exception { + stubPcUrl("work-week-plan", "http://base.yunhaixingtong.com/work-week-plan/front/"); + assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan", + resolver.resolve("work-week-plan", "/weekplan")); + } + + @Test + @DisplayName("目录形态但注册表没写尾斜杠:按目录补回斜杠") + void dirFormWithoutSlashGetsSlash() throws Exception { + stubPcUrl("aca-classroom", "http://base.yunhaixingtong.com/aca-classroom/front"); + assertEquals("http://base.yunhaixingtong.com/aca-classroom/front/#/home", + resolver.resolve("aca-classroom", "/home")); + } + + @Test + @DisplayName("文件形态 pc_url(index.htm(l)):直接拼 #,不补斜杠") + void fileFormKeepsFile() throws Exception { + stubPcUrl("notice", "http://base.yunhaixingtong.com/notice/front/index.htm"); + stubPcUrl("coursehomework", "http://base.yunhaixingtong.com/coursehomework/front/index.html"); + assertEquals("http://base.yunhaixingtong.com/notice/front/index.htm#/detail", + resolver.resolve("notice", "/detail")); + assertEquals("http://base.yunhaixingtong.com/coursehomework/front/index.html#/list", + resolver.resolve("coursehomework", "/list")); + } + + @Test + @DisplayName("注册表 pc_url 自带 fragment:先丢再按配置路由拼") + void registryFragmentIsDropped() throws Exception { + stubPcUrl("flow-center", "http://base.yunhaixingtong.com/flow-center/front/index.html#/my"); + assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/weekplan", + resolver.resolve("flow-center", "/weekplan")); + } + + @Test + @DisplayName("路由容错:无前导斜杠 / 带 # 前缀都归一化") + void pathIsNormalized() throws Exception { + stubPcUrl("work-week-plan", "http://base.yunhaixingtong.com/work-week-plan/front/"); + assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan", + resolver.resolve("work-week-plan", "weekplan")); + assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan", + resolver.resolve("work-week-plan", "#/weekplan")); + } + + @Test + @DisplayName("降级:未配置 / 查无 pc_url / 路由写绝对 URL 一律 null(不猜、不渲染入口)") + void degradesToNull() throws Exception { + stubPcUrl("no-pcurl", null); + assertNull(resolver.resolve(null, "/x")); + assertNull(resolver.resolve("", "/x")); + assertNull(resolver.resolve("work-week-plan", null)); + assertNull(resolver.resolve("work-week-plan", " ")); + assertNull(resolver.resolve("no-pcurl", "/x")); + assertNull(resolver.resolve("work-week-plan", "http://evil.example.com/x")); + } + + @Test + @DisplayName("注册表不可达:返回 null 且不抛;同一 code 命中缓存只出站一次") + void registryDownAndCache() throws Exception { + when(appClient.getAppByCode(anyString())).thenThrow(new RuntimeException("boom")); + assertNull(resolver.resolve("work-week-plan", "/weekplan")); + assertNull(resolver.resolve("work-week-plan", "/weekplan")); + // 失败进短路窗口:窗口内不再出站 + verify(appClient, times(1)).getAppByCode(eq("work-week-plan")); + + OpenApiAppClient ok = mock(OpenApiAppClient.class); + stubPcUrlOk(ok); + WidgetLinkResolver cached = new WidgetLinkResolver(ok); + cached.resolve("work-week-plan", "/weekplan"); + cached.resolve("work-week-plan", "/weekplan"); + verify(ok, times(1)).getAppByCode(eq("work-week-plan")); + } + + private void stubPcUrlOk(OpenApiAppClient client) throws Exception { + when(client.getAppByCode(eq("work-week-plan"))) + .thenReturn(MAPPER.readTree("{\"pcUrl\":\"http://base.yunhaixingtong.com/work-week-plan/front/\"}")); + } + + @Test + @DisplayName("查无 pc_url 也进缓存:脏 code 不重复出站") + void missIsCached() { + when(appClient.getAppByCode(eq("ghost"))).thenReturn(null); + assertNull(resolver.resolve("ghost", "/x")); + assertNull(resolver.resolve("ghost", "/x")); + verify(appClient, times(1)).getAppByCode(eq("ghost")); + } +}