diff --git a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
index fb6104b..30b2165 100644
--- a/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
+++ b/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
@@ -21,8 +21,8 @@ import java.util.Map;
*
* - 列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;
* - 已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);
- * - jumpUrl 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath
- * (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。
+ * - jumpUrl 改写:绝对 URL 直通;相对路径按「注册表 by-code/by-key 取 pc_url(B2 主链路)
+ * → jump-bases 手工表兜底」解析基址,归一化后拼 "#" + jumpPath(裸相对路径如 /task/123)。
*
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥本应用
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
@@ -36,10 +36,21 @@ import java.util.Map;
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;
@@ -155,6 +166,41 @@ public class MsgCenterProxyService {
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(前端维持轮询兜底表现)。
@@ -204,10 +250,15 @@ public class MsgCenterProxyService {
}
/**
- * 基址 + "/#" + jumpPath。基址查询优先「页面归属应用」(jumpApp,代发场景页面属业务方时由
- * 发送方声明),缺省回落 bizType(=发送方自己的消息分类);都查不到返回 null(前端不渲染跳转)。
- * jumpPath 已是绝对 URL(http/https)时直接透传——兼容存量已落库的绝对地址消息,且不再
- * 重复拼基址产出双前缀畸形链接。非应用内跳转(jumpType≠1)/无路径 → null。
+ * 基址解析(B2 跳转基址统一):jumpType=1 且 jumpPath 非空时——
+ *
+ * - 绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);
+ * - 注册表:归属应用 jumpApp(值域=app_code)主查 → appKey 数字兜底(自发场景发送方=页面归属),
+ * 取 pc_url 按 §3.2 归一化(丢 fragment、保留 index.htm(l))后拼 "#";
+ * - 兜底:jump-bases 手工表(jumpApp/bizType 双维度 key),join 形态维持旧 "/#";
+ * - 均无 → null(前端不渲染跳转入口)。
+ *
+ * 注册表是多端地址(pc/app/小程序/钉钉)唯一可持续来源,手工表仅为过渡兜底。
*/
private String jumpUrl(JsonNode row) {
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null;
@@ -221,6 +272,24 @@ public class MsgCenterProxyService {
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)) {
@@ -233,10 +302,36 @@ public class MsgCenterProxyService {
while (b.endsWith("/")) {
b = b.substring(0, b.length() - 1);
}
- String p = path.startsWith("/") ? path : "/" + path;
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);
diff --git a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
index 673f6c5..51ea101 100644
--- a/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
+++ b/src/test/java/com/yunhai/userhome/service/MsgCenterProxyServiceTest.java
@@ -20,6 +20,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -330,4 +331,75 @@ class MsgCenterProxyServiceTest {
Map out = service.summary(201, 1);
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
}
+
+ // ===== B2(跳转基址统一):注册表 pc_url 主链路 =====
+
+ private void stubUserbaseGet(String path, String body) throws Exception {
+ when(openCall.get(eq("userbase"), eq(path), isNull(), eq(JsonNode.class)))
+ .thenReturn(body == null ? null : json(body));
+ }
+
+ private Map, ?> firstRow() throws Exception {
+ Map out = service.list(201, 1, 10, null);
+ return (Map, ?>) ((java.util.List>) out.get("list")).get(0);
+ }
+
+ /** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */
+ @Test
+ void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
+ stubUserbaseGet("/oapi/app/by-code/flow-center",
+ "{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
+ + "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
+ .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ + "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}"));
+ Map, ?> first = firstRow();
+ assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
+ "丢 fragment + # 拼接,不产第二个 #");
+ }
+
+ /** by-code 查无 → by-key 兜底(app_key 列,非主键 id——100009 是流程中心不是课堂作业) */
+ @Test
+ void list_byCodeMiss_fallsBackToByKey() throws Exception {
+ stubUserbaseGet("/oapi/app/by-code/notice", null);
+ stubUserbaseGet("/oapi/app/by-key/100001",
+ "{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\","
+ + "\"pcUrl\":\"http://base.yunhaixingtong.com/notice/front/index.htm\"}");
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
+ .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ + "\"jumpPath\":\"/list/1\",\"appKey\":\"100001\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
+ Map, ?> first = firstRow();
+ assertEquals("http://base.yunhaixingtong.com/notice/front/index.htm#/list/1", first.get("jumpUrl"));
+ }
+
+ /** 注册表两路都查无且无手工表 → null(前端不渲染跳转,不做猜测) */
+ @Test
+ void list_registryMiss_noManualBase_jumpUrlNull() throws Exception {
+ properties.getJumpBases().clear();
+ stubUserbaseGet("/oapi/app/by-code/notice", null);
+ stubUserbaseGet("/oapi/app/by-key/100006",
+ "{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":null}");
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
+ .thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ + "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
+ assertNull(firstRow().get("jumpUrl"));
+ }
+
+ /** UserBase 停摆:首行两次超时后进 30s 短路窗口,后续行不再出站、直接手工表兜底 */
+ @Test
+ void list_userbaseDown_shortCircuitFallsBackToManualBases() throws Exception {
+ when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)))
+ .thenThrow(new RuntimeException("connect timeout"));
+ properties.getJumpBases().put("flow.task", "http://localhost:8088");
+ when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
+ .thenReturn(json("{\"total\":2,\"content\":["
+ + "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0},"
+ + "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
+ Map out = service.list(201, 1, 10, null);
+ java.util.List> rows = (java.util.List>) out.get("list");
+ assertEquals("http://localhost:8088/#/task/1", ((Map, ?>) rows.get(0)).get("jumpUrl"), "首行走手工表兜底");
+ assertEquals("http://localhost:8088/#/task/2", ((Map, ?>) rows.get(1)).get("jumpUrl"));
+ // 首行仅 by-key 一次(无 jumpApp 不查 by-code)→ 短路后第二行 0 次,共 1 次
+ verify(openCall, org.mockito.Mockito.times(1)).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
+ }
}