fix(msgcenter): 代理出站迁 YschOpenCall,修复目标 ctx 签名不剥导致的 UserMsg 401
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:
chenxing
2026-09-20 00:48:05 +08:00
parent 5f5ac3db71
commit 8492fba06b
2 changed files with 182 additions and 74 deletions
@@ -1,8 +1,8 @@
package com.yunhai.userhome.service; package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.utils.http.IDHttp; import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.userhome.config.MsgCenterProperties; import com.yunhai.userhome.config.MsgCenterProperties;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -15,40 +15,55 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):IDHttp 出站自动签名(yh.app-auth 身份 * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
* appKey=100001,UserMsg trusted-apps 已含),UserHome 前端不直连 UserMsg。 * (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
* UserHome 前端不直连 UserMsg。
* <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 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath * <li>jumpUrl 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath
* (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。</li> * (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。</li>
* </ul> * </ul>
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
* 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 本地表链路不动。 * UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
*/ */
@Slf4j @Slf4j
@Service @Service
public class MsgCenterProxyService { public class MsgCenterProxyService {
private final IDHttp http; private static final String TARGET_USERMSG = "usermsg";
private final MsgCenterProperties properties;
private final ObjectMapper objectMapper;
public MsgCenterProxyService(IDHttp http, MsgCenterProperties properties, ObjectMapper objectMapper) { private final YschOpenCall openCall;
this.http = http; private final MsgCenterProperties properties;
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) {
this.openCall = openCall;
this.properties = properties; this.properties = properties;
this.objectMapper = objectMapper;
} }
private boolean enabled() { private boolean enabled() {
return StringUtils.hasText(properties.getUsermsgBaseUrl()); return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG);
} }
private String url(String pathAndQuery) { /** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */
String base = properties.getUsermsgBaseUrl().trim(); private JsonNode get(String path, Map<String, String> query) {
while (base.endsWith("/")) { String base = properties.getUsermsgBaseUrl();
base = base.substring(0, base.length() - 1); 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<String, String> 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 */ /** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl */
@@ -58,10 +73,12 @@ public class MsgCenterProxyService {
return empty; return empty;
} }
try { try {
JsonNode data = http.get(url("/oapi/msg/list?userId=" + userId Map<String, String> query = new LinkedHashMap<>();
+ "&pageIndex=" + (pageIndex == null || pageIndex < 1 ? 1 : pageIndex) query.put("userId", String.valueOf(userId));
+ "&pageSize=" + (pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100)) query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex));
+ "&onlyUnread=" + Boolean.TRUE.equals(onlyUnread)), JsonNode.class); 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<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")) {
@@ -87,14 +104,20 @@ public class MsgCenterProxyService {
return out; return out;
} }
try { try {
JsonNode unread = http.get(url("/oapi/msg/unreadCount?userId=" + userId), JsonNode.class); Map<String, String> 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()); out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong());
} catch (Exception e) { } catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
} }
try { try {
JsonNode todos = http.get(url("/oapi/msg/todo/list?userId=" + userId Map<String, String> q = new LinkedHashMap<>();
+ (schId == null ? "" : "&schId=" + schId)), JsonNode.class); q.put("userId", String.valueOf(userId));
if (schId != null) {
q.put("schId", String.valueOf(schId));
}
JsonNode todos = get("/oapi/msg/todo/list", q);
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
if (todos != null && todos.isArray()) { if (todos != null && todos.isArray()) {
for (JsonNode t : todos) { for (JsonNode t : todos) {
@@ -119,7 +142,7 @@ public class MsgCenterProxyService {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("msgIds", msgIds); body.put("msgIds", msgIds);
body.put("userId", userId); 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<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("userId", userId); 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 { try {
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("userId", userId); 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(); String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText();
if (!StringUtils.hasText(ticket)) { if (!StringUtils.hasText(ticket)) {
return null; return null;
@@ -1,22 +1,24 @@
package com.yunhai.userhome.service; package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.http.DHttpException; import com.yunhai.platform.openapi.core.OpenApiException;
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 com.yunhai.userhome.config.MsgCenterProperties;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.HashMap; 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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -28,31 +30,53 @@ import static org.mockito.Mockito.when;
* (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与
* jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。 * jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。
* UserMsg 不可达降级空结果(铃铛不 500)。 * 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 { 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 MsgCenterProperties properties;
private MsgCenterProxyService service; private MsgCenterProxyService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
http = mock(IDHttp.class); openCall = mock(YschOpenCall.class);
properties = new MsgCenterProperties(); properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl("http://localhost:8080"); properties.setUsermsgBaseUrl(PROD_BASE_URL);
properties.getJumpBases().put("flow.task", "http://localhost:8088/"); 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); return new ObjectMapper().readTree(s);
} }
@Test @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", "通知公告"); 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\":[" .thenReturn(json("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
+ "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\"," + "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\","
@@ -60,7 +84,16 @@ class MsgCenterProxyServiceTest {
+ "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0," + "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0,"
+ "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}")); + "\"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")); 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);
@@ -70,10 +103,23 @@ class MsgCenterProxyServiceTest {
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写"); 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 @Test
void list_appNameMappedFromAppKey() throws Exception { void list_appNameMappedFromAppKey() throws Exception {
properties.getAppNames().put("100617", "通知公告"); 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\":\"通知公告\"," .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}")); + "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null);
@@ -81,34 +127,10 @@ class MsgCenterProxyServiceTest {
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); 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 @Test
void list_noBaseConfigured_jumpUrlNull() throws Exception { void list_noBaseConfigured_jumpUrlNull() throws Exception {
properties.getJumpBases().clear(); 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," .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<String, Object> out = service.list(201, 1, 10, null);
@@ -118,18 +140,44 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_userMsgDown_degradesToEmpty() { void list_userMsgDown_degradesToEmpty() {
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)))
.thenThrow(new DHttpException("DHttp request failed", 0)); .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null);
assertEquals(0, out.get("total")); assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size()); 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 @Test
void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception { 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")); .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}]")); .thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]"));
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, 1);
@@ -137,8 +185,8 @@ class MsgCenterProxyServiceTest {
assertEquals(1, ((java.util.List<?>) out.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out.get("todos")).size());
// 未读数不可达单独降级为 0,待办仍可用 // 未读数不可达单独降级为 0,待办仍可用
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)))
.thenThrow(new DHttpException("down", 0)); .thenThrow(new OpenApiException("down"));
Map<String, Object> out2 = service.summary(201, 1); Map<String, Object> out2 = service.summary(201, 1);
assertEquals(0L, out2.get("unreadCount")); assertEquals(0L, out2.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
@@ -147,23 +195,60 @@ class MsgCenterProxyServiceTest {
@Test @Test
void read_sendsOwnerScopedPayload() { void read_sendsOwnerScopedPayload() {
service.read(201, java.util.Collections.singletonList(7L)); 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 @Test
void disabled_degradesWithoutOutbound() { void disabled_degradesWithoutOutbound() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
assertEquals(0, service.list(201, 1, 10, null).get("total")); assertEquals(0, service.list(201, 1, 10, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L)); service.read(201, java.util.Collections.singletonList(7L));
verify(http, never()).get(anyString(), any()); verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any());
verify(http, never()).post(anyString(), any(), 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 @Test
void summary_disabled_degradesToZero() { void summary_disabled_degradesToZero() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, 1);
assertEquals(0L, out.get("unreadCount")); assertEquals(0L, out.get("unreadCount"));
assertEquals(0, ((java.util.List<?>) out.get("todos")).size()); 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 降级而非抛出");
}
} }