feat(msgcenter): 计数口径改总数 + 出参/筛选全面改用 appId(appKey 不再下发)
build / build-push (push) Successful in 54s

- summary 透传 sysTotal/bizTotal/approvalTotal 分类总数(含已读,UserMsg 新字段)
- 新代理端点 GET /msgCenter/countByApp([{appId, appName, total, unread}])替代 unreadCountByApp:UserMsg /oapi/msg/countByApp + 应用字典富化
- 新增应用字典(UserBase /oapi/app/list 按用户 5min TTL 缓存):appKey↔appId/name 映射,失败沿用旧值并进 30s 短路
- list 筛选参数 appKey→appId(代理翻译为 appKey 下行,字典查无返回空页);行出参 appKey 移除,替换为 appId/appName——appKey 属后端签名标识不再暴露前端
This commit is contained in:
chenxing
2026-10-08 16:37:15 +08:00
parent 18e8e978a6
commit 0e57022555
2 changed files with 144 additions and 27 deletions
@@ -41,17 +41,17 @@ public class MessageCenterController {
} }
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。 /** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
* 可选服务端筛选:msgType 0/1/2、appKey、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */ * 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
@GetMapping("msgCenter/list") @GetMapping("msgCenter/list")
public Map<String, Object> list(BoaUser authUserWithDept, public Map<String, Object> list(BoaUser authUserWithDept,
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "count", required = false) Integer count, @RequestParam(value = "count", required = false) Integer count,
@RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread, @RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread,
@RequestParam(value = "msgType", required = false) Integer msgType, @RequestParam(value = "msgType", required = false) Integer msgType,
@RequestParam(value = "appKey", required = false) String appKey, @RequestParam(value = "appId", required = false) String appId,
@RequestParam(value = "isRead", required = false) Integer isRead, @RequestParam(value = "isRead", required = false) Integer isRead,
@RequestParam(value = "keyword", required = false) String keyword) { @RequestParam(value = "keyword", required = false) String keyword) {
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appKey, isRead, keyword); return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword);
} }
/** 铃铛摘要:未读数 + 各应用待办角标 */ /** 铃铛摘要:未读数 + 各应用待办角标 */
@@ -60,10 +60,10 @@ public class MessageCenterController {
return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept)); return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept));
} }
/** 按来源应用未读数映射(消息中心「按应用」清单角标):{appKey: 未读数},无未读的 appKey 不出现 */ /** 按来源应用消息计数(消息中心「按应用」清单角标):[{appId, appName, total, unread}],总数含已读 */
@GetMapping("msgCenter/unreadCountByApp") @GetMapping("msgCenter/countByApp")
public Map<String, Long> unreadCountByApp(BoaUser authUserWithDept) { public java.util.List<java.util.Map<String, Object>> countByApp(BoaUser authUserWithDept) {
return proxy.unreadCountByApp(authUserWithDept.getId()); return proxy.countByApp(authUserWithDept.getId());
} }
/** /**
@@ -46,6 +46,9 @@ public class MsgCenterProxyService {
/** UserBase 查址失败短路窗口:列表逐行查址,下游停摆时避免每行都吃一次连接超时 */ /** UserBase 查址失败短路窗口:列表逐行查址,下游停摆时避免每行都吃一次连接超时 */
static final long USERBASE_RETRY_MS = 30_000L; static final long USERBASE_RETRY_MS = 30_000L;
/** 应用字典 TTL:appKey↔appId 映射按用户缓存,到期或失败即重拉/沿用旧值 */
static final long APP_DICT_TTL_MS = 300_000L;
/** 消息定位扫描上限:5 页 × 100 条(UserMsg 无按 id 直查端点,逐页扫描兜底) */ /** 消息定位扫描上限:5 页 × 100 条(UserMsg 无按 id 直查端点,逐页扫描兜底) */
static final int MSG_SCAN_PAGES = 5; static final int MSG_SCAN_PAGES = 5;
static final int MSG_SCAN_PAGE_SIZE = 100; static final int MSG_SCAN_PAGE_SIZE = 100;
@@ -56,6 +59,88 @@ public class MsgCenterProxyService {
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */ /** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
private volatile long userbaseDownUntil; private volatile long userbaseDownUntil;
/** 应用字典按用户缓存(volatile 整体替换,并发重拉无害) */
private final java.util.concurrent.ConcurrentHashMap<Integer, AppDict> appDictCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<Integer, Long> appDictAt = new java.util.concurrent.ConcurrentHashMap<>();
/** 应用字典条目:注册表 appId / appKey / 名称(UserBase BoaApp) */
private static final class AppDictEntry {
final int appId;
final String appKey;
final String name;
AppDictEntry(int appId, String appKey, String name) {
this.appId = appId;
this.appKey = appKey;
this.name = name;
}
}
/** 应用字典:byKey 正查(行富化/出参)+ byId 反查(appId 筛选参数翻译) */
private static final class AppDict {
static final AppDict EMPTY = new AppDict(java.util.Collections.emptyMap(), java.util.Collections.emptyMap());
final Map<String, AppDictEntry> byKey;
final Map<Integer, AppDictEntry> byId;
AppDict(Map<String, AppDictEntry> byKey, Map<Integer, AppDictEntry> byId) {
this.byKey = byKey;
this.byId = byId;
}
}
/**
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
*/
private AppDict appDict(Integer userId) {
if (userId == null) {
return AppDict.EMPTY;
}
AppDict cached = appDictCache.get(userId);
Long at = appDictAt.get(userId);
long now = System.currentTimeMillis();
if (cached != null && at != null && now - at < APP_DICT_TTL_MS) {
return cached;
}
if (userbaseDown()) {
return cached != null ? cached : AppDict.EMPTY;
}
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
JsonNode groups = openCall.get(TARGET_USERBASE, "/oapi/app/list", q, JsonNode.class);
userbaseDownUntil = 0L;
Map<String, AppDictEntry> byKey = new LinkedHashMap<>();
Map<Integer, AppDictEntry> byId = new LinkedHashMap<>();
if (groups != null && groups.isArray()) {
for (JsonNode group : groups) {
JsonNode apps = group.path("apps");
if (!apps.isArray()) {
continue;
}
for (JsonNode app : apps) {
if (app == null || !app.hasNonNull("id") || !app.hasNonNull("appKey")) {
continue;
}
AppDictEntry entry = new AppDictEntry(app.get("id").asInt(),
app.get("appKey").asText(), text(app, "name"));
byKey.put(entry.appKey, entry);
byId.put(entry.appId, entry);
}
}
}
AppDict dict = new AppDict(byKey, byId);
appDictCache.put(userId, dict);
appDictAt.put(userId, now);
return dict;
} catch (Exception e) {
userbaseDownUntil = now + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 应用字典不可达({}ms 内沿用旧字典/空)userId={}:{}",
USERBASE_RETRY_MS, userId, e.toString());
return cached != null ? cached : AppDict.EMPTY;
}
}
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) { public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) {
this.openCall = openCall; this.openCall = openCall;
this.properties = properties; this.properties = properties;
@@ -83,17 +168,18 @@ public class MsgCenterProxyService {
} }
/** /**
* 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl 与 showJump 判权出参。 * 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致): * 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
* msgType 0/1/2、appKey 精确、isRead 0/1、keyword 标题/内容模糊。 * msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。
*/ */
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread, public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread,
Integer msgType, String appKey, Integer isRead, String keyword) { Integer msgType, String appId, Integer isRead, String keyword) {
Map<String, Object> empty = emptyPage(); Map<String, Object> empty = emptyPage();
if (!enabled()) { if (!enabled()) {
return empty; return empty;
} }
PerRequestAuthz authz = new PerRequestAuthz(userId); PerRequestAuthz authz = new PerRequestAuthz(userId);
AppDict dict = appDict(userId);
try { try {
Map<String, String> query = new LinkedHashMap<>(); Map<String, String> query = new LinkedHashMap<>();
query.put("userId", String.valueOf(userId)); query.put("userId", String.valueOf(userId));
@@ -103,7 +189,13 @@ public class MsgCenterProxyService {
if (msgType != null) { if (msgType != null) {
query.put("msgType", String.valueOf(msgType)); query.put("msgType", String.valueOf(msgType));
} }
putIfText(query, "appKey", appKey); if (StringUtils.hasText(appId)) {
AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim()));
if (entry == null) {
return empty; // 字典查无此应用:视作该应用无消息
}
query.put("appKey", entry.appKey);
}
if (isRead != null) { if (isRead != null) {
query.put("isRead", String.valueOf(isRead)); query.put("isRead", String.valueOf(isRead));
} }
@@ -112,7 +204,7 @@ public class MsgCenterProxyService {
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")) {
rows.add(rewrite(row, authz)); rows.add(rewrite(row, authz, dict));
} }
} }
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
@@ -149,6 +241,10 @@ public class MsgCenterProxyService {
out.put("approvalUnread", vo.path("approvalUnread").asLong(0)); out.put("approvalUnread", vo.path("approvalUnread").asLong(0));
out.put("total", vo.path("total").asLong(0)); out.put("total", vo.path("total").asLong(0));
out.put("readCount", vo.path("readCount").asLong(0)); out.put("readCount", vo.path("readCount").asLong(0));
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径;旧版 UserMsg 无该字段时缺省,前端按降级处理
out.put("sysTotal", vo.path("sysTotal").asLong(0));
out.put("bizTotal", vo.path("bizTotal").asLong(0));
out.put("approvalTotal", vo.path("approvalTotal").asLong(0));
summaryFetched = true; summaryFetched = true;
} }
} catch (Exception e) { } catch (Exception e) {
@@ -189,26 +285,42 @@ public class MsgCenterProxyService {
} }
/** /**
* 按来源应用未读数映射(UserMsg /oapi/msg/unreadCountByApp,{appKey: 未读数}): * 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
* 消息中心「按应用」清单角标。口径同 unreadCount(过滤删除与过期),无未读的 appKey 不出现。 * [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
* 停机降级空 Map(铃铛不 500)。 * appId 取应用字典(按用户授权列表),字典查无(如授权已回收的来源应用)时 appId=null,前端不展示。
* 停机降级空列表。
*/ */
public Map<String, Long> unreadCountByApp(Integer userId) { public List<Map<String, Object>> countByApp(Integer userId) {
List<Map<String, Object>> empty = new ArrayList<>();
if (!enabled()) { if (!enabled()) {
return new LinkedHashMap<>(); return empty;
} }
try { try {
Map<String, String> q = new LinkedHashMap<>(); Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId)); q.put("userId", String.valueOf(userId));
JsonNode data = get("/oapi/msg/unreadCountByApp", q); JsonNode arr = get("/oapi/msg/countByApp", q);
Map<String, Long> out = new LinkedHashMap<>(); if (arr == null || !arr.isArray()) {
if (data != null && data.isObject()) { return empty;
data.fields().forEachRemaining(e -> out.put(e.getKey(), e.getValue().asLong(0L))); }
AppDict dict = appDict(userId);
List<Map<String, Object>> out = new ArrayList<>();
for (JsonNode a : arr) {
String appKey = text(a, "appKey");
if (appKey == null) {
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.path("total").asLong(0));
row.put("unread", a.path("unread").asLong(0));
out.add(row);
} }
return out; return out;
} catch (Exception e) { } catch (Exception e) {
log.warn("[msgcenter] UserMsg 按应用未读数不可达(降级空)userId={}:{}", userId, e.toString()); log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
return new LinkedHashMap<>(); return empty;
} }
} }
@@ -295,8 +407,12 @@ public class MsgCenterProxyService {
// ===== 出参改写 ===== // ===== 出参改写 =====
/** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用名补全 */ /**
private Map<String, Object> rewrite(JsonNode row, PerRequestAuthz authz) { * MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
* 应用标识对前端只出 appId/appName(应用字典富化):appKey 属后端签名标识不下发;
* 字典查无(授权已回收等)appId/appName 为 null,前端按类型标签降级。
*/
private Map<String, Object> rewrite(JsonNode row, PerRequestAuthz authz, AppDict dict) {
Map<String, Object> out = new LinkedHashMap<>(); Map<String, Object> out = new LinkedHashMap<>();
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null);
out.put("title", text(row, "title")); out.put("title", text(row, "title"));
@@ -305,9 +421,10 @@ public class MsgCenterProxyService {
out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null); out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null);
out.put("jumpPath", text(row, "jumpPath")); out.put("jumpPath", text(row, "jumpPath"));
out.put("jumpApp", text(row, "jumpApp")); out.put("jumpApp", text(row, "jumpApp"));
out.put("appKey", text(row, "appKey"));
String appKey = text(row, "appKey"); String appKey = text(row, "appKey");
out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey)); 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);
out.put("bizType", text(row, "bizType")); out.put("bizType", text(row, "bizType"));
out.put("bizId", text(row, "bizId")); out.put("bizId", text(row, "bizId"));
out.put("sendTime", text(row, "sendTime")); out.put("sendTime", text(row, "sendTime"));