package com.yunhai.userhome.service; import com.fasterxml.jackson.databind.JsonNode; import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.msg.OpenApiMsgClient; import com.yunhai.platform.openapi.msg.impl.DefaultOpenApiMsgClient; import com.yunhai.platform.openapi.msg.model.AppMsgCount; import com.yunhai.platform.openapi.msg.model.MsgListItem; import com.yunhai.platform.openapi.msg.model.MsgListPage; import com.yunhai.platform.openapi.msg.model.MsgListQuery; import com.yunhai.platform.openapi.msg.model.UserMsgSummary; import com.yunhai.userhome.config.MsgCenterProperties; import com.yunhai.userhome.entity.UserCurrentRole; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; /** * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调 * (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path), * UserHome 前端不直连 UserMsg。 * * 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥本应用 * base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401, * 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为 * {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。 * 目标地址来源:usermsg-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址), * 其次服务目录 usermsg(yh.open.services / 枚举兜底)。 * UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。 */ @Slf4j @Service 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; /** 应用字典 TTL:appKey↔appId 映射按用户缓存,到期或失败即重拉/沿用旧值 */ static final long APP_DICT_TTL_MS = 300_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; private final IUserCurrentRoleService userCurrentRoleService; /** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */ private volatile long userbaseDownUntil; /** 应用字典按用户缓存(volatile 整体替换,并发重拉无害) */ private final java.util.concurrent.ConcurrentHashMap appDictCache = new java.util.concurrent.ConcurrentHashMap<>(); private final java.util.concurrent.ConcurrentHashMap 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 byKey; final Map byId; AppDict(Map byKey, Map byId) { this.byKey = byKey; this.byId = byId; } } /** * 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。 */ 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 q = new LinkedHashMap<>(); q.put("userId", String.valueOf(userId)); JsonNode groups = openCall.get(TARGET_USERBASE, "/oapi/app/list", q, JsonNode.class); userbaseDownUntil = 0L; Map byKey = new LinkedHashMap<>(); Map 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, IUserCurrentRoleService userCurrentRoleService) { this.openCall = openCall; this.properties = properties; this.userCurrentRoleService = userCurrentRoleService; } private boolean enabled() { return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG); } /** * 消息读取口径 schId(按学校隔离读取侧,R2):取当前激活身份所属学校 * ({@code userCurrentRoleService.findById} → UserBase /oapi/identity/current), * 严禁用会话主学校(跨校切换会错)。解析为 0(平台/超管)或身份链不可达时返回 null * =不下发过滤(看全部,D5,且与改造前行为一致)。 */ public Integer resolveReadSchId(Integer userId) { if (userId == null) { return null; } try { return userCurrentRoleService.findById(userId) .map(UserCurrentRole::getSchId) .filter(schId -> schId != null && schId > 0) .orElse(null); } catch (Exception e) { log.warn("[msgcenter] 当前身份学校解析不可达(降级不过滤=全量口径)userId={}:{}", userId, e.toString()); return null; } } /** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx);否则类型化读方法按服务目录 usermsg 寻址 */ private OpenApiMsgClient msgClient() { String base = properties.getUsermsgBaseUrl(); return StringUtils.hasText(base) ? new DefaultOpenApiMsgClient(openCall, TargetSpec.fromUrl(base.trim())) : new DefaultOpenApiMsgClient(openCall); } /** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */ private JsonNode get(String path, Map query) { String base = properties.getUsermsgBaseUrl(); if (StringUtils.hasText(base)) { return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class); } return openCall.get(TARGET_USERMSG, path, query, JsonNode.class); } private JsonNode post(String path, Map query, Object body) { String base = properties.getUsermsgBaseUrl(); if (StringUtils.hasText(base)) { return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class); } return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class); } /** * 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。 * 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致): * msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。 * schId=当前身份学校口径({@link #resolveReadSchId}),null=不过滤(平台/超管视角,看全部)。 */ public Map list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread, Integer msgType, String appId, Integer isRead, String keyword, Integer schId) { Map empty = emptyPage(); if (!enabled()) { return empty; } PerRequestAuthz authz = new PerRequestAuthz(userId); AppDict dict = appDict(userId); try { MsgListQuery query = new MsgListQuery(); query.setUserId(userId); query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex); query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100)); query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread)); query.setMsgType(msgType); if (StringUtils.hasText(appId)) { AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim())); if (entry == null) { return empty; // 字典查无此应用:视作该应用无消息 } // 归属口径双值过滤:该应用自发行桶值=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); query.setSchId(schId); MsgListPage page = msgClient().list(query); List> rows = new ArrayList<>(); if (page != null && page.getContent() != null) { for (MsgListItem item : page.getContent()) { rows.add(rewrite(item, authz, dict)); } } Map out = new HashMap<>(); out.put("total", page == null ? 0 : page.getTotal()); out.put("list", rows); return out; } catch (Exception e) { log.warn("[msgcenter] UserMsg 列表不可达(降级空)userId={}:{}", userId, e.toString()); return empty; } } /** * 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。 * 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary * (消息中心左栏一次喂饱),按 msgSchId 学校口径取数;UserMsg 不可达/字段缺省时降级 unreadCount 单查。 * * @param msgSchId 消息读取口径(当前激活身份学校,null=全部) * @param todoSchId 待办角标口径(沿用会话主学校,链路口径不变,见类注释) */ public Map summary(Integer userId, Integer msgSchId, Integer todoSchId) { Map out = new HashMap<>(); out.put("unreadCount", 0L); out.put("todos", new ArrayList<>()); if (!enabled()) { return out; } boolean summaryFetched = false; try { UserMsgSummary vo = msgClient().summary(userId, msgSchId); if (vo != null) { out.put("unreadCount", vo.getUnreadCount()); out.put("sysUnread", vo.getSysUnread()); out.put("bizUnread", vo.getBizUnread()); out.put("approvalUnread", vo.getApprovalUnread()); out.put("total", vo.getTotal()); out.put("readCount", vo.getReadCount()); // 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径 out.put("sysTotal", vo.getSysTotal()); out.put("bizTotal", vo.getBizTotal()); out.put("approvalTotal", vo.getApprovalTotal()); summaryFetched = true; } } catch (Exception e) { // 旧版 UserMsg 无 summary 端点:降级走 unreadCount 单查,字段缺省由前端兜底 log.debug("[msgcenter] UserMsg 摘要端点不可用(降级未读单查)userId={}:{}", userId, e.toString()); } if (!summaryFetched) { try { out.put("unreadCount", msgClient().unreadCount(userId, msgSchId)); } catch (Exception e) { log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); } } try { Map q = new LinkedHashMap<>(); q.put("userId", String.valueOf(userId)); if (todoSchId != null) { q.put("schId", String.valueOf(todoSchId)); } JsonNode todos = get("/oapi/msg/todo/list", q); List> rows = new ArrayList<>(); if (todos != null && todos.isArray()) { for (JsonNode t : todos) { Map row = new LinkedHashMap<>(); // appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp row.put("count", t.has("count") ? t.get("count").asInt() : 0); rows.add(row); } } out.put("todos", rows); } catch (Exception e) { log.warn("[msgcenter] UserMsg 待办角标不可达(降级空)userId={}:{}", userId, e.toString()); } return out; } /** * 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName: * [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。 * UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key): * 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread * (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。 * schId=当前身份学校口径(null=全部)。停机降级空列表。 */ public List> countByApp(Integer userId, Integer schId) { List> empty = new ArrayList<>(); if (!enabled()) { return empty; } try { List counts = msgClient().countByApp(userId, schId); if (counts == null || counts.isEmpty()) { return empty; } AppDict dict = appDict(userId); Map> merged = new LinkedHashMap<>(); for (AppMsgCount a : counts) { String bucket = a == null ? null : a.getAppKey(); if (bucket == null || bucket.isEmpty()) { continue; } 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()); return empty; } } /** * 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪)。 * schId 仅用于 UserMsg 侧缓存键(含学校口径,R1)与 readState 帧口径对齐,不参与 UPDATE 归属判定。 */ public void read(Integer userId, List msgIds, Integer schId) { if (!enabled() || msgIds == null || msgIds.isEmpty()) { return; } Map body = new HashMap<>(); body.put("msgIds", msgIds); body.put("userId", userId); if (schId != null) { body.put("schId", schId); } post("/oapi/msg/read", new HashMap<>(), body); } /** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */ public void readAll(Integer userId, Integer schId) { if (!enabled()) { return; } msgClient().readAll(userId, schId); } /** * 按 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(前端维持轮询兜底表现)。 */ public String createWsTicket(Integer userId) { if (!enabled() || !StringUtils.hasText(properties.getUsermsgWsUrl())) { return null; } try { Map body = new HashMap<>(); body.put("userId", userId); JsonNode data = post("/oapi/msg/ws/ticket", new HashMap<>(), body); String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText(); if (!StringUtils.hasText(ticket)) { return null; } String wsUrl = properties.getUsermsgWsUrl().trim(); return wsUrl + (wsUrl.contains("?") ? "&" : "?") + "ticket=" + ticket; } catch (Exception e) { log.warn("[msgcenter] UserMsg WS 票据不可达(降级 null,前端不建立长连接)userId={}:{}", userId, e.toString()); return null; } } // ===== 出参改写 ===== /** * MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。 * 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时 * 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。 * appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。 */ private Map rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) { Map out = new LinkedHashMap<>(); out.put("msgId", row.getMsgId()); out.put("title", row.getTitle()); out.put("content", row.getContent()); out.put("msgType", row.getMsgType()); out.put("jumpType", row.getJumpType()); out.put("jumpPath", row.getJumpPath()); out.put("jumpApp", row.getJumpApp()); // 归属换源(页面属主优先):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()); out.put("isRead", row.getIsRead() == null ? 0 : row.getIsRead()); String jumpUrl = jumpUrl(row); // showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改); // jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行 Boolean allowed = null; if (jumpUrl != null && StringUtils.hasText(ownerCode)) { allowed = authz.authorized(ownerCode); } boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed); if (Boolean.FALSE.equals(allowed)) { jumpUrl = null; } out.put("jumpUrl", jumpUrl); out.put("showJump", showJump); return out; } /** * 基址解析(B2 跳转基址统一,C2 收口):jumpType=1 且 jumpPath 非空时—— *
    *
  1. 绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);
  2. *
  3. 应用注册表严格二选一:jumpApp(值域=app_code)有值 → 只认 by-code 结果; * jumpApp 为空才按发送方 appKey 走 by-key(自发场景发送方=页面归属)。 * pc_url 按 §3.2 归一化(丢 fragment、去尾斜杠、保留 index.htm(l))后拼 "#";
  4. *
  5. 查无 pc_url → null(前端不渲染跳转入口)——pc_url 空就是空,不猜测、不再回落发送方首页。
  6. *
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。 */ private String jumpUrl(MsgListItem row) { Integer jumpType = row.getJumpType(); String jumpPath = row.getJumpPath(); String jumpApp = row.getJumpApp(); if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) { return null; } String path = jumpPath.trim(); if (path.startsWith("http://") || path.startsWith("https://")) { return path; } if (userbaseDown()) { return null; } String p = path.startsWith("/") ? path : "/" + path; String appKey = row.getAppKey(); String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim()) : (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null); if (!StringUtils.hasText(pcUrl)) { return null; } String base = pcUrl.trim(); int hash = base.indexOf('#'); if (hash >= 0) { base = base.substring(0, hash); } while (base.endsWith("/")) { base = base.substring(0, base.length() - 1); } return base + "#" + p; } /** * 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl * (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变, * 查不到/无缓存才隐藏跳转入口)。 */ private String pcUrlByCode(String appCode) { ByCodeEntry entry = lookupByCode(appCode.trim()); return entry == null || entry == CODE_MISS ? null : entry.pcUrl; } /** 注册表 pc_url 查询(发送方 appKey 回落,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; } /** * 单次请求内的判权懒加载器:列表内首个带 jumpApp 的行触发一次出站,之后行间共用 * (禁按行重复出站);UserBase 不可达 → 判权结果 null(放行),失败进 30s 短路窗口。 */ private class PerRequestAuthz { private final Integer userId; private boolean fetched; private Set codes; PerRequestAuthz(Integer userId) { this.userId = userId; } /** @return true=有权 false=无权 null=不可判(UserBase 不可达)→ 调用方放行 */ Boolean authorized(String appCode) { if (!fetched) { fetched = true; codes = fetchAuthorizedCodes(userId); } return codes == null ? null : codes.contains(appCode); } } /** * 拉当前用户有权应用的 app_code 集合(UserBase /oapi/app/authorized-codes)。 * 复用 userbaseDownUntil 30s 短路:短路中不出站;请求失败同样进短路窗口。 * * @return app_code 集合;UserBase 不可达 → null(调用方不判权放行,绝不批量隐藏) */ private Set fetchAuthorizedCodes(Integer userId) { if (userId == null || userbaseDown()) { return null; } try { Map query = new LinkedHashMap<>(); query.put("userId", String.valueOf(userId)); JsonNode data = openCall.get(TARGET_USERBASE, "/oapi/app/authorized-codes", query, JsonNode.class); userbaseDownUntil = 0L; Set codes = new HashSet<>(); if (data != null && data.isArray()) { for (JsonNode n : data) { if (n != null && n.isTextual() && StringUtils.hasText(n.asText())) { codes.add(n.asText().trim()); } } } return codes; } catch (Exception e) { userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS; log.warn("[msgcenter] UserBase 判权不可达({}ms 内不判权放行)userId={}:{}", USERBASE_RETRY_MS, userId, e.toString()); return null; } } private static Map emptyPage() { Map out = new HashMap<>(); out.put("total", 0); out.put("list", new ArrayList<>()); return out; } private static String text(JsonNode node, String field) { return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null; } }