789 lines
38 KiB
Java
789 lines
38 KiB
Java
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;
|
||
}
|
||
}
|