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)); + } }