Files
UserhomeService/src/main/java/com/yunhai/userhome/service/MsgCenterProxyService.java
T

789 lines
38 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
* (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
* UserHome 前端不直连 UserMsg。
* <ul>
* <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>
* <li>showJump 判权(C1):jumpApp 有值时按 /oapi/app/authorized-codes 集合判定,
* 无权 → showJump=false 且 jumpUrl 一并置 null;jumpApp 为空或 UserBase 不可达不判权放行。</li>
* </ul>
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
* 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为
* {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。
* 目标地址来源:usermsg-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址),
* 其次服务目录 usermsg(yh.open.services / 枚举兜底)。
* UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
*/
@Slf4j
@Service
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;
/** 应用字典 TTL:appKey↔appId 映射按用户缓存,到期或失败即重拉/沿用旧值 */
static final long APP_DICT_TTL_MS = 300_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;
private final IUserCurrentRoleService userCurrentRoleService;
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
private volatile long userbaseDownUntil;
/** 应用字典按用户缓存(volatile 整体替换,并发重拉无害) */
private final java.util.concurrent.ConcurrentHashMap<Integer, AppDict> appDictCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<Integer, Long> appDictAt = new java.util.concurrent.ConcurrentHashMap<>();
/** 应用字典条目:注册表 appId / appKey / 名称(UserBase BoaApp) */
private static final class AppDictEntry {
final int appId;
final String appKey;
final String name;
AppDictEntry(int appId, String appKey, String name) {
this.appId = appId;
this.appKey = appKey;
this.name = name;
}
}
/** 应用字典:byKey 正查(行富化/出参)+ byId 反查(appId 筛选参数翻译) */
private static final class AppDict {
static final AppDict EMPTY = new AppDict(java.util.Collections.emptyMap(), java.util.Collections.emptyMap());
final Map<String, AppDictEntry> byKey;
final Map<Integer, AppDictEntry> byId;
AppDict(Map<String, AppDictEntry> byKey, Map<Integer, AppDictEntry> byId) {
this.byKey = byKey;
this.byId = byId;
}
}
/**
* by-code 解析条目(UserBase /oapi/app/by-code/{appCode},值域=消息 jump_app)。
* appId=注册表主键 id(2026-10-13 160 实测与 /oapi/app/list 同体系:notice 主键=100001、
* 流程中心主键=100011/app_key=100009,无混体系风险)。
*/
private static final class ByCodeEntry {
final int appId;
final String appKey;
final String name;
final String pcUrl;
final String code;
ByCodeEntry(int appId, String appKey, String name, String pcUrl, String code) {
this.appId = appId;
this.appKey = appKey;
this.name = name;
this.pcUrl = pcUrl;
this.code = code;
}
}
/** 查无哨兵(ConcurrentHashMap 不允许 null 值):脏 code 不重复出站 */
private static final ByCodeEntry CODE_MISS = new ByCodeEntry(-1, null, null, null, null);
/** by-code 缓存本体与时间戳(TTL 对齐 {@link #APP_DICT_TTL_MS}) */
private final java.util.concurrent.ConcurrentHashMap<String, ByCodeEntry> byCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> byCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
/**
* by-code 应用缓存(TTL 对齐 {@link #APP_DICT_TTL_MS}):一次调用同时取 name/appId/pcUrl,
* 消掉逐行 by-code 查址的 N+1(旧实现每行现查)。UserBase 不可达复用
* {@link #userbaseDownUntil} 短路窗口;返回 null=不可达(调用方降级),返回
* {@link #CODE_MISS}=查无(回落发送方口径)。
*/
private ByCodeEntry lookupByCode(String appCode) {
if (appCode == null || appCode.isEmpty()) {
return CODE_MISS;
}
Long at = byCodeAt.get(appCode);
boolean fresh = at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS;
if (fresh) {
ByCodeEntry cached = byCodeCache.get(appCode);
return cached == null ? CODE_MISS : cached;
}
if (userbaseDown()) {
ByCodeEntry stale = byCodeCache.get(appCode);
return stale == null ? CODE_MISS : stale;
}
try {
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-code/" + appCode, null, JsonNode.class);
userbaseDownUntil = 0L;
ByCodeEntry entry = app != null && app.hasNonNull("id")
? new ByCodeEntry(app.get("id").asInt(),
app.hasNonNull("appKey") ? String.valueOf(app.get("appKey").asInt()) : null,
text(app, "name"), text(app, "pcUrl"), text(app, "appCode"))
: CODE_MISS;
byCodeCache.put(appCode, entry);
byCodeAt.put(appCode, System.currentTimeMillis());
return entry;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase by-code 解析不可达(降级回落发送方口径)code={}:{}", appCode, e.toString());
return null;
}
}
/** appKey→app_code 反查缓存(list 的 appId 双值翻译用;空串值=查无,退化为单值过滤) */
private final java.util.concurrent.ConcurrentHashMap<String, String> appCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> appCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
/** appKey→app_code 反查(/oapi/app/by-key,VO 含 appCode):首次查询后缓存,查无/不可达返回 null */
private String appCodeByKey(String appKey) {
if (appKey == null || appKey.isEmpty()) {
return null;
}
Long at = appCodeAt.get(appKey);
if (at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS) {
return appCodeCache.get(appKey);
}
if (appCodeCache.containsKey(appKey) && userbaseDown()) {
return appCodeCache.get(appKey);
}
try {
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-key/" + appKey, null, JsonNode.class);
userbaseDownUntil = 0L;
String code = app == null || app.isNull() ? null : text(app, "appCode");
appCodeCache.put(appKey, code == null ? "" : code);
appCodeAt.put(appKey, System.currentTimeMillis());
return StringUtils.hasText(code) ? code : null;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase by-key 反查 app_code 不可达(退化单值过滤)appKey={}:{}", appKey, e.toString());
return null;
}
}
/**
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
*/
private AppDict appDict(Integer userId) {
if (userId == null) {
return AppDict.EMPTY;
}
AppDict cached = appDictCache.get(userId);
Long at = appDictAt.get(userId);
long now = System.currentTimeMillis();
if (cached != null && at != null && now - at < APP_DICT_TTL_MS) {
return cached;
}
if (userbaseDown()) {
return cached != null ? cached : AppDict.EMPTY;
}
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
JsonNode groups = openCall.get(TARGET_USERBASE, "/oapi/app/list", q, JsonNode.class);
userbaseDownUntil = 0L;
Map<String, AppDictEntry> byKey = new LinkedHashMap<>();
Map<Integer, AppDictEntry> byId = new LinkedHashMap<>();
if (groups != null && groups.isArray()) {
for (JsonNode group : groups) {
JsonNode apps = group.path("apps");
if (!apps.isArray()) {
continue;
}
for (JsonNode app : apps) {
if (app == null || !app.hasNonNull("id") || !app.hasNonNull("appKey")) {
continue;
}
AppDictEntry entry = new AppDictEntry(app.get("id").asInt(),
app.get("appKey").asText(), text(app, "name"));
byKey.put(entry.appKey, entry);
byId.put(entry.appId, entry);
}
}
}
AppDict dict = new AppDict(byKey, byId);
appDictCache.put(userId, dict);
appDictAt.put(userId, now);
return dict;
} catch (Exception e) {
userbaseDownUntil = now + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 应用字典不可达({}ms 内沿用旧字典/空)userId={}:{}",
USERBASE_RETRY_MS, userId, e.toString());
return cached != null ? cached : AppDict.EMPTY;
}
}
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();
if (StringUtils.hasText(base)) {
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
}
return openCall.get(TARGET_USERMSG, path, query, JsonNode.class);
}
private JsonNode post(String path, Map<String, String> query, Object body) {
String base = properties.getUsermsgBaseUrl();
if (StringUtils.hasText(base)) {
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
}
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
}
/**
* 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 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 schId) {
Map<String, Object> empty = emptyPage();
if (!enabled()) {
return empty;
}
PerRequestAuthz authz = new PerRequestAuthz(userId);
AppDict dict = appDict(userId);
try {
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; // 字典查无此应用:视作该应用无消息
}
// 归属口径双值过滤:该应用自发行桶值=app_key,流程中心代发行桶值=app_code
List<String> appKeys = new ArrayList<>();
appKeys.add(entry.appKey);
String code = appCodeByKey(entry.appKey);
if (StringUtils.hasText(code) && !code.equals(entry.appKey)) {
appKeys.add(code);
}
query.setAppKeys(appKeys);
}
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 (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", page == null ? 0 : page.getTotal());
out.put("list", rows);
return out;
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 列表不可达(降级空)userId={}:{}", userId, e.toString());
return empty;
}
}
/**
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
* 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary
* (消息中心左栏一次喂饱),按 msgSchId 学校口径取数;UserMsg 不可达/字段缺省时降级 unreadCount 单查。
*
* @param msgSchId 消息读取口径(当前激活身份学校,null=全部)
* @param todoSchId 待办角标口径(沿用会话主学校,链路口径不变,见类注释)
*/
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<>());
if (!enabled()) {
return out;
}
boolean summaryFetched = false;
try {
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) {
// 旧版 UserMsg 无 summary 端点:降级走 unreadCount 单查,字段缺省由前端兜底
log.debug("[msgcenter] UserMsg 摘要端点不可用(降级未读单查)userId={}:{}", userId, e.toString());
}
if (!summaryFetched) {
try {
out.put("unreadCount", msgClient().unreadCount(userId, msgSchId));
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
}
}
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
if (todoSchId != null) {
q.put("schId", String.valueOf(todoSchId));
}
JsonNode todos = get("/oapi/msg/todo/list", q);
List<Map<String, Object>> rows = new ArrayList<>();
if (todos != null && todos.isArray()) {
for (JsonNode t : todos) {
Map<String, Object> row = new LinkedHashMap<>();
// appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp
row.put("count", t.has("count") ? t.get("count").asInt() : 0);
rows.add(row);
}
}
out.put("todos", rows);
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 待办角标不可达(降级空)userId={}:{}", userId, e.toString());
}
return out;
}
/**
* 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
* UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key):
* 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread
* (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。
* schId=当前身份学校口径(null=全部)。停机降级空列表。
*/
public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
List<Map<String, Object>> empty = new ArrayList<>();
if (!enabled()) {
return empty;
}
try {
List<AppMsgCount> counts = msgClient().countByApp(userId, schId);
if (counts == null || counts.isEmpty()) {
return empty;
}
AppDict dict = appDict(userId);
Map<Object, Map<String, Object>> merged = new LinkedHashMap<>();
for (AppMsgCount a : counts) {
String bucket = a == null ? null : a.getAppKey();
if (bucket == null || bucket.isEmpty()) {
continue;
}
AppDictEntry entry = dict.byKey.get(bucket);
// 纯数字桶值不可能是 app_code,免一次注定查无的出站;
// 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏
ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+")
? lookupByCode(bucket) : null;
Integer appId;
String name;
if (entry != null) {
appId = entry.appId;
name = entry.name;
} else if (codeEntry != null && codeEntry != CODE_MISS) {
appId = codeEntry.appId;
name = codeEntry.name;
} else if (codeEntry == CODE_MISS) {
continue; // code 脏桶双查无:丢弃,与改造前一致
} else {
appId = null;
name = properties.getAppNames().get(bucket);
}
// 合桶键=注册表主键 appId:自发桶(byKey)与代发桶(byCode)同应用并成一类目
Map<String, Object> row = merged.get(appId);
if (row == null) {
row = new LinkedHashMap<>();
row.put("appId", appId);
row.put("appName", name);
row.put("total", 0L);
row.put("unread", 0L);
merged.put(appId, row);
}
row.put("total", (Long) row.get("total") + a.getTotal());
row.put("unread", (Long) row.get("unread") + a.getUnread());
}
List<Map<String, Object>> out = new ArrayList<>(merged.values());
out.sort((x, y) -> {
int c = Long.compare((Long) y.get("total"), (Long) x.get("total"));
return c != 0 ? c : Long.compare((Long) y.get("unread"), (Long) x.get("unread"));
});
return out;
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
return empty;
}
}
/**
* 标记已读(代理按登录人发送,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);
}
/** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
public void readAll(Integer userId, Integer schId) {
if (!enabled()) {
return;
}
msgClient().readAll(userId, schId);
}
/**
* 按 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(前端维持轮询兜底表现)。
*/
public String createWsTicket(Integer userId) {
if (!enabled() || !StringUtils.hasText(properties.getUsermsgWsUrl())) {
return null;
}
try {
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
JsonNode data = post("/oapi/msg/ws/ticket", new HashMap<>(), body);
String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText();
if (!StringUtils.hasText(ticket)) {
return null;
}
String wsUrl = properties.getUsermsgWsUrl().trim();
return wsUrl + (wsUrl.contains("?") ? "&" : "?") + "ticket=" + ticket;
} catch (Exception e) {
log.warn("[msgcenter] UserMsg WS 票据不可达(降级 null,前端不建立长连接)userId={}:{}",
userId, e.toString());
return null;
}
}
// ===== 出参改写 =====
/**
* MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
* 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时
* 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。
* appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。
*/
private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
Map<String, Object> out = new LinkedHashMap<>();
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());
// 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用;
// jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。
// 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。
String jumpApp = row.getJumpApp();
String ownerCode = jumpApp == null ? null : jumpApp.trim();
AppDictEntry entry = null;
Integer appId = null;
String appName = null;
if (StringUtils.hasText(ownerCode)) {
ByCodeEntry codeEntry = lookupByCode(ownerCode);
if (codeEntry != null && codeEntry != CODE_MISS) {
appId = codeEntry.appId;
appName = codeEntry.name;
}
}
if (appId == null) {
String appKey = row.getAppKey();
entry = appKey == null ? null : dict.byKey.get(appKey);
if (entry != null) {
appId = entry.appId;
appName = entry.name;
} else {
appName = properties.getAppNames().get(appKey);
}
}
out.put("appId", appId);
out.put("appName", appName);
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;
if (jumpUrl != null && StringUtils.hasText(ownerCode)) {
allowed = authz.authorized(ownerCode);
}
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
if (Boolean.FALSE.equals(allowed)) {
jumpUrl = null;
}
out.put("jumpUrl", jumpUrl);
out.put("showJump", showJump);
return out;
}
/**
* 基址解析(B2 跳转基址统一,C2 收口):jumpType=1 且 jumpPath 非空时——
* <ol>
* <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li>
* <li>应用注册表严格二选一:jumpApp(值域=app_code)有值 → 只认 by-code 结果;
* jumpApp 为空才按发送方 appKey 走 by-key(自发场景发送方=页面归属)。
* pc_url 按 §3.2 归一化(丢 fragment、去尾斜杠、保留 index.htm(l))后拼 "#";</li>
* <li>查无 pc_url → null(前端不渲染跳转入口)——pc_url 空就是空,不猜测、不再回落发送方首页。</li>
* </ol>
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
*/
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;
}
String path = jumpPath.trim();
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (userbaseDown()) {
return null;
}
String p = path.startsWith("/") ? path : "/" + path;
String appKey = row.getAppKey();
String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
: (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
if (!StringUtils.hasText(pcUrl)) {
return null;
}
String base = pcUrl.trim();
int hash = base.indexOf('#');
if (hash >= 0) {
base = base.substring(0, hash);
}
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
return base + "#" + p;
}
/**
* 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl
* (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变,
* 查不到/无缓存才隐藏跳转入口)。
*/
private String pcUrlByCode(String appCode) {
ByCodeEntry entry = lookupByCode(appCode.trim());
return entry == null || entry == CODE_MISS ? null : entry.pcUrl;
}
/** 注册表 pc_url 查询(发送方 appKey 回落,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;
}
/**
* 单次请求内的判权懒加载器:列表内首个带 jumpApp 的行触发一次出站,之后行间共用
* (禁按行重复出站);UserBase 不可达 → 判权结果 null(放行),失败进 30s 短路窗口。
*/
private class PerRequestAuthz {
private final Integer userId;
private boolean fetched;
private Set<String> codes;
PerRequestAuthz(Integer userId) {
this.userId = userId;
}
/** @return true=有权 false=无权 null=不可判(UserBase 不可达)→ 调用方放行 */
Boolean authorized(String appCode) {
if (!fetched) {
fetched = true;
codes = fetchAuthorizedCodes(userId);
}
return codes == null ? null : codes.contains(appCode);
}
}
/**
* 拉当前用户有权应用的 app_code 集合(UserBase /oapi/app/authorized-codes)。
* 复用 userbaseDownUntil 30s 短路:短路中不出站;请求失败同样进短路窗口。
*
* @return app_code 集合;UserBase 不可达 → null(调用方不判权放行,绝不批量隐藏)
*/
private Set<String> fetchAuthorizedCodes(Integer userId) {
if (userId == null || userbaseDown()) {
return null;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userId", String.valueOf(userId));
JsonNode data = openCall.get(TARGET_USERBASE, "/oapi/app/authorized-codes", query, JsonNode.class);
userbaseDownUntil = 0L;
Set<String> codes = new HashSet<>();
if (data != null && data.isArray()) {
for (JsonNode n : data) {
if (n != null && n.isTextual() && StringUtils.hasText(n.asText())) {
codes.add(n.asText().trim());
}
}
}
return codes;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 判权不可达({}ms 内不判权放行)userId={}:{}",
USERBASE_RETRY_MS, userId, e.toString());
return null;
}
}
private static Map<String, Object> emptyPage() {
Map<String, Object> out = new HashMap<>();
out.put("total", 0);
out.put("list", new ArrayList<>());
return out;
}
private static String text(JsonNode node, String field) {
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
}
}