From 15be1ffabb805541e653d575e297d0921da321c0 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 13:29:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(msgcenter):=20jumpUrl=20=E5=9F=BA=E5=9D=80?= =?UTF-8?q?=E6=94=B9=E8=B5=B0=E5=BA=94=E7=94=A8=E6=B3=A8=E5=86=8C=E8=A1=A8?= =?UTF-8?q?=E2=80=94=E2=80=94by-code=20=E4=B8=BB=E6=9F=A5/by-key=20?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=8F=96=20pc=5Furl=EF=BC=88=C2=A73.2=20?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=E4=B8=A2=20fragment=20=E6=8B=BC=20#?= =?UTF-8?q?=EF=BC=89=EF=BC=8Cjump-bases=20=E9=99=8D=E4=B8=BA=E5=85=9C?= =?UTF-8?q?=E5=BA=95=20+=2030s=20=E4=B8=8D=E5=8F=AF=E8=BE=BE=E7=9F=AD?= =?UTF-8?q?=E8=B7=AF=EF=BC=9B=E9=99=84=20findMessage=20=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D=E6=96=B9=E6=B3=95=EF=BC=88=E4=BE=9B=20B3=20?= =?UTF-8?q?=E7=9B=B4=E6=89=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MsgCenterProxyService.java | 109 ++++++++++++++++-- .../service/MsgCenterProxyServiceTest.java | 72 ++++++++++++ 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java index fb6104b..30b2165 100644 --- a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java +++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java @@ -21,8 +21,8 @@ import java.util.Map; * * 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥本应用 * base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401, @@ -36,10 +36,21 @@ import java.util.Map; public class MsgCenterProxyService { private static final String TARGET_USERMSG = "usermsg"; + private static final String TARGET_USERBASE = "userbase"; + + /** UserBase 查址失败短路窗口:列表逐行查址,下游停摆时避免每行都吃一次连接超时 */ + static final long USERBASE_RETRY_MS = 30_000L; + + /** 消息定位扫描上限:5 页 × 100 条(UserMsg 无按 id 直查端点,逐页扫描兜底) */ + static final int MSG_SCAN_PAGES = 5; + static final int MSG_SCAN_PAGE_SIZE = 100; private final YschOpenCall openCall; private final MsgCenterProperties properties; + /** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */ + private volatile long userbaseDownUntil; + public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) { this.openCall = openCall; this.properties = properties; @@ -155,6 +166,41 @@ public class MsgCenterProxyService { post("/oapi/msg/readAll", new HashMap<>(), body); } + /** + * 按 msgId 定位当前登录人的消息(卡片直批 B3 用)。归属校验=按登录人分页拉列表后命中: + * 他人消息/已删除/已过期一律查无,统一返回 null(不区分原因,防越权探测)。 + */ + public JsonNode findMessage(Integer userId, Long msgId) { + if (!enabled() || msgId == null) { + return null; + } + for (int page = 1; page <= MSG_SCAN_PAGES; page++) { + try { + Map query = new LinkedHashMap<>(); + query.put("userId", String.valueOf(userId)); + query.put("pageIndex", String.valueOf(page)); + query.put("pageSize", String.valueOf(MSG_SCAN_PAGE_SIZE)); + JsonNode data = get("/oapi/msg/list", query); + if (data == null || !data.path("content").isArray()) { + return null; + } + for (JsonNode row : data.get("content")) { + if (row.has("msgId") && row.get("msgId").asLong() == msgId) { + return row; + } + } + if (data.get("content").size() < MSG_SCAN_PAGE_SIZE + || data.path("total").asLong() <= (long) page * MSG_SCAN_PAGE_SIZE) { + return null; + } + } catch (Exception e) { + log.warn("[msgcenter] UserMsg 消息定位不可达 msgId={}:{}", msgId, e.toString()); + return null; + } + } + return null; + } + /** * WS 握手票据:以应用身份为登录人换一次性票据(UserMsg /oapi/msg/ws/ticket), * 拼上浏览器可达的 WS 端点返回。未配置 WS 地址 / UserMsg 不可达 → null(前端维持轮询兜底表现)。 @@ -204,10 +250,15 @@ public class MsgCenterProxyService { } /** - * 基址 + "/#" + jumpPath。基址查询优先「页面归属应用」(jumpApp,代发场景页面属业务方时由 - * 发送方声明),缺省回落 bizType(=发送方自己的消息分类);都查不到返回 null(前端不渲染跳转)。 - * jumpPath 已是绝对 URL(http/https)时直接透传——兼容存量已落库的绝对地址消息,且不再 - * 重复拼基址产出双前缀畸形链接。非应用内跳转(jumpType≠1)/无路径 → null。 + * 基址解析(B2 跳转基址统一):jumpType=1 且 jumpPath 非空时—— + *
    + *
  1. 绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);
  2. + *
  3. 注册表:归属应用 jumpApp(值域=app_code)主查 → appKey 数字兜底(自发场景发送方=页面归属), + * 取 pc_url 按 §3.2 归一化(丢 fragment、保留 index.htm(l))后拼 "#";
  4. + *
  5. 兜底:jump-bases 手工表(jumpApp/bizType 双维度 key),join 形态维持旧 "/#";
  6. + *
  7. 均无 → null(前端不渲染跳转入口)。
  8. + *
+ * 注册表是多端地址(pc/app/小程序/钉钉)唯一可持续来源,手工表仅为过渡兜底。 */ private String jumpUrl(JsonNode row) { Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null; @@ -221,6 +272,24 @@ public class MsgCenterProxyService { if (path.startsWith("http://") || path.startsWith("https://")) { return path; } + String p = path.startsWith("/") ? path : "/" + path; + String appKey = text(row, "appKey"); + + if (!userbaseDown()) { + String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim()) : null; + if (!StringUtils.hasText(pcUrl) && StringUtils.hasText(appKey)) { + pcUrl = pcUrlByKey(appKey.trim()); + } + if (StringUtils.hasText(pcUrl)) { + String base = pcUrl.trim(); + int hash = base.indexOf('#'); + if (hash >= 0) { + base = base.substring(0, hash); + } + return base + "#" + p; + } + } + String base = StringUtils.hasText(jumpApp) ? properties.getJumpBases().get(jumpApp.trim()) : null; if (!StringUtils.hasText(base) && StringUtils.hasText(bizType)) { @@ -233,10 +302,36 @@ public class MsgCenterProxyService { while (b.endsWith("/")) { b = b.substring(0, b.length() - 1); } - String p = path.startsWith("/") ? path : "/" + path; return b + "/#" + p; } + /** 注册表 pc_url 查询(by-code 主查)。UserBase 不可达/查无 → null 走手工表兜底,失败进短路窗口 */ + private String pcUrlByCode(String appCode) { + return lookupPcUrl("/oapi/app/by-code/" + appCode); + } + + /** 注册表 pc_url 查询(by-key 兜底,appKey 是 app_key 列非主键 id) */ + private String pcUrlByKey(String appKey) { + return lookupPcUrl("/oapi/app/by-key/" + appKey); + } + + private String lookupPcUrl(String path) { + try { + JsonNode app = openCall.get(TARGET_USERBASE, path, null, JsonNode.class); + boolean ok = app != null && app.hasNonNull("pcUrl") && StringUtils.hasText(app.get("pcUrl").asText()); + userbaseDownUntil = 0L; + return ok ? app.get("pcUrl").asText() : null; + } catch (Exception e) { + userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS; + log.warn("[msgcenter] UserBase 查址不可达({}ms 内走手工表兜底)path={}:{}", USERBASE_RETRY_MS, path, e); + return null; + } + } + + private boolean userbaseDown() { + return System.currentTimeMillis() < userbaseDownUntil; + } + private static Map emptyPage() { Map out = new HashMap<>(); out.put("total", 0); diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java index 673f6c5..51ea101 100644 --- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java +++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java @@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -330,4 +331,75 @@ class MsgCenterProxyServiceTest { Map out = service.summary(201, 1); assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出"); } + + // ===== B2(跳转基址统一):注册表 pc_url 主链路 ===== + + private void stubUserbaseGet(String path, String body) throws Exception { + when(openCall.get(eq("userbase"), eq(path), isNull(), eq(JsonNode.class))) + .thenReturn(body == null ? null : json(body)); + } + + private Map firstRow() throws Exception { + Map out = service.list(201, 1, 10, null); + return (Map) ((java.util.List) out.get("list")).get(0); + } + + /** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */ + @Test + void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception { + stubUserbaseGet("/oapi/app/by-code/flow-center", + "{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\"," + + "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}"); + 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/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}")); + Map first = firstRow(); + assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"), + "丢 fragment + # 拼接,不产第二个 #"); + } + + /** by-code 查无 → by-key 兜底(app_key 列,非主键 id——100009 是流程中心不是课堂作业) */ + @Test + void list_byCodeMiss_fallsBackToByKey() throws Exception { + stubUserbaseGet("/oapi/app/by-code/notice", null); + stubUserbaseGet("/oapi/app/by-key/100001", + "{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\"," + + "\"pcUrl\":\"http://base.yunhaixingtong.com/notice/front/index.htm\"}"); + 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/1\",\"appKey\":\"100001\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + Map first = firstRow(); + assertEquals("http://base.yunhaixingtong.com/notice/front/index.htm#/list/1", first.get("jumpUrl")); + } + + /** 注册表两路都查无且无手工表 → null(前端不渲染跳转,不做猜测) */ + @Test + void list_registryMiss_noManualBase_jumpUrlNull() throws Exception { + properties.getJumpBases().clear(); + stubUserbaseGet("/oapi/app/by-code/notice", null); + 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))) + .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + + "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}")); + assertNull(firstRow().get("jumpUrl")); + } + + /** UserBase 停摆:首行两次超时后进 30s 短路窗口,后续行不再出站、直接手工表兜底 */ + @Test + void list_userbaseDown_shortCircuitFallsBackToManualBases() throws Exception { + when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class))) + .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))) + .thenReturn(json("{\"total\":2,\"content\":[" + + "{\"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}]}")); + Map out = service.list(201, 1, 10, null); + java.util.List rows = (java.util.List) out.get("list"); + assertEquals("http://localhost:8088/#/task/1", ((Map) rows.get(0)).get("jumpUrl"), "首行走手工表兜底"); + assertEquals("http://localhost:8088/#/task/2", ((Map) rows.get(1)).get("jumpUrl")); + // 首行仅 by-key 一次(无 jumpApp 不查 by-code)→ 短路后第二行 0 次,共 1 次 + verify(openCall, org.mockito.Mockito.times(1)).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)); + } }