feat(msgcenter): 消息读取改走 SDK msg 读方法并带当前激活身份学校口径(resolveReadSchId→/oapi/identity/current,0/异常降级 null);todo 角标沿用主学校口径不变;直批标读联动带口径(消息按学校隔离 批次3)

This commit is contained in:
chenxing
2026-10-09 12:23:10 +08:00
parent 1f352214f7
commit 570b593293
5 changed files with 402 additions and 211 deletions
@@ -29,7 +29,11 @@ public class MessageCenterController {
private final MsgCenterProxyService proxy;
private final MsgApprovalProxyService approvalProxy;
/** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */
/**
* 会话主学校(旧口径):<b>只</b>供待办角标(todo)与卡片直批(flow-center 办理人学校)使用——
* 这两条链路口径不变(方案 §8)。<b>消息读取一律不得用它</b>(R2:跨校切换会错),
* 必须用 {@link MsgCenterProxyService#resolveReadSchId}(当前激活身份学校)。
*/
private Integer resolveSchId(BoaUser user) {
if (user.getSchool() != null) {
return user.getSchool().getId();
@@ -40,6 +44,11 @@ public class MessageCenterController {
return 0;
}
/** 消息读取口径 schId:当前激活身份所属学校(0/平台/超管与身份链不可达 → null=看全部,D5) */
private Integer msgSchId(BoaUser authUserWithDept) {
return proxy.resolveReadSchId(authUserWithDept.getId());
}
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
* 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
@GetMapping("msgCenter/list")
@@ -51,19 +60,20 @@ public class MessageCenterController {
@RequestParam(value = "appId", required = false) String appId,
@RequestParam(value = "isRead", required = false) Integer isRead,
@RequestParam(value = "keyword", required = false) String keyword) {
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword);
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword,
msgSchId(authUserWithDept));
}
/** 铃铛摘要:未读数 + 各应用待办角标 */
/** 铃铛摘要:未读数 + 各应用待办角标(未读/分类计数按当前身份学校口径;待办角标沿用主学校口径不变) */
@GetMapping("msgCenter/summary")
public Map<String, Object> summary(BoaUser authUserWithDept) {
return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept));
return proxy.summary(authUserWithDept.getId(), msgSchId(authUserWithDept), resolveSchId(authUserWithDept));
}
/** 按来源应用消息计数(消息中心「按应用」清单角标):[{appId, appName, total, unread}],总数含已读 */
@GetMapping("msgCenter/countByApp")
public java.util.List<java.util.Map<String, Object>> countByApp(BoaUser authUserWithDept) {
return proxy.countByApp(authUserWithDept.getId());
return proxy.countByApp(authUserWithDept.getId(), msgSchId(authUserWithDept));
}
/**
@@ -77,16 +87,16 @@ public class MessageCenterController {
return out;
}
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪) */
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪;schId 只用于缓存失效键与 readState 口径) */
@PostMapping("msgCenter/read")
public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) {
proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId));
proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId), msgSchId(authUserWithDept));
}
/** 全部已读 */
/** 全部已读(按当前身份学校限定:只标本校+平台未读,他校未读不受影响,D6) */
@PostMapping("msgCenter/readAll")
public void readAll(BoaUser authUserWithDept) {
proxy.readAll(authUserWithDept.getId());
proxy.readAll(authUserWithDept.getId(), msgSchId(authUserWithDept));
}
/**
@@ -107,7 +107,8 @@ public class MsgApprovalProxyService {
return fail(out, "流程中心暂不可用,请稍后重试");
}
msgProxy.read(userId, java.util.Collections.singletonList(msgId));
// 直批后标读:schId 用消息读取口径(当前激活身份学校),保证 UserMsg 侧失效的是本校缓存键
msgProxy.read(userId, java.util.Collections.singletonList(msgId), msgProxy.resolveReadSchId(userId));
out.put("ok", true);
out.put("message", "办理成功");
return out;
@@ -3,7 +3,15 @@ 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.platform.openapi.msg.OpenApiMsgClient;
import com.yunhai.platform.openapi.msg.impl.DefaultOpenApiMsgClient;
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
import com.yunhai.platform.openapi.msg.model.MsgListItem;
import com.yunhai.platform.openapi.msg.model.MsgListPage;
import com.yunhai.platform.openapi.msg.model.MsgListQuery;
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties;
import com.yunhai.userhome.entity.UserCurrentRole;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -21,8 +29,11 @@ import java.util.Set;
* (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>消息读取(列表/摘要/未读数/按应用计数/全部已读)走 SDK msg 域类型化读方法
* ({@link OpenApiMsgClient}),并带「当前激活身份」所属学校口径 schId
* ({@link #resolveReadSchId})——按学校隔离读取侧;</li>
* <li>待办角标 todo/list(仍按主学校 schId,链路口径不变)、单条已读 read、WS 票据
* 继续走 {@link YschOpenCall} 泛化直调;</li>
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
* 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
@@ -33,7 +44,8 @@ import java.util.Set;
* 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-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址),
* 其次服务目录 usermsg(yh.open.services / 枚举兜底)。
* UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
*/
@Slf4j
@@ -55,6 +67,7 @@ public class MsgCenterProxyService {
private final YschOpenCall openCall;
private final MsgCenterProperties properties;
private final IUserCurrentRoleService userCurrentRoleService;
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
private volatile long userbaseDownUntil;
@@ -141,15 +154,46 @@ public class MsgCenterProxyService {
}
}
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) {
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties,
IUserCurrentRoleService userCurrentRoleService) {
this.openCall = openCall;
this.properties = properties;
this.userCurrentRoleService = userCurrentRoleService;
}
private boolean enabled() {
return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG);
}
/**
* 消息读取口径 schId(按学校隔离读取侧,R2):取<b>当前激活身份</b>所属学校
* ({@code userCurrentRoleService.findById} → UserBase /oapi/identity/current),
* <b>严禁</b>用会话主学校(跨校切换会错)。解析为 0(平台/超管)或身份链不可达时返回 null
* =不下发过滤(看全部,D5,且与改造前行为一致)。
*/
public Integer resolveReadSchId(Integer userId) {
if (userId == null) {
return null;
}
try {
return userCurrentRoleService.findById(userId)
.map(UserCurrentRole::getSchId)
.filter(schId -> schId != null && schId > 0)
.orElse(null);
} catch (Exception e) {
log.warn("[msgcenter] 当前身份学校解析不可达(降级不过滤=全量口径)userId={}:{}", userId, e.toString());
return null;
}
}
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx);否则类型化读方法按服务目录 usermsg 寻址 */
private OpenApiMsgClient msgClient() {
String base = properties.getUsermsgBaseUrl();
return StringUtils.hasText(base)
? new DefaultOpenApiMsgClient(openCall, TargetSpec.fromUrl(base.trim()))
: new DefaultOpenApiMsgClient(openCall);
}
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */
private JsonNode get(String path, Map<String, String> query) {
String base = properties.getUsermsgBaseUrl();
@@ -171,9 +215,10 @@ public class MsgCenterProxyService {
* 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
* msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。
* schId=当前身份学校口径({@link #resolveReadSchId}),null=不过滤(平台/超管视角,看全部)。
*/
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread,
Integer msgType, String appId, Integer isRead, String keyword) {
Integer msgType, String appId, Integer isRead, String keyword, Integer schId) {
Map<String, Object> empty = emptyPage();
if (!enabled()) {
return empty;
@@ -181,34 +226,31 @@ public class MsgCenterProxyService {
PerRequestAuthz authz = new PerRequestAuthz(userId);
AppDict dict = appDict(userId);
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)));
if (msgType != null) {
query.put("msgType", String.valueOf(msgType));
}
MsgListQuery query = new MsgListQuery();
query.setUserId(userId);
query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex);
query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100));
query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread));
query.setMsgType(msgType);
if (StringUtils.hasText(appId)) {
AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim()));
if (entry == null) {
return empty; // 字典查无此应用:视作该应用无消息
}
query.put("appKey", entry.appKey);
query.setAppKey(entry.appKey);
}
if (isRead != null) {
query.put("isRead", String.valueOf(isRead));
}
putIfText(query, "keyword", keyword);
JsonNode data = get("/oapi/msg/list", query);
query.setIsRead(isRead);
query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
query.setSchId(schId);
MsgListPage page = msgClient().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, authz, dict));
if (page != null && page.getContent() != null) {
for (MsgListItem item : page.getContent()) {
rows.add(rewrite(item, authz, dict));
}
}
Map<String, Object> out = new HashMap<>();
out.put("total", data == null || !data.has("total") ? 0 : data.get("total").asInt());
out.put("total", page == null ? 0 : page.getTotal());
out.put("list", rows);
return out;
} catch (Exception e) {
@@ -220,9 +262,12 @@ public class MsgCenterProxyService {
/**
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
* 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary
* (消息中心左栏一次喂饱);旧版 UserMsg 无该端点 → 字段缺省不出现,前端按降级处理。
* (消息中心左栏一次喂饱),按 msgSchId 学校口径取数;UserMsg 不可达/字段缺省时降级 unreadCount 单查。
*
* @param msgSchId 消息读取口径(当前激活身份学校,null=全部)
* @param todoSchId 待办角标口径(沿用会话主学校,链路口径不变,见类注释)
*/
public Map<String, Object> summary(Integer userId, Integer schId) {
public Map<String, Object> summary(Integer userId, Integer msgSchId, Integer todoSchId) {
Map<String, Object> out = new HashMap<>();
out.put("unreadCount", 0L);
out.put("todos", new ArrayList<>());
@@ -231,20 +276,18 @@ public class MsgCenterProxyService {
}
boolean summaryFetched = false;
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
JsonNode vo = get("/oapi/msg/summary", q);
if (vo != null && vo.has("unreadCount")) {
out.put("unreadCount", vo.get("unreadCount").asLong());
out.put("sysUnread", vo.path("sysUnread").asLong(0));
out.put("bizUnread", vo.path("bizUnread").asLong(0));
out.put("approvalUnread", vo.path("approvalUnread").asLong(0));
out.put("total", vo.path("total").asLong(0));
out.put("readCount", vo.path("readCount").asLong(0));
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径;旧版 UserMsg 无该字段时缺省,前端按降级处理
out.put("sysTotal", vo.path("sysTotal").asLong(0));
out.put("bizTotal", vo.path("bizTotal").asLong(0));
out.put("approvalTotal", vo.path("approvalTotal").asLong(0));
UserMsgSummary vo = msgClient().summary(userId, msgSchId);
if (vo != null) {
out.put("unreadCount", vo.getUnreadCount());
out.put("sysUnread", vo.getSysUnread());
out.put("bizUnread", vo.getBizUnread());
out.put("approvalUnread", vo.getApprovalUnread());
out.put("total", vo.getTotal());
out.put("readCount", vo.getReadCount());
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径
out.put("sysTotal", vo.getSysTotal());
out.put("bizTotal", vo.getBizTotal());
out.put("approvalTotal", vo.getApprovalTotal());
summaryFetched = true;
}
} catch (Exception e) {
@@ -253,10 +296,7 @@ public class MsgCenterProxyService {
}
if (!summaryFetched) {
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());
out.put("unreadCount", msgClient().unreadCount(userId, msgSchId));
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
}
@@ -264,8 +304,8 @@ public class MsgCenterProxyService {
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
if (schId != null) {
q.put("schId", String.valueOf(schId));
if (todoSchId != null) {
q.put("schId", String.valueOf(todoSchId));
}
JsonNode todos = get("/oapi/msg/todo/list", q);
List<Map<String, Object>> rows = new ArrayList<>();
@@ -288,24 +328,22 @@ public class MsgCenterProxyService {
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
* appId 取应用字典(按用户授权列表),字典查无(如授权已回收的来源应用)时 appId=null,前端不展示。
* 停机降级空列表。
* schId=当前身份学校口径(null=全部)。停机降级空列表。
*/
public List<Map<String, Object>> countByApp(Integer userId) {
public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
List<Map<String, Object>> empty = new ArrayList<>();
if (!enabled()) {
return empty;
}
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
JsonNode arr = get("/oapi/msg/countByApp", q);
if (arr == null || !arr.isArray()) {
List<AppMsgCount> counts = msgClient().countByApp(userId, schId);
if (counts == null || counts.isEmpty()) {
return empty;
}
AppDict dict = appDict(userId);
List<Map<String, Object>> out = new ArrayList<>();
for (JsonNode a : arr) {
String appKey = text(a, "appKey");
for (AppMsgCount a : counts) {
String appKey = a == null ? null : a.getAppKey();
if (appKey == null) {
continue;
}
@@ -313,8 +351,8 @@ public class MsgCenterProxyService {
Map<String, Object> row = new LinkedHashMap<>();
row.put("appId", entry == null ? null : entry.appId);
row.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name);
row.put("total", a.path("total").asLong(0));
row.put("unread", a.path("unread").asLong(0));
row.put("total", a.getTotal());
row.put("unread", a.getUnread());
out.add(row);
}
return out;
@@ -324,25 +362,29 @@ public class MsgCenterProxyService {
}
}
/** 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪) */
public void read(Integer userId, List<Long> msgIds) {
/**
* 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪)。
* schId 仅用于 UserMsg 侧缓存键(含学校口径,R1)与 readState 帧口径对齐,不参与 UPDATE 归属判定。
*/
public void read(Integer userId, List<Long> msgIds, Integer schId) {
if (!enabled() || msgIds == null || msgIds.isEmpty()) {
return;
}
Map<String, Object> body = new HashMap<>();
body.put("msgIds", msgIds);
body.put("userId", userId);
if (schId != null) {
body.put("schId", schId);
}
post("/oapi/msg/read", new HashMap<>(), body);
}
/** 全部已读 */
public void readAll(Integer userId) {
/** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
public void readAll(Integer userId, Integer schId) {
if (!enabled()) {
return;
}
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
post("/oapi/msg/readAll", new HashMap<>(), body);
msgClient().readAll(userId, schId);
}
/**
@@ -412,28 +454,28 @@ public class MsgCenterProxyService {
* 应用标识对前端只出 appId/appName(应用字典富化):appKey 属后端签名标识不下发;
* 字典查无(授权已回收等)appId/appName 为 null,前端按类型标签降级。
*/
private Map<String, Object> rewrite(JsonNode row, PerRequestAuthz authz, AppDict dict) {
private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
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"));
String appKey = text(row, "appKey");
out.put("msgId", row.getMsgId());
out.put("title", row.getTitle());
out.put("content", row.getContent());
out.put("msgType", row.getMsgType());
out.put("jumpType", row.getJumpType());
out.put("jumpPath", row.getJumpPath());
out.put("jumpApp", row.getJumpApp());
String appKey = row.getAppKey();
AppDictEntry entry = appKey == null ? null : dict.byKey.get(appKey);
out.put("appId", entry == null ? null : entry.appId);
out.put("appName", entry == null ? properties.getAppNames().get(appKey) : entry.name);
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("bizType", row.getBizType());
out.put("bizId", row.getBizId());
out.put("sendTime", row.getSendTime());
out.put("isRead", row.getIsRead() == null ? 0 : row.getIsRead());
String jumpUrl = jumpUrl(row);
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
Boolean allowed = null;
String jumpApp = text(row, "jumpApp");
String jumpApp = row.getJumpApp();
if (jumpUrl != null && StringUtils.hasText(jumpApp)) {
allowed = authz.authorized(jumpApp.trim());
}
@@ -457,10 +499,10 @@ public class MsgCenterProxyService {
* </ol>
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
*/
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");
private String jumpUrl(MsgListItem row) {
Integer jumpType = row.getJumpType();
String jumpPath = row.getJumpPath();
String jumpApp = row.getJumpApp();
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
return null;
}
@@ -472,7 +514,7 @@ public class MsgCenterProxyService {
return null;
}
String p = path.startsWith("/") ? path : "/" + path;
String appKey = text(row, "appKey");
String appKey = row.getAppKey();
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
: (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
if (!StringUtils.hasText(pcUrl)) {
@@ -578,13 +620,6 @@ public class MsgCenterProxyService {
return out;
}
/** 有值(非空白)才进 query——空串不透传,保持与不传一致 */
private static void putIfText(Map<String, String> query, String key, String value) {
if (StringUtils.hasText(value)) {
query.put(key, value.trim());
}
}
private static String text(JsonNode node, String field) {
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
}
@@ -136,7 +136,7 @@ class MsgApprovalProxyServiceTest {
assertEquals("张三", postQuery.getValue().get("operatorName"));
ArgumentCaptor<List<Long>> readIds = ArgumentCaptor.forClass(List.class);
verify(msgProxy).read(eq(201), readIds.capture());
verify(msgProxy).read(eq(201), readIds.capture(), any());
assertTrue(readIds.getValue().contains(5L), "办理成功后标记已读");
}
@@ -180,6 +180,6 @@ class MsgApprovalProxyServiceTest {
assertFalse((Boolean) out.get("ok"));
assertEquals("当前用户不是该任务办理人", out.get("message"), "业务拒绝原文透出(flow-center 逐字复核)");
verify(msgProxy, never()).read(any(), any());
verify(msgProxy, never()).read(any(), any(), any());
}
}
@@ -5,13 +5,19 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
import com.yunhai.platform.openapi.msg.model.MsgListPage;
import com.yunhai.platform.openapi.msg.model.ReadAllMsgRequest;
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties;
import com.yunhai.userhome.entity.UserCurrentRole;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -45,27 +51,56 @@ import static org.mockito.Mockito.when;
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
* <p>
* 按学校隔离(2026-10-09 批次3):消息读取(list/summary/unreadCount/countByApp/readAll)改走
* SDK msg 域类型化读方法(Tier 1,仍委托同一个 {@link YschOpenCall},故断言靶心从 JsonNode
* 换成 DTO class:{@code MsgListPage/UserMsgSummary/Long/AppMsgCount/Void}),并带
* <b>当前激活身份</b>学校口径({@link MsgCenterProxyService#resolveReadSchId},R2);
* 待办角标 todo/list 仍按主学校口径(§8 不动角标链路),两者在 summary 上是两个独立入参。
*/
@DisplayName("MsgCenterProxyService - YschOpenCall 代理、jumpUrl 改写与 showJump 判权")
@DisplayName("MsgCenterProxyService - SDK 类型化读取、学校口径下行与 jumpUrl/showJump 改写")
class MsgCenterProxyServiceTest {
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg";
private static final ObjectMapper MAPPER = new ObjectMapper();
private YschOpenCall openCall;
private IUserCurrentRoleService userCurrentRoleService;
private MsgCenterProperties properties;
private MsgCenterProxyService service;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
userCurrentRoleService = mock(IUserCurrentRoleService.class);
properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl(PROD_BASE_URL);
service = new MsgCenterProxyService(openCall, properties);
service = new MsgCenterProxyService(openCall, properties, userCurrentRoleService);
}
private static JsonNode json(String s) throws Exception {
return new ObjectMapper().readTree(s);
return MAPPER.readTree(s);
}
/** SDK 类型化读方法的响应靶心:同一份 JSON 夹具直接绑成 SDK DTO(契约字段与 UserMsg 出参对齐) */
private static <T> T dto(String s, Class<T> type) throws Exception {
return MAPPER.readValue(s, type);
}
private static <E> java.util.List<E> listDto(String s, Class<E> elementType) throws Exception {
return MAPPER.readValue(s, MAPPER.getTypeFactory()
.constructCollectionType(java.util.List.class, elementType));
}
/** 当前身份 stub(R2 数据源):findById(userId) 返回给定 schId 的激活身份 */
private void stubCurrentIdentity(Integer schId) {
UserCurrentRole role = new UserCurrentRole();
role.setUserId(201);
role.setRoleId(9);
role.setSchId(schId);
when(userCurrentRoleService.findById(201)).thenReturn(Optional.of(role));
}
/** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */
@@ -89,16 +124,118 @@ class MsgCenterProxyServiceTest {
+ "\",\"name\":\"" + name + "\"}]}]"));
}
// ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) =====
@Test
void resolveReadSchId_usesCurrentIdentitySchool() {
stubCurrentIdentity(7);
assertEquals(Integer.valueOf(7), service.resolveReadSchId(201), "当前身份 schId>0 → 原值下行(本校+平台可见)");
}
@Test
void resolveReadSchId_platformIdentityZeroMeansSeeAll() {
stubCurrentIdentity(0);
assertNull(service.resolveReadSchId(201), "schId=0(平台/超管)→ null=看全部(D5)");
stubCurrentIdentity(null);
assertNull(service.resolveReadSchId(201), "身份无 schId 同样按看全部处理");
}
@Test
void resolveReadSchId_identityChainDown_seesAllWithoutThrowing() {
when(userCurrentRoleService.findById(201)).thenThrow(new OpenApiException("userbase down"));
assertNull(service.resolveReadSchId(201), "身份链不可达降级 null=改造前全量口径,铃铛不 500");
assertNull(service.resolveReadSchId(null), "无登录人不出站");
verify(userCurrentRoleService, never()).findById(null);
}
// ===== 批次3:学校口径随读端点下行(R1 的下游半程) =====
@Test
void list_andSummary_passSchIdDownstream() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenReturn(dto("{\"unreadCount\":1}", UserMsgSummary.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
service.list(201, 1, 10, null, null, null, null, null, 7);
service.summary(201, 7, 3);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> listQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), listQuery.capture(), eq(MsgListPage.class));
assertEquals("7", listQuery.getValue().get("schId"));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> summaryQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/summary"), summaryQuery.capture(),
eq(UserMsgSummary.class));
assertEquals("7", summaryQuery.getValue().get("schId"), "摘要未读/分类计数按当前身份学校口径");
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> todoQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), todoQuery.capture(), eq(JsonNode.class));
assertEquals("3", todoQuery.getValue().get("schId"), "待办角标沿用主学校口径(§8 链路不变)");
}
@Test
void countByApp_passesSchIdAndEnriches() throws Exception {
stubAppDict(100011, "100019", "流程中心");
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":4,\"unread\":1}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, 7);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), query.capture(),
eq(AppMsgCount.class));
assertEquals("7", query.getValue().get("schId"));
assertEquals(1, rows.size());
assertEquals(100011, rows.get(0).get("appId"));
assertEquals(4L, rows.get(0).get("total"));
}
@Test
void read_schIdGoesIntoBodyOnlyWhenPresent() {
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
service.read(201, java.util.Collections.singletonList(7L), 7);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), body.capture(),
eq(JsonNode.class));
assertEquals(7, body.getValue().get("schId"), "schId 供 UserMsg 侧缓存失效键与 readState 口径(D8)");
assertEquals(201, body.getValue().get("userId"));
ArgumentCaptor<Map<String, Object>> body2 = ArgumentCaptor.forClass(Map.class);
service.read(201, java.util.Collections.singletonList(8L), null);
verify(openCall, org.mockito.Mockito.times(2))
.post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), body2.capture(), eq(JsonNode.class));
assertFalse(body2.getAllValues().get(1).containsKey("schId"), "null 不下发=平台视角(与历史调用一致)");
}
@Test
void readAll_goesThroughSdkWithSchId() {
ArgumentCaptor<ReadAllMsgRequest> body = ArgumentCaptor.forClass(ReadAllMsgRequest.class);
service.readAll(201, 7);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/readAll"), anyMap(), body.capture(),
eq(Void.class));
assertEquals(Integer.valueOf(201), body.getValue().getUserId());
assertEquals(Integer.valueOf(7), body.getValue().getSchId(), "全部已读按当前身份学校限定(D6)");
}
// ===== 寻址回归(160 事故)与 list 改写 =====
@Test
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(null);
service.list(201, 1, 10, null, null, null, null, null);
service.list(201, 1, 10, null, null, null, null, null, null);
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class));
verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost());
assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名");
}
@@ -109,29 +246,31 @@ class MsgCenterProxyServiceTest {
// 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一)
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":2,\"content\":["
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
+ "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\","
+ "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0},"
+ "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0,"
+ "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}"));
+ "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, null, null, null, null);
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, null, null, null, null, null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class));
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("201", query.getValue().get("userId"));
assertEquals("2", query.getValue().get("pageIndex"));
assertEquals("20", query.getValue().get("pageSize"));
assertEquals("true", query.getValue().get("onlyUnread"));
assertFalse(query.getValue().containsKey("schId"), "schId=null 不下发(向后兼容,验收 7)");
assertEquals(2, out.get("total"));
java.util.List<?> rows = (java.util.List<?>) out.get("list");
Map<?, ?> first = (Map<?, ?>) rows.get(0);
assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
assertEquals("flow.task", first.get("bizType"));
assertEquals("2026-09-13 10:00:00", first.get("sendTime"), "sendTime 逐字段透传(SDK 侧 String 口径)");
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
Map<?, ?> second = (Map<?, ?>) rows.get(1);
@@ -141,12 +280,12 @@ class MsgCenterProxyServiceTest {
@Test
void list_defaultsPageParams() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(null);
service.list(201, 0, 999, null, null, null, null, null);
service.list(201, 0, 999, null, null, null, null, null, null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class));
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1");
assertEquals("100", query.getValue().get("pageSize"), "页长上限 100");
assertEquals("false", query.getValue().get("onlyUnread"));
@@ -155,10 +294,11 @@ class MsgCenterProxyServiceTest {
@Test
void list_appNameMappedFromAppKey() throws Exception {
properties.getAppNames().put("100617", "通知公告");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}",
MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
}
@@ -167,9 +307,9 @@ class MsgCenterProxyServiceTest {
* 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */
@Test
void list_noRegistrySource_jumpUrlNull() throws Exception {
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/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
assertEquals(Boolean.FALSE, first.get("showJump"));
@@ -188,10 +328,10 @@ class MsgCenterProxyServiceTest {
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\","
+ "\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice", "flow-center");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}"));
+ "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
"归属应用 jumpApp 主查 by-code");
@@ -202,10 +342,10 @@ class MsgCenterProxyServiceTest {
/** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */
@Test
void list_absoluteJumpPath_passedThrough() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\","
+ "\"bizType\":\"flow.task\",\"isRead\":0}]}"));
+ "\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"),
"绝对 URL 原样透传");
@@ -217,9 +357,10 @@ class MsgCenterProxyServiceTest {
void list_jumpAppAbsent_fallsBackToSendersAppKey() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
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/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey");
assertEquals(Boolean.TRUE, first.get("showJump"));
@@ -229,10 +370,10 @@ class MsgCenterProxyServiceTest {
@Test
void list_jumpAppMiss_noAppKeyFallback_jumpUrlNull() throws Exception {
stubUserbaseGet("/oapi/app/by-code/unknown-app", null);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\","
+ "\"isRead\":0}]}"));
+ "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"));
assertEquals(Boolean.FALSE, first.get("showJump"));
@@ -244,9 +385,10 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice", null);
stubUserbaseGet("/oapi/app/by-key/100006",
"{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":\"http://localhost:8090\"}");
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}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空");
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class));
@@ -258,9 +400,10 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077/\"}");
stubAuthorizedCodes("notice");
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/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
"尾斜杠归一 + 无前导斜杠路径补斜杠");
@@ -268,9 +411,9 @@ class MsgCenterProxyServiceTest {
@Test
void list_userMsgDown_degradesToEmpty() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401"));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size());
}
@@ -303,12 +446,12 @@ class MsgCenterProxyServiceTest {
@Test
void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("3"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(3L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]"));
Map<String, Object> out = service.summary(201, 1);
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(3L, out.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out.get("todos")).size());
// appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count
@@ -318,9 +461,9 @@ class MsgCenterProxyServiceTest {
assertEquals(2, todoRow.get("count"));
// 未读数不可达单独降级为 0,待办仍可用
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenThrow(new OpenApiException("down"));
Map<String, Object> out2 = service.summary(201, 1);
Map<String, Object> out2 = service.summary(201, null, 1);
assertEquals(0L, out2.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
}
@@ -330,25 +473,25 @@ class MsgCenterProxyServiceTest {
@Test
void list_filtersPassedThrough() throws Exception {
stubAppDict(100019, "100019", "流程中心");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假");
service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假", null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class));
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("2", query.getValue().get("msgType"));
assertEquals("100019", query.getValue().get("appKey"), "appId 去空白经字典翻译为 appKey 下行");
assertEquals("0", query.getValue().get("isRead"));
assertEquals("请假", query.getValue().get("keyword"));
// 全空筛选不出现新键(与历史调用逐字一致)
service.list(201, 1, 10, null, null, " ", null, "");
service.list(201, 1, 10, null, null, " ", null, "", null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query2 = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2))
.get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(JsonNode.class));
.get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(MsgListPage.class));
assertFalse(query2.getAllValues().get(1).containsKey("msgType"));
assertFalse(query2.getAllValues().get(1).containsKey("appKey"));
assertFalse(query2.getAllValues().get(1).containsKey("isRead"));
@@ -359,12 +502,12 @@ class MsgCenterProxyServiceTest {
@Test
void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception {
// 字典未 stub → /oapi/app/list 返回 null → EMPTY
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}"));
Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", null, null);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", null, null, null);
assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size());
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
}
/** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径;
@@ -372,11 +515,11 @@ class MsgCenterProxyServiceTest {
@Test
void countByApp_enrichesWithRegistryDict() throws Exception {
stubAppDict(100011, "100019", "流程中心");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3},"
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3},"
+ "{\"appKey\":\"999999\",\"total\":5,\"unread\":1},"
+ "{\"total\":2,\"unread\":0}]"));
java.util.List<Map<String, Object>> rows = service.countByApp(201);
+ "{\"total\":2,\"unread\":0}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
assertEquals(2, rows.size(), "无 appKey 行跳过");
Map<String, Object> first = rows.get(0);
assertEquals(100011, first.get("appId"));
@@ -393,61 +536,60 @@ class MsgCenterProxyServiceTest {
void countByApp_disabledOrDown_degradesEmpty() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
assertTrue(service.countByApp(201).isEmpty());
assertTrue(service.countByApp(201, null).isEmpty());
properties.setUsermsgBaseUrl(PROD_BASE_URL);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(JsonNode.class)))
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenThrow(new OpenApiException("down"));
assertTrue(service.countByApp(201).isEmpty(), "UserMsg 不可达降级空,不抛出");
assertTrue(service.countByApp(201, null).isEmpty(), "UserMsg 不可达降级空,不抛出");
}
/** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */
@Test
void summary_prefersSummaryEndpoint() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
+ "\"total\":9,\"readCount\":4}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenReturn(dto("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
+ "\"total\":9,\"readCount\":4,\"sysTotal\":3,\"bizTotal\":2,\"approvalTotal\":4}",
UserMsgSummary.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 1);
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(5L, out.get("unreadCount"));
assertEquals(2L, out.get("sysUnread"));
assertEquals(1L, out.get("bizUnread"));
assertEquals(2L, out.get("approvalUnread"));
assertEquals(9L, out.get("total"));
assertEquals(4L, out.get("readCount"));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class));
assertEquals(3L, out.get("sysTotal"));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class));
}
/** 旧版 UserMsg 无 summary 端点(404/异常)→ 降级 unreadCount 单查,分类字段缺省 */
@Test
void summary_legacyUserMsg_fallsBackToUnreadCount() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(JsonNode.class)))
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=404"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("7"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(7L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 1);
Map<String, Object> out = service.summary(201, 7, 1);
assertEquals(7L, out.get("unreadCount"));
assertFalse(out.containsKey("sysUnread"), "旧端点无分类计数,字段缺省由前端降级");
}
@Test
void read_sendsOwnerScopedPayload() {
service.read(201, java.util.Collections.singletonList(7L));
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), any(Map.class),
eq(JsonNode.class));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), query.capture(), eq(Long.class));
assertEquals("7", query.getValue().get("schId"), "降级单查同样带当前身份学校口径");
}
@Test
void disabled_degradesWithoutOutbound() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
assertEquals(0, service.list(201, 1, 10, null, null, null, null, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L));
assertEquals(0, service.list(201, 1, 10, null, null, null, null, null, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L), null);
verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any());
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any());
}
@@ -457,9 +599,9 @@ class MsgCenterProxyServiceTest {
// base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(true);
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("5"));
Map<String, Object> out = service.summary(201, null);
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(5L);
Map<String, Object> out = service.summary(201, null, null);
assertEquals(5L, out.get("unreadCount"));
}
@@ -467,19 +609,19 @@ class MsgCenterProxyServiceTest {
void summary_disabled_degradesToZero() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
Map<String, Object> out = service.summary(201, 1);
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount"));
assertEquals(0, ((java.util.List<?>) out.get("todos")).size());
}
@Test
void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("0"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(0L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
service.summary(201, null);
service.summary(201, 3);
service.summary(201, null, null);
service.summary(201, null, 3);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2))
@@ -491,7 +633,7 @@ class MsgCenterProxyServiceTest {
@Test
void malformedBaseUrl_disabledNotCrash() {
properties.setUsermsgBaseUrl("::::not-a-url");
Map<String, Object> out = service.summary(201, 1);
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
}
@@ -503,7 +645,7 @@ class MsgCenterProxyServiceTest {
}
private Map<?, ?> firstRow() throws Exception {
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
}
@@ -514,9 +656,10 @@ class MsgCenterProxyServiceTest {
"{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
stubAuthorizedCodes("flow-center");
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}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
"丢 fragment + # 拼接,不产第二个 #");
@@ -531,9 +674,9 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("flow-center");
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\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口");
assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)");
@@ -544,9 +687,9 @@ class MsgCenterProxyServiceTest {
void list_jumpAppBlank_noAuthzOutbound() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
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/9\",\"appKey\":\"100019\",\"isRead\":0}]}"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"));
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
@@ -557,10 +700,10 @@ class MsgCenterProxyServiceTest {
void list_authzUnreachable_absoluteUrlStillPasses() throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}"));
+ "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行");
assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl"));
@@ -572,11 +715,12 @@ class MsgCenterProxyServiceTest {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":2,\"content\":["
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}"));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null);
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(0)).get("showJump"));
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(1)).get("showJump"));
@@ -589,11 +733,12 @@ class MsgCenterProxyServiceTest {
void list_userbaseDown_shortCircuit_relativeJumpNull() throws Exception {
when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":2,\"content\":["
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"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, null, null, null, null);
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}",
MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null");
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"));