fix(msgcenter): 代理出站迁 YschOpenCall,修复目标 ctx 签名不剥导致的 UserMsg 401
build / build-push (push) Successful in 58s

160 事故:MsgCenterProxyService 走共享 IDHttp 出站,AppSignInterceptor 只剥本应用
base-path(/userbase),打 usermsg-service:10009/usermsg 时签名路径多 /usermsg 前缀,
UserMsg AppAuthFilter 剥 ctx 后验签不一致 → 401 → 列表/未读/WS 票据全部静默降级空。

修复:改 YschOpenCall 泛化直调(Tier 0),usermsg-base-url 解析为 TargetSpec
(host + /usermsg ctx),per-target 剥离后签名与服务端对齐;base-url 未配时回落
服务目录 usermsg。新增回归断言:TargetSpec.basePath==/usermsg 且调用 path 为
ctx 裸路径。测试 16/16 绿。
This commit is contained in:
chenxing
2026-09-20 00:48:05 +08:00
parent 5f5ac3db71
commit 8492fba06b
2 changed files with 182 additions and 74 deletions
@@ -1,8 +1,8 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.utils.http.IDHttp;
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;
@@ -15,40 +15,55 @@ import java.util.List;
import java.util.Map;
/**
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):IDHttp 出站自动签名(yh.app-auth 身份
* appKey=100001,UserMsg trusted-apps 已含),UserHome 前端不直连 UserMsg。
* 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 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath
* (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。</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 final IDHttp http;
private final MsgCenterProperties properties;
private final ObjectMapper objectMapper;
private static final String TARGET_USERMSG = "usermsg";
public MsgCenterProxyService(IDHttp http, MsgCenterProperties properties, ObjectMapper objectMapper) {
this.http = http;
private final YschOpenCall openCall;
private final MsgCenterProperties properties;
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) {
this.openCall = openCall;
this.properties = properties;
this.objectMapper = objectMapper;
}
private boolean enabled() {
return StringUtils.hasText(properties.getUsermsgBaseUrl());
return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG);
}
private String url(String pathAndQuery) {
String base = properties.getUsermsgBaseUrl().trim();
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
/** 显式 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 base + pathAndQuery;
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 */
@@ -58,10 +73,12 @@ public class MsgCenterProxyService {
return empty;
}
try {
JsonNode data = http.get(url("/oapi/msg/list?userId=" + userId
+ "&pageIndex=" + (pageIndex == null || pageIndex < 1 ? 1 : pageIndex)
+ "&pageSize=" + (pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))
+ "&onlyUnread=" + Boolean.TRUE.equals(onlyUnread)), JsonNode.class);
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")) {
@@ -87,14 +104,20 @@ public class MsgCenterProxyService {
return out;
}
try {
JsonNode unread = http.get(url("/oapi/msg/unreadCount?userId=" + userId), JsonNode.class);
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 {
JsonNode todos = http.get(url("/oapi/msg/todo/list?userId=" + userId
+ (schId == null ? "" : "&schId=" + schId)), JsonNode.class);
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) {
@@ -119,7 +142,7 @@ public class MsgCenterProxyService {
Map<String, Object> body = new HashMap<>();
body.put("msgIds", msgIds);
body.put("userId", userId);
http.post(url("/oapi/msg/read"), body, JsonNode.class);
post("/oapi/msg/read", new HashMap<>(), body);
}
/** 全部已读 */
@@ -129,7 +152,7 @@ public class MsgCenterProxyService {
}
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
http.post(url("/oapi/msg/readAll"), body, JsonNode.class);
post("/oapi/msg/readAll", new HashMap<>(), body);
}
/**
@@ -143,7 +166,7 @@ public class MsgCenterProxyService {
try {
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
JsonNode data = http.post(url("/oapi/msg/ws/ticket"), body, JsonNode.class);
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;