diff --git a/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java b/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java index 56851fb..863e0af 100644 --- a/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java +++ b/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java @@ -30,6 +30,11 @@ public class MsgCenterProperties { /** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */ private Map appNames = new LinkedHashMap<>(); - /** bizType → 应用 front 基址(jumpUrl = 基址 + "/#" + jumpPath);生产同主域用相对段如 /flow-center */ + /** + * 基址表:key 兼容两种查询维度——「页面归属应用」(jumpApp,发送方代发时声明,优先)与 + * 「bizType」(发送方自己的消息分类,回落)。业务方应用名键(如 notice)与其主 bizType 键 + * 通常重合;细分分类(notice.expedite)由发送方配 jumpApp=notice 命中应用键,无需逐分类配键。 + * jumpUrl = 基址 + "/#" + jumpPath;生产同主域用相对段如 /flow-center。 + */ private Map jumpBases = new LinkedHashMap<>(); } diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java index c3e9b3f..fb6104b 100644 --- a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java +++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java @@ -182,7 +182,7 @@ public class MsgCenterProxyService { // ===== 出参改写 ===== - /** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且配置了 bizType 基址时)+ 应用名补全 */ + /** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ 应用名补全 */ private Map rewrite(JsonNode row) { Map out = new LinkedHashMap<>(); out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); @@ -191,6 +191,7 @@ public class MsgCenterProxyService { out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").asInt() : null); out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null); out.put("jumpPath", text(row, "jumpPath")); + out.put("jumpApp", text(row, "jumpApp")); out.put("appKey", text(row, "appKey")); String appKey = text(row, "appKey"); out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey)); @@ -202,16 +203,29 @@ public class MsgCenterProxyService { return out; } - /** 基址 + "/#" + jumpPath;未配置基址/非应用内跳转/无路径 → null(前端不渲染跳转) */ + /** + * 基址 + "/#" + jumpPath。基址查询优先「页面归属应用」(jumpApp,代发场景页面属业务方时由 + * 发送方声明),缺省回落 bizType(=发送方自己的消息分类);都查不到返回 null(前端不渲染跳转)。 + * jumpPath 已是绝对 URL(http/https)时直接透传——兼容存量已落库的绝对地址消息,且不再 + * 重复拼基址产出双前缀畸形链接。非应用内跳转(jumpType≠1)/无路径 → null。 + */ private String jumpUrl(JsonNode row) { Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null; String jumpPath = text(row, "jumpPath"); + String jumpApp = text(row, "jumpApp"); String bizType = text(row, "bizType"); - if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath) - || !StringUtils.hasText(bizType)) { + if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) { return null; } - String base = properties.getJumpBases().get(bizType); + String path = jumpPath.trim(); + if (path.startsWith("http://") || path.startsWith("https://")) { + return path; + } + String base = StringUtils.hasText(jumpApp) + ? properties.getJumpBases().get(jumpApp.trim()) : null; + if (!StringUtils.hasText(base) && StringUtils.hasText(bizType)) { + base = properties.getJumpBases().get(bizType); + } if (!StringUtils.hasText(base)) { return null; } @@ -219,8 +233,8 @@ public class MsgCenterProxyService { while (b.endsWith("/")) { b = b.substring(0, b.length() - 1); } - String path = jumpPath.startsWith("/") ? jumpPath : "/" + jumpPath; - return b + "/#" + path; + String p = path.startsWith("/") ? path : "/" + path; + return b + "/#" + p; } private static Map emptyPage() { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f20f090..fd20b42 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -96,15 +96,21 @@ userhome: 100617: 通知公告 100019: 流程中心 100001: 用户主页 - # bizType → 应用 front 基址:jumpPath 是裸相对路径(如 /task/123)且落地页是 hash 路由, - # 代理拼 jumpUrl = 基址 + "/#" + jumpPath。生产(Nacos userhome.yaml 覆盖)同主域相对段: - # jump-bases: { flow: /flow-center, flow.task: /flow-center, flow.result: /flow-center, flow.urge: /flow-center } + # 基址表:key 兼容「页面归属应用」(jumpApp,发送方声明,优先)与「bizType」(回落)两个维度。 + # 跳转路径是裸相对路径(如 /task/123)且落地页是 hash 路由,代理拼 jumpUrl = 基址 + "/#" + jumpPath; + # 已是绝对 URL 的存量消息直接透传(不二次拼基址)。生产(Nacos userhome.yaml 覆盖)同主域相对段: + # 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)。 # Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患; diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java index 98805c8..673f6c5 100644 --- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java +++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java @@ -28,7 +28,8 @@ import static org.mockito.Mockito.when; /** * 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约 * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 - * jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。 + * jumpUrl 改写规则(jumpType=1 时:绝对 URL 直接透传;相对路径按「页面归属应用 jumpApp → + * bizType 回落」查基址 → 基址 + "/#" + jumpPath)。 * UserMsg 不可达降级空结果(铃铛不 500)。 *

* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0): @@ -138,6 +139,84 @@ class MsgCenterProxyServiceTest { assertNull(first.get("jumpUrl"), "bizType 未配基址 → 不改写(前端不渲染跳转)"); } + // ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 ===== + + /** 代发待办:bizType=flow.task 但页面属业务方(jumpApp=notice)→ 按 notice 基址拼(B1) */ + @Test + void list_jumpAppPreferredOverBizType() throws Exception { + properties.getJumpBases().put("notice", "http://localhost:8077"); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\"," + + "\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertEquals("http://localhost:8077/#/list/detail?serid=1", first.get("jumpUrl"), + "归属应用优先于 bizType 查基址"); + assertEquals("notice", first.get("jumpApp"), "jumpApp 逐字段透传"); + } + + /** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */ + @Test + void list_absoluteJumpPath_passedThrough() throws Exception { + 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/detail?serid=1\"," + + "\"bizType\":\"flow.task\",\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"), + "绝对 URL 原样透传"); + } + + /** 灰度兼容:旧发送端未升级(无 jumpApp)→ 回落按 bizType 查(= 改造前行为) */ + @Test + void list_jumpAppAbsent_fallsBackToBizType() throws Exception { + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertEquals("http://localhost:8088/#/task/9", first.get("jumpUrl"), "无归属声明回落 bizType"); + } + + /** 归属键未配基址(如催办 notice.expedite 场景 jumpApp 未升级)→ 仍回落 bizType */ + @Test + void list_jumpAppMiss_fallsBackToBizType() throws Exception { + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\"," + + "\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertEquals("http://localhost:8088/#/task/9", first.get("jumpUrl")); + } + + /** 归属与 bizType 都查不到基址 → null(不渲染跳转) */ + @Test + void list_jumpAppAndBizTypeBothMiss_null() throws Exception { + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"/x\",\"bizType\":\"flow.nowhere\",\"jumpApp\":\"nowhere\"," + + "\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertNull(first.get("jumpUrl")); + } + + /** 基址尾部斜杠归一保留:jumpApp 命中带尾斜杠的基址也不产出双斜杠 */ + @Test + void list_jumpAppBaseTrailingSlash_normalized() throws Exception { + properties.getJumpBases().put("notice", "http://localhost:8077/"); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + Map out = service.list(201, 1, 10, null); + Map first = (Map) ((java.util.List) out.get("list")).get(0); + assertEquals("http://localhost:8077/#/list/detail?serid=1", first.get("jumpUrl"), + "基址尾斜杠归一 + 无前导斜杠路径补斜杠"); + } + @Test void list_userMsgDown_degradesToEmpty() { when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))