feat(msgcenter): 消息跳转收口(C1/C2)——出参增 showJump 判权(authorized-codes 每请求懒加载一次、UserBase 不可达放行、无权 jumpUrl 同置 null 双保险);jump-bases 手工基址表三处退役(字段/yml/兜底分支)+ jumpApp 命中严格二选一不回落发送方 appKey,注册表基址并入尾斜杠归一

This commit is contained in:
chenxing
2026-09-28 16:52:51 +08:00
parent 847f13445d
commit ae4bc5ecf6
4 changed files with 253 additions and 138 deletions
@@ -10,7 +10,8 @@ import java.util.Map;
/** /**
* 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路): * 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
* UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含), * UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
* jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按 bizType 查基址表拼绝对 jumpUrl。 * jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按应用注册表(UserBase by-code/by-key
* 取 pc_url)拼绝对 jumpUrl;手工基址表 jump-bases 已退役(C2)。
*/ */
@Data @Data
@Component @Component
@@ -35,12 +36,4 @@ public class MsgCenterProperties {
/** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */ /** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
private Map<String, String> appNames = new LinkedHashMap<>(); private Map<String, String> appNames = new LinkedHashMap<>();
/**
* 基址表:key 兼容两种查询维度——「页面归属应用」(jumpApp,发送方代发时声明,优先)与
* 「bizType」(发送方自己的消息分类,回落)。业务方应用名键(如 notice)与其主 bizType 键
* 通常重合;细分分类(notice.expedite)由发送方配 jumpApp=notice 命中应用键,无需逐分类配键。
* jumpUrl = 基址 + "/#" + jumpPath;生产同主域用相对段如 /flow-center。
*/
private Map<String, String> jumpBases = new LinkedHashMap<>();
} }
@@ -10,9 +10,11 @@ import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调 * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
@@ -21,8 +23,11 @@ import java.util.Map;
* <ul> * <ul>
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li> * <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li>
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li> * <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li>
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按「注册表 by-code/by-key 取 pc_url(B2 主链路) * <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
* → jump-bases 手工表兜底」解析基址,归一化后拼 "#" + jumpPath(裸相对路径如 /task/123)。</li> * 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
* <li>showJump 判权(C1):jumpApp 有值时按 /oapi/app/authorized-codes 集合判定,
* 无权 → showJump=false 且 jumpUrl 一并置 null;jumpApp 为空或 UserBase 不可达不判权放行。</li>
* </ul> * </ul>
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b> * 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401, * base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
@@ -77,12 +82,13 @@ public class MsgCenterProxyService {
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class); return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
} }
/** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl */ /** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl 与 showJump 判权出参 */
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) { public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) {
Map<String, Object> empty = emptyPage(); Map<String, Object> empty = emptyPage();
if (!enabled()) { if (!enabled()) {
return empty; return empty;
} }
PerRequestAuthz authz = new PerRequestAuthz(userId);
try { try {
Map<String, String> query = new LinkedHashMap<>(); Map<String, String> query = new LinkedHashMap<>();
query.put("userId", String.valueOf(userId)); query.put("userId", String.valueOf(userId));
@@ -93,7 +99,7 @@ public class MsgCenterProxyService {
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
if (data != null && data.path("content").isArray()) { if (data != null && data.path("content").isArray()) {
for (JsonNode row : data.get("content")) { for (JsonNode row : data.get("content")) {
rows.add(rewrite(row)); rows.add(rewrite(row, authz));
} }
} }
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
@@ -228,8 +234,8 @@ public class MsgCenterProxyService {
// ===== 出参改写 ===== // ===== 出参改写 =====
/** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ 应用名补全 */ /** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用名补全 */
private Map<String, Object> rewrite(JsonNode row) { private Map<String, Object> rewrite(JsonNode row, PerRequestAuthz authz) {
Map<String, Object> out = new LinkedHashMap<>(); Map<String, Object> out = new LinkedHashMap<>();
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null);
out.put("title", text(row, "title")); out.put("title", text(row, "title"));
@@ -245,26 +251,38 @@ public class MsgCenterProxyService {
out.put("bizId", text(row, "bizId")); out.put("bizId", text(row, "bizId"));
out.put("sendTime", text(row, "sendTime")); out.put("sendTime", text(row, "sendTime"));
out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0); out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0);
out.put("jumpUrl", jumpUrl(row)); String jumpUrl = jumpUrl(row);
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
Boolean allowed = null;
String jumpApp = text(row, "jumpApp");
if (jumpUrl != null && StringUtils.hasText(jumpApp)) {
allowed = authz.authorized(jumpApp.trim());
}
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
if (Boolean.FALSE.equals(allowed)) {
jumpUrl = null;
}
out.put("jumpUrl", jumpUrl);
out.put("showJump", showJump);
return out; return out;
} }
/** /**
* 基址解析(B2 跳转基址统一):jumpType=1 且 jumpPath 非空时—— * 基址解析(B2 跳转基址统一,C2 收口):jumpType=1 且 jumpPath 非空时——
* <ol> * <ol>
* <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li> * <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li>
* <li>注册表:归属应用 jumpApp(值域=app_code)主查 → appKey 数字兜底(自发场景发送方=页面归属), * <li>应用注册表严格二选一:jumpApp(值域=app_code)有值 → 只认 by-code 结果;
* 取 pc_url 按 §3.2 归一化(丢 fragment、保留 index.htm(l))后拼 "#";</li> * jumpApp 为空才按发送方 appKey 走 by-key(自发场景发送方=页面归属)。
* <li>兜底:jump-bases 手工表(jumpApp/bizType 双维度 key),join 形态维持旧 "/#";</li> * pc_url 按 §3.2 归一化(丢 fragment、去尾斜杠、保留 index.htm(l))后拼 "#";</li>
* <li>均无 → null(前端不渲染跳转入口)。</li> * <li>查无 pc_url → null(前端不渲染跳转入口)——pc_url 空就是空,不猜测、不再回落发送方首页。</li>
* </ol> * </ol>
* 注册表是多端地址(pc/app/小程序/钉钉)唯一可持续来源,手工表仅为过渡兜底。 * 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
*/ */
private String jumpUrl(JsonNode row) { private String jumpUrl(JsonNode row) {
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null; Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null;
String jumpPath = text(row, "jumpPath"); String jumpPath = text(row, "jumpPath");
String jumpApp = text(row, "jumpApp"); String jumpApp = text(row, "jumpApp");
String bizType = text(row, "bizType");
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) { if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
return null; return null;
} }
@@ -272,45 +290,33 @@ public class MsgCenterProxyService {
if (path.startsWith("http://") || path.startsWith("https://")) { if (path.startsWith("http://") || path.startsWith("https://")) {
return path; return path;
} }
if (userbaseDown()) {
return null;
}
String p = path.startsWith("/") ? path : "/" + path; String p = path.startsWith("/") ? path : "/" + path;
String appKey = text(row, "appKey"); String appKey = text(row, "appKey");
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
if (!userbaseDown()) { : (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim()) : null; if (!StringUtils.hasText(pcUrl)) {
if (!StringUtils.hasText(pcUrl) && StringUtils.hasText(appKey)) { return null;
pcUrl = pcUrlByKey(appKey.trim());
} }
if (StringUtils.hasText(pcUrl)) {
String base = pcUrl.trim(); String base = pcUrl.trim();
int hash = base.indexOf('#'); int hash = base.indexOf('#');
if (hash >= 0) { if (hash >= 0) {
base = base.substring(0, hash); base = base.substring(0, hash);
} }
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + "#" + p; return base + "#" + p;
} }
}
String base = StringUtils.hasText(jumpApp) /** 注册表 pc_url 查询(by-code 主查)。UserBase 不可达/查无 → null,失败进短路窗口 */
? properties.getJumpBases().get(jumpApp.trim()) : null;
if (!StringUtils.hasText(base) && StringUtils.hasText(bizType)) {
base = properties.getJumpBases().get(bizType);
}
if (!StringUtils.hasText(base)) {
return null;
}
String b = base.trim();
while (b.endsWith("/")) {
b = b.substring(0, b.length() - 1);
}
return b + "/#" + p;
}
/** 注册表 pc_url 查询(by-code 主查)。UserBase 不可达/查无 → null 走手工表兜底,失败进短路窗口 */
private String pcUrlByCode(String appCode) { private String pcUrlByCode(String appCode) {
return lookupPcUrl("/oapi/app/by-code/" + appCode); return lookupPcUrl("/oapi/app/by-code/" + appCode);
} }
/** 注册表 pc_url 查询(by-key 兜底,appKey 是 app_key 列非主键 id) */ /** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */
private String pcUrlByKey(String appKey) { private String pcUrlByKey(String appKey) {
return lookupPcUrl("/oapi/app/by-key/" + appKey); return lookupPcUrl("/oapi/app/by-key/" + appKey);
} }
@@ -323,7 +329,7 @@ public class MsgCenterProxyService {
return ok ? app.get("pcUrl").asText() : null; return ok ? app.get("pcUrl").asText() : null;
} catch (Exception e) { } catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS; userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 查址不可达({}ms 内走手工表兜底)path={}:{}", USERBASE_RETRY_MS, path, e); log.warn("[msgcenter] UserBase 查址不可达({}ms 内跳转入口整体隐藏)path={}:{}", USERBASE_RETRY_MS, path, e);
return null; return null;
} }
} }
@@ -332,6 +338,61 @@ public class MsgCenterProxyService {
return System.currentTimeMillis() < userbaseDownUntil; return System.currentTimeMillis() < userbaseDownUntil;
} }
/**
* 单次请求内的判权懒加载器:列表内首个带 jumpApp 的行触发一次出站,之后行间共用
* (禁按行重复出站);UserBase 不可达 → 判权结果 null(放行),失败进 30s 短路窗口。
*/
private class PerRequestAuthz {
private final Integer userId;
private boolean fetched;
private Set<String> codes;
PerRequestAuthz(Integer userId) {
this.userId = userId;
}
/** @return true=有权 false=无权 null=不可判(UserBase 不可达)→ 调用方放行 */
Boolean authorized(String appCode) {
if (!fetched) {
fetched = true;
codes = fetchAuthorizedCodes(userId);
}
return codes == null ? null : codes.contains(appCode);
}
}
/**
* 拉当前用户有权应用的 app_code 集合(UserBase /oapi/app/authorized-codes)。
* 复用 userbaseDownUntil 30s 短路:短路中不出站;请求失败同样进短路窗口。
*
* @return app_code 集合;UserBase 不可达 → null(调用方不判权放行,绝不批量隐藏)
*/
private Set<String> fetchAuthorizedCodes(Integer userId) {
if (userId == null || userbaseDown()) {
return null;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userId", String.valueOf(userId));
JsonNode data = openCall.get(TARGET_USERBASE, "/oapi/app/authorized-codes", query, JsonNode.class);
userbaseDownUntil = 0L;
Set<String> codes = new HashSet<>();
if (data != null && data.isArray()) {
for (JsonNode n : data) {
if (n != null && n.isTextual() && StringUtils.hasText(n.asText())) {
codes.add(n.asText().trim());
}
}
}
return codes;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 判权不可达({}ms 内不判权放行)userId={}:{}",
USERBASE_RETRY_MS, userId, e.toString());
return null;
}
}
private static Map<String, Object> emptyPage() { private static Map<String, Object> emptyPage() {
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("total", 0); out.put("total", 0);
+3 -15
View File
@@ -99,21 +99,9 @@ userhome:
100617: 通知公告 100617: 通知公告
100019: 流程中心 100019: 流程中心
100001: 用户主页 100001: 用户主页
# 基址表:key 兼容「页面归属应用」(jumpApp,发送方声明,优先)与「bizType」(回落)两个维度。 # 跳转基址唯一来源 = 应用注册表(UserBase /oapi/app/by-code|by-key 取 pc_url,§3.2 归一化后
# 跳转路径是裸相对路径(如 /task/123)且落地页是 hash 路由,代理拼 jumpUrl = 基址 + "/#" + jumpPath; # 拼 "#" + jumpPath);已是绝对 URL 的存量消息直接透传。手工基址表 jump-bases 已退役(C2),
# 已是绝对 URL 的存量消息直接透传(不二次拼基址)。生产(Nacos userhome.yaml 覆盖)同主域相对段: # 生产 Nacos userhome.yaml 若残留 jump-bases 段应一并清理(Spring 静默忽略,留着误导)。
# jump-bases: { flow: /flow-center, flow.task: /flow-center, flow.result: /flow-center,
# flow.urge: /flow-center, flow.cc: /flow-center, flow.returned: /flow-center,
# notice: /notice, svc_item: /svc-hall }
jump-bases:
flow: http://localhost:8088
flow.task: http://localhost:8088
flow.result: http://localhost:8088
flow.urge: http://localhost:8088
flow.cc: http://localhost:8088
flow.returned: http://localhost:8088
notice: http://localhost:8077
svc_item: http://localhost:8087
# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。 # [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。
# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患; # Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患;
@@ -11,11 +11,12 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
@@ -28,17 +29,23 @@ import static org.mockito.Mockito.when;
/** /**
* 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约 * 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约
* (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)、
* jumpUrl 改写规则(jumpType=1 时:绝对 URL 直接透传;相对路径按「页面归属应用 jumpApp → * jumpUrl 改写规则与 showJump 判权出参(C1/C2 收口后口径)。
* bizType 回落」查基址 → 基址 + "/#" + jumpPath)。 * <p>
* UserMsg 不可达降级空结果(铃铛不 500)。 * jumpUrl(C2 收口):jumpType=1 时绝对 URL 直接透传;相对路径走应用注册表严格二选一——
* jumpApp(值域=app_code)有值只认 by-code 结果,jumpApp 为空才按发送方 appKey 走 by-key
* (自发场景);pc_url 空就是空,不再回落、jump-bases 手工基址表已退役。
* <p>
* showJump(C1 判权):jumpApp 有值且 jumpUrl 非空时按 /oapi/app/authorized-codes 集合判定;
* 无权 → showJump=false 且 jumpUrl 一并置 null(双保险);jumpApp 为空 / UserBase 不可达不判权放行;
* 判权集合每请求懒加载一次,行间共享。
* <p> * <p>
* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0): * 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0):
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时 * 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从 * 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。 * usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
*/ */
@DisplayName("MsgCenterProxyService - YschOpenCall 代理与 jumpUrl 改写") @DisplayName("MsgCenterProxyService - YschOpenCall 代理、jumpUrl 改写与 showJump 判权")
class MsgCenterProxyServiceTest { class MsgCenterProxyServiceTest {
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */ /** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
@@ -53,7 +60,6 @@ class MsgCenterProxyServiceTest {
openCall = mock(YschOpenCall.class); openCall = mock(YschOpenCall.class);
properties = new MsgCenterProperties(); properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl(PROD_BASE_URL); properties.setUsermsgBaseUrl(PROD_BASE_URL);
properties.getJumpBases().put("flow.task", "http://localhost:8088/");
service = new MsgCenterProxyService(openCall, properties); service = new MsgCenterProxyService(openCall, properties);
} }
@@ -61,6 +67,20 @@ class MsgCenterProxyServiceTest {
return new ObjectMapper().readTree(s); return new ObjectMapper().readTree(s);
} }
/** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */
private void stubAuthorizedCodes(String... codes) throws Exception {
StringBuilder arr = new StringBuilder("[");
for (int i = 0; i < codes.length; i++) {
if (i > 0) {
arr.append(',');
}
arr.append('"').append(codes[i]).append('"');
}
arr.append(']');
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
.thenReturn(json(arr.toString()));
}
@Test @Test
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() { void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg // 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
@@ -78,6 +98,9 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_buildsQueryParams_andRewritesJumpUrl() throws Exception { void list_buildsQueryParams_andRewritesJumpUrl() throws Exception {
properties.getAppNames().put("100617", "通知公告"); properties.getAppNames().put("100617", "通知公告");
// 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一)
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(json("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
@@ -99,10 +122,13 @@ class MsgCenterProxyServiceTest {
assertEquals(2, out.get("total")); assertEquals(2, out.get("total"));
java.util.List<?> rows = (java.util.List<?>) out.get("list"); java.util.List<?> rows = (java.util.List<?>) out.get("list");
Map<?, ?> first = (Map<?, ?>) rows.get(0); Map<?, ?> first = (Map<?, ?>) rows.get(0);
assertEquals("http://localhost:8088/#/task/123", first.get("jumpUrl"), "基址+/#+裸路径"); assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
assertEquals("flow.task", first.get("bizType")); assertEquals("flow.task", first.get("bizType"));
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名"); assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写"); Map<?, ?> second = (Map<?, ?>) rows.get(1);
assertNull(second.get("jumpUrl"), "非应用内跳转不改写");
assertEquals(Boolean.FALSE, second.get("showJump"));
} }
@Test @Test
@@ -129,32 +155,37 @@ class MsgCenterProxyServiceTest {
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
} }
/** jumpApp 与 appKey 均空 → 注册表无源可查 → null(前端不渲染跳转),且不产生判权/查址出站 */
@Test @Test
void list_noBaseConfigured_jumpUrlNull() throws Exception { void list_noRegistrySource_jumpUrlNull() throws Exception {
properties.getJumpBases().clear();
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<?, ?> first = firstRow();
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
assertNull(first.get("jumpUrl"), "bizType 未配基址 → 不改写(前端不渲染跳转)"); assertEquals(Boolean.FALSE, first.get("showJump"));
verify(openCall, never()).get(eq("userbase"), anyString(), anyMap(), eq(JsonNode.class));
verify(openCall, never()).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
} }
// ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 ===== // ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 =====
/** 代发待办:bizType=flow.task 但页面属业务方(jumpApp=notice)→ 按 notice 基址拼(B1) */ /** 归属应用 jumpApp 主查注册表 by-code,pc_url 拼接 + 判权放行 */
@Test @Test
void list_jumpAppPreferredOverBizType() throws Exception { void list_jumpApp_byCodeHit_authorized_showJumpTrue() throws Exception {
properties.getJumpBases().put("notice", "http://localhost:8077"); stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\","
+ "\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice", "flow-center");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\"," + "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}")); + "\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<?, ?> first = firstRow();
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
assertEquals("http://localhost:8077/#/list/detail?serid=1", first.get("jumpUrl"), "归属应用 jumpApp 主查 by-code");
"归属应用优先于 bizType 查基址");
assertEquals("notice", first.get("jumpApp"), "jumpApp 逐字段透传"); assertEquals("notice", first.get("jumpApp"), "jumpApp 逐字段透传");
assertEquals(Boolean.TRUE, first.get("showJump"), "有权 → 显示跳转入口");
} }
/** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */ /** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */
@@ -164,58 +195,64 @@ class MsgCenterProxyServiceTest {
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\"," + "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\","
+ "\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "\"bizType\":\"flow.task\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<?, ?> first = firstRow();
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"), assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"),
"绝对 URL 原样透传"); "绝对 URL 原样透传");
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
} }
/** 灰度兼容:旧发送端未升级(无 jumpApp)→ 回落按 bizType 查(= 改造前行为) */ /** 旧发送端未升级(无 jumpApp)的自发消息 → 按发送方 appKey 走 by-key(严格二选一的第二分支) */
@Test @Test
void list_jumpAppAbsent_fallsBackToBizType() throws Exception { void list_jumpAppAbsent_fallsBackToSendersAppKey() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<?, ?> first = firstRow();
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey");
assertEquals("http://localhost:8088/#/task/9", first.get("jumpUrl"), "无归属声明回落 bizType"); assertEquals(Boolean.TRUE, first.get("showJump"));
} }
/** 归属键未配基址(如催办 notice.expedite 场景 jumpApp 未升级)→ 仍回落 bizType */ /** jumpApp 查无 pc_url 且 appKey 为空 → null(不做猜测) */
@Test @Test
void list_jumpAppMiss_fallsBackToBizType() throws Exception { void list_jumpAppMiss_noAppKeyFallback_jumpUrlNull() throws Exception {
stubUserbaseGet("/oapi/app/by-code/unknown-app", null);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\"," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\","
+ "\"isRead\":0}]}")); + "\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<?, ?> first = firstRow();
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); assertNull(first.get("jumpUrl"));
assertEquals("http://localhost:8088/#/task/9", first.get("jumpUrl")); assertEquals(Boolean.FALSE, first.get("showJump"));
} }
/** 归属与 bizType 都查不到基址 → null(不渲染跳转) */ /** P2 缺陷回归:jumpApp 有值但注册表查无 pc_url → 严格 null,绝不回落发送方 appKey */
@Test @Test
void list_jumpAppAndBizTypeBothMiss_null() throws Exception { void list_jumpAppPresent_byCodeMiss_noSenderAppKeyFallback() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice", null);
stubUserbaseGet("/oapi/app/by-key/100006",
"{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":\"http://localhost:8090\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/x\",\"bizType\":\"flow.nowhere\",\"jumpApp\":\"nowhere\"," + "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
+ "\"isRead\":0}]}")); Map<?, ?> first = firstRow();
Map<String, Object> out = service.list(201, 1, 10, null); assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空");
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class));
assertNull(first.get("jumpUrl"));
} }
/** 基址尾部斜杠归一保留:jumpApp 命中带尾斜杠的基址也不产出双斜杠 */ /** pc_url 尾斜杠归一:注册表基址带尾斜杠也不产出 //(归一化并入注册表路径) */
@Test @Test
void list_jumpAppBaseTrailingSlash_normalized() throws Exception { void list_registryPcUrlTrailingSlash_normalized() throws Exception {
properties.getJumpBases().put("notice", "http://localhost:8077/"); stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077/\"}");
stubAuthorizedCodes("notice");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<?, ?> first = firstRow();
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
assertEquals("http://localhost:8077/#/list/detail?serid=1", first.get("jumpUrl"), "尾斜杠归一 + 无前导斜杠路径补斜杠");
"基址尾斜杠归一 + 无前导斜杠路径补斜杠");
} }
@Test @Test
@@ -350,55 +387,91 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/flow-center", stubUserbaseGet("/oapi/app/by-code/flow-center",
"{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\"," "{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}"); + "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
stubAuthorizedCodes("flow-center");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}"));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"), assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
"丢 fragment + # 拼接,不产第二个 #"); "丢 fragment + # 拼接,不产第二个 #");
assertEquals(Boolean.TRUE, first.get("showJump"));
} }
/** by-code 查无 → by-key 兜底(app_key 列,非主键 id——100009 是流程中心不是课堂作业) */ // ===== C1(showJump 判权):无权隐藏、不可达放行、每请求一次出站 =====
/** 无权:showJump=false 且 jumpUrl 一并置 null(双保险,前端漏改也不可点) */
@Test @Test
void list_byCodeMiss_fallsBackToByKey() throws Exception { void list_jumpApp_unauthorized_showJumpFalse_jumpUrlNull() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice", null); stubUserbaseGet("/oapi/app/by-code/notice",
stubUserbaseGet("/oapi/app/by-key/100001", "{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\"," stubAuthorizedCodes("flow-center");
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/notice/front/index.htm\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/1\",\"appKey\":\"100001\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
Map<?, ?> first = firstRow(); Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/notice/front/index.htm#/list/1", first.get("jumpUrl")); assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口");
assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)");
} }
/** 注册表两路都查无且无手工表 → null(前端不渲染跳转,不做猜测) */ /** jumpApp 为空 → 不判权(避免误伤无归属消息),且不触发 authorized-codes 出站 */
@Test @Test
void list_registryMiss_noManualBase_jumpUrlNull() throws Exception { void list_jumpAppBlank_noAuthzOutbound() throws Exception {
properties.getJumpBases().clear(); stubUserbaseGet("/oapi/app/by-key/100019",
stubUserbaseGet("/oapi/app/by-code/notice", null); "{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
stubUserbaseGet("/oapi/app/by-key/100006",
"{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":null}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}"));
assertNull(firstRow().get("jumpUrl")); Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"));
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
} }
/** UserBase 停摆:首行两次超时后进 30s 短路窗口,后续行不再出站、直接手工表兜底 */ /** UserBase 判权不可达 → 不判权放行:绝对 URL 消息 showJump 保持 true(绝不批量隐藏) */
@Test @Test
void list_userbaseDown_shortCircuitFallsBackToManualBases() throws Exception { void list_authzUnreachable_absoluteUrlStillPasses() throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}"));
Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行");
assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl"));
}
/** 判权集合每请求懒加载一次:同列表多行带 jumpApp 共用一次出站(禁按行重复) */
@Test
void list_authzFetchedOncePerRequest_sharedAcrossRows() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(0)).get("showJump"));
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(1)).get("showJump"));
verify(openCall, org.mockito.Mockito.times(1))
.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
}
/** UserBase 停摆:首行查址失败进 30s 短路窗口,后续行不再出站;相对路径无基址可解 → 入口整体隐藏 */
@Test
void list_userbaseDown_shortCircuit_relativeJumpNull() throws Exception {
when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class))) when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout")); .thenThrow(new RuntimeException("connect timeout"));
properties.getJumpBases().put("flow.task", "http://localhost:8088");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":2,\"content\":[" .thenReturn(json("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}," + "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list"); java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertEquals("http://localhost:8088/#/task/1", ((Map<?, ?>) rows.get(0)).get("jumpUrl"), "首行走手工表兜底"); assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null");
assertEquals("http://localhost:8088/#/task/2", ((Map<?, ?>) rows.get(1)).get("jumpUrl")); assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"));
assertEquals(Boolean.FALSE, ((Map<?, ?>) rows.get(0)).get("showJump"));
// 首行仅 by-key 一次(无 jumpApp 不查 by-code)→ 短路后第二行 0 次,共 1 次 // 首行仅 by-key 一次(无 jumpApp 不查 by-code)→ 短路后第二行 0 次,共 1 次
verify(openCall, org.mockito.Mockito.times(1)).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)); verify(openCall, org.mockito.Mockito.times(1)).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
} }