diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java index 9374a34..c3e9b3f 100644 --- a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java +++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java @@ -1,8 +1,8 @@ package com.yunhai.userhome.service; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.yunhai.platform.utils.http.IDHttp; +import com.yunhai.platform.openapi.core.TargetSpec; +import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.userhome.config.MsgCenterProperties; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -15,40 +15,55 @@ import java.util.List; import java.util.Map; /** - * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):IDHttp 出站自动签名(yh.app-auth 身份 - * appKey=100001,UserMsg trusted-apps 已含),UserHome 前端不直连 UserMsg。 + * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调 + * (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path), + * UserHome 前端不直连 UserMsg。 * + * 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥本应用 + * base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401, + * 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为 + * {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。 + * 目标地址来源:usermsg-base-url 显式配置优先,其次服务目录 usermsg(yh.open.services / 枚举兜底)。 * UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。 */ @Slf4j @Service public class MsgCenterProxyService { - private final IDHttp http; - private final MsgCenterProperties properties; - private final ObjectMapper objectMapper; + private static final String TARGET_USERMSG = "usermsg"; - public MsgCenterProxyService(IDHttp http, MsgCenterProperties properties, ObjectMapper objectMapper) { - this.http = http; + private final YschOpenCall openCall; + private final MsgCenterProperties properties; + + public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) { + this.openCall = openCall; this.properties = properties; - this.objectMapper = objectMapper; } private boolean enabled() { - return StringUtils.hasText(properties.getUsermsgBaseUrl()); + return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG); } - private String url(String pathAndQuery) { - String base = properties.getUsermsgBaseUrl().trim(); - while (base.endsWith("/")) { - base = base.substring(0, base.length() - 1); + /** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */ + private JsonNode get(String path, Map query) { + String base = properties.getUsermsgBaseUrl(); + if (StringUtils.hasText(base)) { + return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class); } - return base + pathAndQuery; + return openCall.get(TARGET_USERMSG, path, query, JsonNode.class); + } + + private JsonNode post(String path, Map query, Object body) { + String base = properties.getUsermsgBaseUrl(); + if (StringUtils.hasText(base)) { + return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class); + } + return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class); } /** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl */ @@ -58,10 +73,12 @@ public class MsgCenterProxyService { return empty; } try { - JsonNode data = http.get(url("/oapi/msg/list?userId=" + userId - + "&pageIndex=" + (pageIndex == null || pageIndex < 1 ? 1 : pageIndex) - + "&pageSize=" + (pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100)) - + "&onlyUnread=" + Boolean.TRUE.equals(onlyUnread)), JsonNode.class); + Map query = new LinkedHashMap<>(); + query.put("userId", String.valueOf(userId)); + query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex)); + query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))); + query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread))); + JsonNode data = get("/oapi/msg/list", query); List> rows = new ArrayList<>(); if (data != null && data.path("content").isArray()) { for (JsonNode row : data.get("content")) { @@ -87,14 +104,20 @@ public class MsgCenterProxyService { return out; } try { - JsonNode unread = http.get(url("/oapi/msg/unreadCount?userId=" + userId), JsonNode.class); + Map q = new LinkedHashMap<>(); + q.put("userId", String.valueOf(userId)); + JsonNode unread = get("/oapi/msg/unreadCount", q); out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong()); } catch (Exception e) { log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); } try { - JsonNode todos = http.get(url("/oapi/msg/todo/list?userId=" + userId - + (schId == null ? "" : "&schId=" + schId)), JsonNode.class); + Map q = new LinkedHashMap<>(); + q.put("userId", String.valueOf(userId)); + if (schId != null) { + q.put("schId", String.valueOf(schId)); + } + JsonNode todos = get("/oapi/msg/todo/list", q); List> rows = new ArrayList<>(); if (todos != null && todos.isArray()) { for (JsonNode t : todos) { @@ -119,7 +142,7 @@ public class MsgCenterProxyService { Map body = new HashMap<>(); body.put("msgIds", msgIds); body.put("userId", userId); - http.post(url("/oapi/msg/read"), body, JsonNode.class); + post("/oapi/msg/read", new HashMap<>(), body); } /** 全部已读 */ @@ -129,7 +152,7 @@ public class MsgCenterProxyService { } Map body = new HashMap<>(); body.put("userId", userId); - http.post(url("/oapi/msg/readAll"), body, JsonNode.class); + post("/oapi/msg/readAll", new HashMap<>(), body); } /** @@ -143,7 +166,7 @@ public class MsgCenterProxyService { try { Map body = new HashMap<>(); body.put("userId", userId); - JsonNode data = http.post(url("/oapi/msg/ws/ticket"), body, JsonNode.class); + JsonNode data = post("/oapi/msg/ws/ticket", new HashMap<>(), body); String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText(); if (!StringUtils.hasText(ticket)) { return null; diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java index a79e0d8..98805c8 100644 --- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java +++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java @@ -1,22 +1,24 @@ package com.yunhai.userhome.service; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.yunhai.platform.http.DHttpException; -import com.yunhai.platform.utils.http.IDHttp; +import com.yunhai.platform.openapi.core.OpenApiException; +import com.yunhai.platform.openapi.core.TargetSpec; +import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.userhome.config.MsgCenterProperties; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -28,31 +30,53 @@ import static org.mockito.Mockito.when; * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 * jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。 * UserMsg 不可达降级空结果(铃铛不 500)。 + *

+ * 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0): + * 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时 + * 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从 + * usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。 */ -@DisplayName("MsgCenterProxyService - UserMsg 代理与 jumpUrl 改写") +@DisplayName("MsgCenterProxyService - YschOpenCall 代理与 jumpUrl 改写") class MsgCenterProxyServiceTest { - private IDHttp http; + /** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */ + private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg"; + + private YschOpenCall openCall; private MsgCenterProperties properties; private MsgCenterProxyService service; @BeforeEach void setUp() { - http = mock(IDHttp.class); + openCall = mock(YschOpenCall.class); properties = new MsgCenterProperties(); - properties.setUsermsgBaseUrl("http://localhost:8080"); + properties.setUsermsgBaseUrl(PROD_BASE_URL); properties.getJumpBases().put("flow.task", "http://localhost:8088/"); - service = new MsgCenterProxyService(http, properties, new ObjectMapper()); + service = new MsgCenterProxyService(openCall, properties); } - private static com.fasterxml.jackson.databind.JsonNode json(String s) throws Exception { + private static JsonNode json(String s) throws Exception { return new ObjectMapper().readTree(s); } @Test - void list_rewritesJumpUrlForFlowTask() throws Exception { + void list_targetSpecStripsUsermsgCtx_forPerTargetSign() { + // 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(null); + + service.list(201, 1, 10, null); + + ArgumentCaptor spec = ArgumentCaptor.forClass(TargetSpec.class); + verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)); + assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost()); + assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名"); + } + + @Test + void list_buildsQueryParams_andRewritesJumpUrl() throws Exception { properties.getAppNames().put("100617", "通知公告"); - when(http.get(contains("/oapi/msg/list?userId=201"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":2,\"content\":[" + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\"," @@ -60,7 +84,16 @@ class MsgCenterProxyServiceTest { + "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0," + "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}")); - Map out = service.list(201, 1, 10, null); + Map out = service.list(201, 2, 20, Boolean.TRUE); + + @SuppressWarnings("unchecked") + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); + assertEquals("201", query.getValue().get("userId")); + assertEquals("2", query.getValue().get("pageIndex")); + assertEquals("20", query.getValue().get("pageSize")); + assertEquals("true", query.getValue().get("onlyUnread")); + assertEquals(2, out.get("total")); java.util.List rows = (java.util.List) out.get("list"); Map first = (Map) rows.get(0); @@ -70,10 +103,23 @@ class MsgCenterProxyServiceTest { assertNull(((Map) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写"); } + @Test + void list_defaultsPageParams() { + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(null); + service.list(201, 0, 999, null); + @SuppressWarnings("unchecked") + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); + assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1"); + assertEquals("100", query.getValue().get("pageSize"), "页长上限 100"); + assertEquals("false", query.getValue().get("onlyUnread")); + } + @Test void list_appNameMappedFromAppKey() throws Exception { properties.getAppNames().put("100617", "通知公告"); - when(http.get(contains("/oapi/msg/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\"," + "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); @@ -81,34 +127,10 @@ class MsgCenterProxyServiceTest { assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); } - @Test - void wsTicket_returnsUrlWithTicket() throws Exception { - properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg"); - when(http.post(contains("/oapi/msg/ws/ticket"), any(Map.class), eq(com.fasterxml.jackson.databind.JsonNode.class))) - .thenReturn(json("{\"ticket\":\"abc123ticket\",\"expiresIn\":60}")); - String url = service.createWsTicket(201); - assertEquals("ws://localhost:8080/ws/msg?ticket=abc123ticket", url); - } - - @Test - void wsTicket_wsUrlNotConfigured_nullWithoutOutbound() { - properties.setUsermsgWsUrl(""); - assertNull(service.createWsTicket(201)); - verify(http, never()).post(anyString(), any(), any()); - } - - @Test - void wsTicket_userMsgDown_degradesNull() { - properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg"); - when(http.post(contains("/oapi/msg/ws/ticket"), any(Map.class), eq(com.fasterxml.jackson.databind.JsonNode.class))) - .thenThrow(new DHttpException("down", 0)); - assertNull(service.createWsTicket(201), "票据失败降级 null,前端不建立长连接"); - } - @Test void list_noBaseConfigured_jumpUrlNull() throws Exception { properties.getJumpBases().clear(); - when(http.get(contains("/oapi/msg/list"), eq(com.fasterxml.jackson.databind.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," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); Map out = service.list(201, 1, 10, null); @@ -118,18 +140,44 @@ class MsgCenterProxyServiceTest { @Test void list_userMsgDown_degradesToEmpty() { - when(http.get(contains("/oapi/msg/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) - .thenThrow(new DHttpException("DHttp request failed", 0)); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) + .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401")); Map out = service.list(201, 1, 10, null); assertEquals(0, out.get("total")); assertEquals(0, ((java.util.List) out.get("list")).size()); } + @Test + void wsTicket_returnsUrlWithTicket() throws Exception { + properties.setUsermsgWsUrl("ws://base.yunhaixingtong.com/usermsg/ws/msg"); + when(openCall.post(any(TargetSpec.class), eq("/oapi/msg/ws/ticket"), anyMap(), any(Map.class), + eq(JsonNode.class))) + .thenReturn(json("{\"ticket\":\"abc123ticket\",\"expiresIn\":60}")); + String url = service.createWsTicket(201); + assertEquals("ws://base.yunhaixingtong.com/usermsg/ws/msg?ticket=abc123ticket", url); + } + + @Test + void wsTicket_wsUrlNotConfigured_nullWithoutOutbound() { + properties.setUsermsgWsUrl(""); + assertNull(service.createWsTicket(201)); + verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any()); + } + + @Test + void wsTicket_userMsgDown_degradesNull() { + properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg"); + when(openCall.post(any(TargetSpec.class), eq("/oapi/msg/ws/ticket"), anyMap(), any(Map.class), + eq(JsonNode.class))) + .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401")); + assertNull(service.createWsTicket(201), "票据失败降级 null,前端不建立长连接"); + } + @Test void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception { - when(http.get(contains("/oapi/msg/unreadCount"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) .thenReturn(json("3")); - when(http.get(contains("/oapi/msg/todo/list"), eq(com.fasterxml.jackson.databind.JsonNode.class))) + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) .thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]")); Map out = service.summary(201, 1); @@ -137,8 +185,8 @@ class MsgCenterProxyServiceTest { assertEquals(1, ((java.util.List) out.get("todos")).size()); // 未读数不可达单独降级为 0,待办仍可用 - when(http.get(contains("/oapi/msg/unreadCount"), eq(com.fasterxml.jackson.databind.JsonNode.class))) - .thenThrow(new DHttpException("down", 0)); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) + .thenThrow(new OpenApiException("down")); Map out2 = service.summary(201, 1); assertEquals(0L, out2.get("unreadCount")); assertEquals(1, ((java.util.List) out2.get("todos")).size()); @@ -147,23 +195,60 @@ class MsgCenterProxyServiceTest { @Test void read_sendsOwnerScopedPayload() { service.read(201, java.util.Collections.singletonList(7L)); - verify(http).post(contains("/oapi/msg/read"), any(Map.class), eq(com.fasterxml.jackson.databind.JsonNode.class)); + verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), any(Map.class), + eq(JsonNode.class)); } @Test void disabled_degradesWithoutOutbound() { properties.setUsermsgBaseUrl(""); + when(openCall.hasTarget("usermsg")).thenReturn(false); assertEquals(0, service.list(201, 1, 10, null).get("total")); service.read(201, java.util.Collections.singletonList(7L)); - verify(http, never()).get(anyString(), any()); - verify(http, never()).post(anyString(), any(), any()); + verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any()); + verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any()); + } + + @Test + void disabled_fallsBackToDirectoryTarget_whenBaseUrlBlank() throws Exception { + // base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用 + properties.setUsermsgBaseUrl(""); + when(openCall.hasTarget("usermsg")).thenReturn(true); + when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("5")); + Map out = service.summary(201, null); + assertEquals(5L, out.get("unreadCount")); } @Test void summary_disabled_degradesToZero() { properties.setUsermsgBaseUrl(""); + when(openCall.hasTarget("usermsg")).thenReturn(false); Map out = service.summary(201, 1); assertEquals(0L, out.get("unreadCount")); assertEquals(0, ((java.util.List) out.get("todos")).size()); } + + @Test + void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception { + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("0")); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json("[]")); + service.summary(201, null); + service.summary(201, 3); + @SuppressWarnings("unchecked") + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(openCall, org.mockito.Mockito.times(2)) + .get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), query.capture(), eq(JsonNode.class)); + assertEquals(1, query.getAllValues().get(0).size(), "schId=null 不下发该参数"); + assertEquals("3", query.getAllValues().get(1).get("schId")); + } + + @Test + void malformedBaseUrl_disabledNotCrash() { + properties.setUsermsgBaseUrl("::::not-a-url"); + Map out = service.summary(201, 1); + assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出"); + } }