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;
}