diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java index a46e953..eaee3e5 100644 --- a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java +++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java @@ -101,6 +101,102 @@ public class MsgCenterProxyService { } } + /** + * by-code 解析条目(UserBase /oapi/app/by-code/{appCode},值域=消息 jump_app)。 + * appId=注册表主键 id(2026-10-13 160 实测与 /oapi/app/list 同体系:notice 主键=100001、 + * 流程中心主键=100011/app_key=100009,无混体系风险)。 + */ + private static final class ByCodeEntry { + final int appId; + final String appKey; + final String name; + final String pcUrl; + final String code; + + ByCodeEntry(int appId, String appKey, String name, String pcUrl, String code) { + this.appId = appId; + this.appKey = appKey; + this.name = name; + this.pcUrl = pcUrl; + this.code = code; + } + } + + /** 查无哨兵(ConcurrentHashMap 不允许 null 值):脏 code 不重复出站 */ + private static final ByCodeEntry CODE_MISS = new ByCodeEntry(-1, null, null, null, null); + + /** by-code 缓存本体与时间戳(TTL 对齐 {@link #APP_DICT_TTL_MS}) */ + private final java.util.concurrent.ConcurrentHashMap byCodeCache = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.concurrent.ConcurrentHashMap byCodeAt = new java.util.concurrent.ConcurrentHashMap<>(); + + /** + * by-code 应用缓存(TTL 对齐 {@link #APP_DICT_TTL_MS}):一次调用同时取 name/appId/pcUrl, + * 消掉逐行 by-code 查址的 N+1(旧实现每行现查)。UserBase 不可达复用 + * {@link #userbaseDownUntil} 短路窗口;返回 null=不可达(调用方降级),返回 + * {@link #CODE_MISS}=查无(回落发送方口径)。 + */ + private ByCodeEntry lookupByCode(String appCode) { + if (appCode == null || appCode.isEmpty()) { + return CODE_MISS; + } + Long at = byCodeAt.get(appCode); + boolean fresh = at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS; + if (fresh) { + ByCodeEntry cached = byCodeCache.get(appCode); + return cached == null ? CODE_MISS : cached; + } + if (userbaseDown()) { + ByCodeEntry stale = byCodeCache.get(appCode); + return stale == null ? CODE_MISS : stale; + } + try { + JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-code/" + appCode, null, JsonNode.class); + userbaseDownUntil = 0L; + ByCodeEntry entry = app != null && app.hasNonNull("id") + ? new ByCodeEntry(app.get("id").asInt(), + app.hasNonNull("appKey") ? String.valueOf(app.get("appKey").asInt()) : null, + text(app, "name"), text(app, "pcUrl"), text(app, "appCode")) + : CODE_MISS; + byCodeCache.put(appCode, entry); + byCodeAt.put(appCode, System.currentTimeMillis()); + return entry; + } catch (Exception e) { + userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS; + log.warn("[msgcenter] UserBase by-code 解析不可达(降级回落发送方口径)code={}:{}", appCode, e.toString()); + return null; + } + } + + /** appKey→app_code 反查缓存(list 的 appId 双值翻译用;空串值=查无,退化为单值过滤) */ + private final java.util.concurrent.ConcurrentHashMap appCodeCache = new java.util.concurrent.ConcurrentHashMap<>(); + private final java.util.concurrent.ConcurrentHashMap appCodeAt = new java.util.concurrent.ConcurrentHashMap<>(); + + /** appKey→app_code 反查(/oapi/app/by-key,VO 含 appCode):首次查询后缓存,查无/不可达返回 null */ + private String appCodeByKey(String appKey) { + if (appKey == null || appKey.isEmpty()) { + return null; + } + Long at = appCodeAt.get(appKey); + if (at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS) { + return appCodeCache.get(appKey); + } + if (appCodeCache.containsKey(appKey) && userbaseDown()) { + return appCodeCache.get(appKey); + } + try { + JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-key/" + appKey, null, JsonNode.class); + userbaseDownUntil = 0L; + String code = app == null || app.isNull() ? null : text(app, "appCode"); + appCodeCache.put(appKey, code == null ? "" : code); + appCodeAt.put(appKey, System.currentTimeMillis()); + return StringUtils.hasText(code) ? code : null; + } catch (Exception e) { + userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS; + log.warn("[msgcenter] UserBase by-key 反查 app_code 不可达(退化单值过滤)appKey={}:{}", appKey, e.toString()); + return null; + } + } + /** * 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。 * 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。 @@ -237,7 +333,14 @@ public class MsgCenterProxyService { if (entry == null) { return empty; // 字典查无此应用:视作该应用无消息 } - query.setAppKey(entry.appKey); + // 归属口径双值过滤:该应用自发行桶值=app_key,流程中心代发行桶值=app_code + List appKeys = new ArrayList<>(); + appKeys.add(entry.appKey); + String code = appCodeByKey(entry.appKey); + if (StringUtils.hasText(code) && !code.equals(entry.appKey)) { + appKeys.add(code); + } + query.setAppKeys(appKeys); } query.setIsRead(isRead); query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null); @@ -327,7 +430,9 @@ public class MsgCenterProxyService { /** * 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName: * [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。 - * appId 取应用字典(按用户授权列表),字典查无(如授权已回收的来源应用)时 appId=null,前端不展示。 + * UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key): + * 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread + * (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。 * schId=当前身份学校口径(null=全部)。停机降级空列表。 */ public List> countByApp(Integer userId, Integer schId) { @@ -341,20 +446,49 @@ public class MsgCenterProxyService { return empty; } AppDict dict = appDict(userId); - List> out = new ArrayList<>(); + Map> merged = new LinkedHashMap<>(); for (AppMsgCount a : counts) { - String appKey = a == null ? null : a.getAppKey(); - if (appKey == null) { + String bucket = a == null ? null : a.getAppKey(); + if (bucket == null || bucket.isEmpty()) { continue; } - AppDictEntry entry = dict.byKey.get(appKey); - Map row = new LinkedHashMap<>(); - row.put("appId", entry == null ? null : entry.appId); - row.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name); - row.put("total", a.getTotal()); - row.put("unread", a.getUnread()); - out.add(row); + AppDictEntry entry = dict.byKey.get(bucket); + // 纯数字桶值不可能是 app_code,免一次注定查无的出站; + // 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏 + ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+") + ? lookupByCode(bucket) : null; + Integer appId; + String name; + if (entry != null) { + appId = entry.appId; + name = entry.name; + } else if (codeEntry != null && codeEntry != CODE_MISS) { + appId = codeEntry.appId; + name = codeEntry.name; + } else if (codeEntry == CODE_MISS) { + continue; // code 脏桶双查无:丢弃,与改造前一致 + } else { + appId = null; + name = properties.getAppNames().get(bucket); + } + // 合桶键=注册表主键 appId:自发桶(byKey)与代发桶(byCode)同应用并成一类目 + Map row = merged.get(appId); + if (row == null) { + row = new LinkedHashMap<>(); + row.put("appId", appId); + row.put("appName", name); + row.put("total", 0L); + row.put("unread", 0L); + merged.put(appId, row); + } + row.put("total", (Long) row.get("total") + a.getTotal()); + row.put("unread", (Long) row.get("unread") + a.getUnread()); } + List> out = new ArrayList<>(merged.values()); + out.sort((x, y) -> { + int c = Long.compare((Long) y.get("total"), (Long) x.get("total")); + return c != 0 ? c : Long.compare((Long) y.get("unread"), (Long) x.get("unread")); + }); return out; } catch (Exception e) { log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString()); @@ -451,8 +585,9 @@ public class MsgCenterProxyService { /** * MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。 - * 应用标识对前端只出 appId/appName(应用字典富化):appKey 属后端签名标识不下发; - * 字典查无(授权已回收等)appId/appName 为 null,前端按类型标签降级。 + * 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时 + * 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。 + * appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。 */ private Map rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) { Map out = new LinkedHashMap<>(); @@ -463,10 +598,33 @@ public class MsgCenterProxyService { out.put("jumpType", row.getJumpType()); out.put("jumpPath", row.getJumpPath()); out.put("jumpApp", row.getJumpApp()); - String appKey = row.getAppKey(); - AppDictEntry entry = appKey == null ? null : dict.byKey.get(appKey); - out.put("appId", entry == null ? null : entry.appId); - out.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name); + // 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用; + // jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。 + // 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。 + String jumpApp = row.getJumpApp(); + String ownerCode = jumpApp == null ? null : jumpApp.trim(); + AppDictEntry entry = null; + Integer appId = null; + String appName = null; + if (StringUtils.hasText(ownerCode)) { + ByCodeEntry codeEntry = lookupByCode(ownerCode); + if (codeEntry != null && codeEntry != CODE_MISS) { + appId = codeEntry.appId; + appName = codeEntry.name; + } + } + if (appId == null) { + String appKey = row.getAppKey(); + entry = appKey == null ? null : dict.byKey.get(appKey); + if (entry != null) { + appId = entry.appId; + appName = entry.name; + } else { + appName = properties.getAppNames().get(appKey); + } + } + out.put("appId", appId); + out.put("appName", appName); out.put("bizType", row.getBizType()); out.put("bizId", row.getBizId()); out.put("sendTime", row.getSendTime()); @@ -475,9 +633,8 @@ public class MsgCenterProxyService { // showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改); // jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行 Boolean allowed = null; - String jumpApp = row.getJumpApp(); - if (jumpUrl != null && StringUtils.hasText(jumpApp)) { - allowed = authz.authorized(jumpApp.trim()); + if (jumpUrl != null && StringUtils.hasText(ownerCode)) { + allowed = authz.authorized(ownerCode); } boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed); if (Boolean.FALSE.equals(allowed)) { @@ -531,9 +688,14 @@ public class MsgCenterProxyService { return base + "#" + p; } - /** 注册表 pc_url 查询(by-code 主查)。UserBase 不可达/查无 → null,失败进短路窗口 */ + /** + * 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl + * (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变, + * 查不到/无缓存才隐藏跳转入口)。 + */ private String pcUrlByCode(String appCode) { - return lookupPcUrl("/oapi/app/by-code/" + appCode); + ByCodeEntry entry = lookupByCode(appCode.trim()); + return entry == null || entry == CODE_MISS ? null : entry.pcUrl; } /** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */ diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java index 1619619..ff36162 100644 --- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java +++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java @@ -124,6 +124,12 @@ class MsgCenterProxyServiceTest { + "\",\"name\":\"" + name + "\"}]}]")); } + /** 应用字典 stub(多应用形态):合桶/双值翻译验证两桶同归一应用 */ + private void stubAppDictRaw(String groupsJson) throws Exception { + when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class))) + .thenReturn(json(groupsJson)); + } + // ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) ===== @Test @@ -482,7 +488,8 @@ class MsgCenterProxyServiceTest { ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class)); assertEquals("2", query.getValue().get("msgType")); - assertEquals("100019", query.getValue().get("appKey"), "appId 去空白经字典翻译为 appKey 下行"); + assertEquals("100019", query.getValue().get("appKeys"), "appId 去空白经字典翻译为归属口径 appKeys 下行"); + assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方 appKey 口径"); assertEquals("0", query.getValue().get("isRead")); assertEquals("请假", query.getValue().get("keyword")); @@ -649,6 +656,88 @@ class MsgCenterProxyServiceTest { return (Map) ((java.util.List) out.get("list")).get(0); } + // ===== 消息归属子应用收口(2026-10-13):出参归属换源、countByApp 合桶、list 双值翻译 ===== + + private void stubMsgList(String contentJson) throws Exception { + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class))) + .thenReturn(dto("{\"total\":1,\"content\":" + contentJson + "}", MsgListPage.class)); + } + + /** 换源三态①:jumpApp 可解析 → appId/appName 指向业务方应用(页面属主优先于发送方) */ + @Test + void list_jumpAppResolvable_ownershipRewrittenToBusinessApp() throws Exception { + stubAppDict(100011, "100009", "流程中心"); + stubUserbaseGet("/oapi/app/by-code/notice", + "{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\",\"pcUrl\":\"http://localhost:8077\"}"); + stubMsgList("[{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/detail?serid=59\"," + + "\"appKey\":\"100009\",\"jumpApp\":\"notice\",\"bizType\":\"flow.task\",\"isRead\":0}]"); + Map first = firstRow(); + assertEquals(100001, first.get("appId"), "归属出参跟页面属主,不跟发送方"); + assertEquals("通知公告", first.get("appName")); + } + + /** 换源三态②:jumpApp 脏值查无 → 回落发送方字典口径(现状对齐,appId/appName 走 byKey) */ + @Test + void list_jumpAppDirty_fallsBackToSenderDictEntry() throws Exception { + stubAppDict(100011, "100009", "流程中心"); + stubUserbaseGet("/oapi/app/by-code/svc_item", null); + stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":\"svc_item\",\"bizType\":\"flow.task\",\"isRead\":0}]"); + Map first = firstRow(); + assertEquals(100011, first.get("appId"), "脏 jumpApp 回落发送方 appKey 字典"); + assertEquals("流程中心", first.get("appName")); + } + + /** 换源三态③:jumpApp 为空(办结/催办按拍板留流程中心)→ 不调 by-code,直接发送方口径 */ + @Test + void list_jumpAppAbsent_noByCodeCall_senderOwnership() throws Exception { + stubAppDict(100011, "100009", "流程中心"); + stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":null,\"bizType\":\"flow.result\",\"isRead\":0}]"); + Map first = firstRow(); + assertEquals(100011, first.get("appId")); + assertEquals("流程中心", first.get("appName")); + verify(openCall, never()).get(eq("userbase"), contains("/oapi/app/by-code/"), isNull(), eq(JsonNode.class)); + } + + /** countByApp 合桶:notice 代发桶 + 100001 自发桶解析到同一注册表主键 → 并一行、计数相加 */ + @Test + void countByApp_mergesCodeAndKeyBucketsIntoOneApp() throws Exception { + stubAppDict(100001, "100001", "通知公告"); + stubUserbaseGet("/oapi/app/by-code/notice", + "{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\"}"); + when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class))) + .thenReturn(listDto("[{\"appKey\":\"notice\",\"total\":2,\"unread\":2}," + + "{\"appKey\":\"100001\",\"total\":1,\"unread\":0}," + + "{\"appKey\":\"100009\",\"total\":3,\"unread\":1}]", AppMsgCount.class)); + + java.util.List> rows = service.countByApp(201, null); + + assertEquals(2, rows.size(), "notice 与 100001 两桶合并为通知公告一类目"); + Map notice = rows.stream().filter(r -> Integer.valueOf(100001).equals(r.get("appId"))) + .findFirst().orElseThrow(IllegalStateException::new); + assertEquals("通知公告", notice.get("appName")); + assertEquals(3L, notice.get("total")); + assertEquals(2L, notice.get("unread")); + assertEquals(3L, rows.get(0).get("total"), "合并后仍按总数降序"); + } + + /** list 的 appId 筛选翻译改双值:app_key + app_code 下行 appKeys,不再下行 appKey */ + @Test + void list_appIdFilter_translatesToDualOwnerValues() throws Exception { + stubAppDictRaw("[{\"apps\":[{\"id\":100001,\"appKey\":\"100001\",\"name\":\"通知公告\"}," + + "{\"id\":100011,\"appKey\":\"100009\",\"name\":\"流程中心\"}]}]"); + stubUserbaseGet("/oapi/app/by-key/100001", "{\"id\":100001,\"appKey\":100001,\"appCode\":\"notice\"}"); + when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class))) + .thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class)); + + service.list(201, 1, 10, null, null, "100001", null, null, null); + + @SuppressWarnings("unchecked") + ArgumentCaptor> query = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class)); + assertEquals("100001,notice", query.getValue().get("appKeys"), "自发桶+代发桶一次筛全"); + assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方口径"); + } + /** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */ @Test void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {