Files
UserhomeService/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
T

346 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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。
* <ul>
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li>
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li>
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按「注册表 by-code/by-key 取 pc_url(B2 主链路)
* → jump-bases 手工表兜底」解析基址,归一化后拼 "#" + jumpPath(裸相对路径如 /task/123)。</li>
* </ul>
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
* 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<String, String> 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<String, String> 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<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) {
Map<String, Object> empty = emptyPage();
if (!enabled()) {
return empty;
}
try {
Map<String, String> 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<Map<String, Object>> rows = new ArrayList<>();
if (data != null && data.path("content").isArray()) {
for (JsonNode row : data.get("content")) {
rows.add(rewrite(row));
}
}
Map<String, Object> 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<String, Object> summary(Integer userId, Integer schId) {
Map<String, Object> out = new HashMap<>();
out.put("unreadCount", 0L);
out.put("todos", new ArrayList<>());
if (!enabled()) {
return out;
}
try {
Map<String, String> 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<String, String> 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<Map<String, Object>> rows = new ArrayList<>();
if (todos != null && todos.isArray()) {
for (JsonNode t : todos) {
Map<String, Object> 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<Long> msgIds) {
if (!enabled() || msgIds == null || msgIds.isEmpty()) {
return;
}
Map<String, Object> 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<String, Object> 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<String, String> 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<String, Object> 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<String, Object> rewrite(JsonNode row) {
Map<String, Object> 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 非空时——
* <ol>
* <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li>
* <li>注册表:归属应用 jumpApp(值域=app_code)主查 → appKey 数字兜底(自发场景发送方=页面归属),
* 取 pc_url 按 §3.2 归一化(丢 fragment、保留 index.htm(l))后拼 "#";</li>
* <li>兜底:jump-bases 手工表(jumpApp/bizType 双维度 key),join 形态维持旧 "/#";</li>
* <li>均无 → null(前端不渲染跳转入口)。</li>
* </ol>
* 注册表是多端地址(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<String, Object> emptyPage() {
Map<String, Object> 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;
}
}