Files
UserhomeService/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java
T
chenxing d2cf718b51
build / build-push (push) Successful in 1m6s
fix(小组件外跳): 目录形态 pc_url 保留尾斜杠,修 /front#/weekplan 少一个斜杠
- 根因:归一化把尾斜杠一并去掉,目录形态基址(.../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 覆盖/路由归一化/三类降级/不可达短路/查无缓存
2026-10-15 21:02:51 +08:00

151 lines
6.6 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小组件外跳地址解析:{@code app_code} → 应用注册表 {@code pc_url} → 绝对跳转地址。
*
* <p>「地址不写死」落点:库里只存**子应用 code + 路由**,域名一律来自应用注册表
* (UserBase {@code /oapi/app/by-code/{appCode}},经 SDK {@link OpenApiAppClient#getAppByCode} 调用,
* 签名自动)。拼接口径与消息中心 {@code jumpUrl} 一致:{@code pc_url} 归一化
* (丢 fragment、去尾斜杠)后拼 {@code "#" + path}。</p>
*
* <p>降级纪律:未配置 / 查无 pc_url / 注册表不可达 / 路由写成绝对 URL —— 一律返回 {@code null},
* 调用方不渲染跳转入口(pc_url 空就是空,不猜测、不回落)。</p>
*
* <p>缓存与短路:注册表地址 60s TTL(低频变更,避免每次进桌面都打一次 UserBase);查询失败进 30s
* 短路窗口,窗口内沿用旧缓存,避免下游停摆时每次渲染都吃一次连接超时。</p>
*
* <p>TODO 跨服务公共抽取:本实现只解决「小组件这一侧」;消息中心 {@code MsgCenterProxyService}
* 仍保留自己的 by-code 缓存与短路窗口。待平台层({{@code yh-spring-boot-starter}})提供带缓存的
* 地址解析,或 UserBase oapi 直接下发归一化跳转基址后,本类应退化为薄壳。</p>
*/
@Service
public class WidgetLinkResolver {
private static final Logger log = LoggerFactory.getLogger(WidgetLinkResolver.class);
/** 注册表地址缓存时长 */
private static final long PC_URL_TTL_MS = 60_000L;
/** 注册表不可达之后的静默期 */
private static final long PC_URL_DOWN_MS = 30_000L;
/** 注册表应用域客户端(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<>();
private final ConcurrentHashMap<String, Long> pcUrlAt = new ConcurrentHashMap<>();
/** 注册表不可达短路截止时间戳 */
private volatile long downUntil;
/**
* 解析外跳地址;无法解析返回 null(调用方不渲染入口)。
*
* @param appCode 应用注册表 app_code,如 {@code work-week-plan}
* @param linkPath 子应用内路由(hash 路由),如 {@code /weekplan};允许写成 {@code #/weekplan} 或 {@code weekplan}
*/
public String resolve(String appCode, String linkPath) {
if (!StringUtils.hasText(appCode) || !StringUtils.hasText(linkPath)) {
return null;
}
String path = normalizePath(linkPath);
if (path == null) {
log.warn("[widget] 外跳路由不合法(应为子应用内相对路由,禁止写绝对 URL):app={} path={}", appCode, linkPath);
return null;
}
String pcUrl = pcUrlOf(appCode.trim());
if (!StringUtils.hasText(pcUrl)) {
return null;
}
String base = pcUrl.trim();
int hash = base.indexOf('#');
if (hash >= 0) {
base = base.substring(0, hash);
}
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
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;
}
/**
* 路由归一化:去首部 {@code #}、去空白,保证以 {@code /} 开头;
* 绝对 URL(http/https)视为配置错误返回 null(域名只能来自注册表)。
*/
private String normalizePath(String linkPath) {
String path = linkPath.trim();
if (path.startsWith("http://") || path.startsWith("https://")) {
return null;
}
if (path.startsWith("#")) {
path = path.substring(1);
}
if (path.isEmpty()) {
return null;
}
return path.startsWith("/") ? path : "/" + path;
}
/** 注册表 pc_url 查询:命中缓存则返回;查无记空串;不可达走短路窗口(窗口内沿用旧值) */
private String pcUrlOf(String appCode) {
Long at = pcUrlAt.get(appCode);
if (at != null && System.currentTimeMillis() - at < PC_URL_TTL_MS) {
return emptyToNull(pcUrlCache.get(appCode));
}
if (System.currentTimeMillis() < downUntil) {
return emptyToNull(pcUrlCache.get(appCode));
}
try {
JsonNode app = appClient.getAppByCode(appCode);
downUntil = 0L;
String pcUrl = app == null || app.isNull() ? null : text(app, "pcUrl");
pcUrlCache.put(appCode, pcUrl == null ? "" : pcUrl);
pcUrlAt.put(appCode, System.currentTimeMillis());
if (!StringUtils.hasText(pcUrl)) {
log.warn("[widget] 注册表无 pc_url,外跳入口不渲染:app={}", appCode);
}
return pcUrl;
} catch (Exception e) {
downUntil = System.currentTimeMillis() + PC_URL_DOWN_MS;
log.warn("[widget] 应用注册表不可达({}ms 内外跳入口沿用旧值/不渲染):app={} {}",
PC_URL_DOWN_MS, appCode, e.toString());
return emptyToNull(pcUrlCache.get(appCode));
}
}
private String emptyToNull(String value) {
return StringUtils.hasText(value) ? value : null;
}
private String text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asText();
}
}