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 覆盖/路由归一化/三类降级/不可达短路/查无缓存
151 lines
6.6 KiB
Java
151 lines
6.6 KiB
Java
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();
|
||
}
|
||
}
|