fix(小组件外跳): 目录形态 pc_url 保留尾斜杠,修 /front#/weekplan 少一个斜杠
build / build-push (push) Successful in 1m6s

- 根因:归一化把尾斜杠一并去掉,目录形态基址(.../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:
chenxing
2026-10-15 21:02:51 +08:00
parent 920408dd0f
commit d2cf718b51
2 changed files with 146 additions and 3 deletions
@@ -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"));
}
}