fix(msgcenter): 代理出站迁 YschOpenCall,修复目标 ctx 签名不剥导致的 UserMsg 401
build / build-push (push) Successful in 58s
build / build-push (push) Successful in 58s
160 事故:MsgCenterProxyService 走共享 IDHttp 出站,AppSignInterceptor 只剥本应用 base-path(/userbase),打 usermsg-service:10009/usermsg 时签名路径多 /usermsg 前缀, UserMsg AppAuthFilter 剥 ctx 后验签不一致 → 401 → 列表/未读/WS 票据全部静默降级空。 修复:改 YschOpenCall 泛化直调(Tier 0),usermsg-base-url 解析为 TargetSpec (host + /usermsg ctx),per-target 剥离后签名与服务端对齐;base-url 未配时回落 服务目录 usermsg。新增回归断言:TargetSpec.basePath==/usermsg 且调用 path 为 ctx 裸路径。测试 16/16 绿。
This commit is contained in:
@@ -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)。
|
||||
* <p>
|
||||
* 出站通道 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<TargetSpec> 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<String, Object> out = service.list(201, 1, 10, null);
|
||||
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Map<String, String>> 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<Map<String, String>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> out = service.summary(201, null);
|
||||
assertEquals(5L, out.get("unreadCount"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void summary_disabled_degradesToZero() {
|
||||
properties.setUsermsgBaseUrl("");
|
||||
when(openCall.hasTarget("usermsg")).thenReturn(false);
|
||||
Map<String, Object> 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<Map<String, String>> 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<String, Object> out = service.summary(201, 1);
|
||||
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user