346 lines
16 KiB
Java
346 lines
16 KiB
Java
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;
|
||
}
|
||
}
|