feat(msg): 消息归属换源页面属主——rewrite 出参 appId/appName 按 jumpApp by-code 解析(缓存+N+1 消除,查无/不可达回落发送方);countByApp 双桶按注册表 appId 合并;类目筛选翻译 app_key+app_code 双值(SDK appKeys -70);任务书:消息归属子应用收口-20261013
build / build-push (push) Successful in 1m22s

This commit is contained in:
chenxing
2026-10-13 11:39:40 +08:00
parent 175dacc9ad
commit 9370429455
2 changed files with 275 additions and 24 deletions
@@ -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<String, ByCodeEntry> byCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> 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<String, String> appCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> 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<String> 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<Map<String, Object>> countByApp(Integer userId, Integer schId) {
@@ -341,20 +446,49 @@ public class MsgCenterProxyService {
return empty;
}
AppDict dict = appDict(userId);
List<Map<String, Object>> out = new ArrayList<>();
Map<Object, Map<String, Object>> 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<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
Map<String, Object> 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) */
@@ -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<Map<String, String>> 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<Map<String, Object>> rows = service.countByApp(201, null);
assertEquals(2, rows.size(), "notice 与 100001 两桶合并为通知公告一类目");
Map<String, Object> 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<Map<String, String>> 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 {