- 根因:归一化把尾斜杠一并去掉,目录形态基址(.../work-week-plan/front/)被削成 .../front,拼出 .../front#/weekplan(应 .../front/#/weekplan) - 规则:丢 fragment 后,末段不含 '.'(非文件)视为目录并补回单个斜杠;文件形态(.../index.htm(l))直接拼 # - 注册表两种形态并存:work-week-plan/aca-syllabus 是目录,notice/coursehomework 是 index.htm(l) - 新增 WidgetLinkResolverTest 8 例:目录/无斜杠目录/文件/fragment 覆盖/路由归一化/三类降级/不可达短路/查无缓存
This commit is contained in:
@@ -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<String, String> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 → 绝对跳转地址」的拼接口径。
|
||||
*
|
||||
* <p>关键回归点(2026-10-15 生产实锤):注册表两种 pc_url 形态并存——
|
||||
* 目录形态({@code …/work-week-plan/front/})与文件形态({@code …/notice/front/index.htm})。
|
||||
* 目录形态**必须保留斜杠**({@code …/front/#/weekplan}),曾因「去尾斜杠」削成 {@code …/front#/weekplan}
|
||||
* 被反代 301,且有丢 fragment 风险;文件形态直接拼 {@code #} 不能补斜杠。</p>
|
||||
*/
|
||||
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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user