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.userhome.config.MsgCenterProperties; 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.LinkedHashMap; import java.util.List; import java.util.Map; /** * 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 显式配置优先,其次服务目录 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; /** 消息定位扫描上限: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; } private boolean enabled() { return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG); } /** 显式 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 */ public Map list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) { Map empty = emptyPage(); if (!enabled()) { return empty; } try { Map query = new LinkedHashMap<>(); query.put("userId", String.valueOf(userId)); query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex)); query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))); query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread))); JsonNode data = get("/oapi/msg/list", query); List> rows = new ArrayList<>(); if (data != null && data.path("content").isArray()) { for (JsonNode row : data.get("content")) { rows.add(rewrite(row)); } } Map out = new HashMap<>(); out.put("total", data == null || !data.has("total") ? 0 : data.get("total").asInt()); out.put("list", rows); return out; } catch (Exception e) { log.warn("[msgcenter] UserMsg 列表不可达(降级空)userId={}:{}", userId, e.toString()); return empty; } } /** 铃铛摘要:未读数 + 各应用待办角标(停机各自降级 0/空) */ public Map summary(Integer userId, Integer schId) { Map out = new HashMap<>(); out.put("unreadCount", 0L); out.put("todos", new ArrayList<>()); if (!enabled()) { return out; } try { Map q = new LinkedHashMap<>(); q.put("userId", String.valueOf(userId)); JsonNode unread = get("/oapi/msg/unreadCount", q); out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong()); } catch (Exception e) { log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); } try { Map q = new LinkedHashMap<>(); q.put("userId", String.valueOf(userId)); if (schId != null) { q.put("schId", String.valueOf(schId)); } 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<>(); row.put("appKey", text(t, "appKey")); 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 侧 UPDATE 带归属人防伪) */ public void read(Integer userId, List msgIds) { if (!enabled() || msgIds == null || msgIds.isEmpty()) { return; } Map body = new HashMap<>(); body.put("msgIds", msgIds); body.put("userId", userId); post("/oapi/msg/read", new HashMap<>(), body); } /** 全部已读 */ public void readAll(Integer userId) { if (!enabled()) { return; } Map body = new HashMap<>(); body.put("userId", userId); 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(前端维持轮询兜底表现)。 */ 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 且查到基址时)+ 应用名补全 */ private Map rewrite(JsonNode row) { Map out = new LinkedHashMap<>(); out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); out.put("title", text(row, "title")); out.put("content", text(row, "content")); out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").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("jumpApp", text(row, "jumpApp")); out.put("appKey", text(row, "appKey")); String appKey = text(row, "appKey"); out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey)); out.put("bizType", text(row, "bizType")); out.put("bizId", text(row, "bizId")); out.put("sendTime", text(row, "sendTime")); out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0); out.put("jumpUrl", jumpUrl(row)); return out; } /** * 基址解析(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; String jumpPath = text(row, "jumpPath"); String jumpApp = text(row, "jumpApp"); String bizType = text(row, "bizType"); if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) { return null; } String path = jumpPath.trim(); 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)) { base = properties.getJumpBases().get(bizType); } if (!StringUtils.hasText(base)) { return null; } String b = base.trim(); while (b.endsWith("/")) { b = b.substring(0, b.length() - 1); } 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); 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; } }