feat(msgcenter): jumpUrl 基址改走应用注册表——by-code 主查/by-key 兜底取 pc_url(§3.2 归一化丢 fragment 拼 #),jump-bases 降为兜底 + 30s 不可达短路;附 findMessage 共享定位方法(供 B3 直批)
This commit is contained in:
@@ -21,8 +21,8 @@ import java.util.Map;
|
||||
* <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>
|
||||
* <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,
|
||||
@@ -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<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(前端维持轮询兜底表现)。
|
||||
@@ -204,10 +250,15 @@ public class MsgCenterProxyService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 基址 + "/#" + jumpPath。基址查询优先「页面归属应用」(jumpApp,代发场景页面属业务方时由
|
||||
* 发送方声明),缺省回落 bizType(=发送方自己的消息分类);都查不到返回 null(前端不渲染跳转)。
|
||||
* jumpPath 已是绝对 URL(http/https)时直接透传——兼容存量已落库的绝对地址消息,且不再
|
||||
* 重复拼基址产出双前缀畸形链接。非应用内跳转(jumpType≠1)/无路径 → null。
|
||||
* 基址解析(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;
|
||||
@@ -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<String, Object> emptyPage() {
|
||||
Map<String, Object> out = new HashMap<>();
|
||||
out.put("total", 0);
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user