Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f10d8d4eb | ||
|
|
ae4bc5ecf6 | ||
|
|
847f13445d | ||
|
|
15be1ffabb | ||
|
|
7924b2d654 |
@@ -10,7 +10,8 @@ import java.util.Map;
|
|||||||
/**
|
/**
|
||||||
* 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
|
* 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
|
||||||
* UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
|
* UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
|
||||||
* jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按 bizType 查基址表拼绝对 jumpUrl。
|
* jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按应用注册表(UserBase by-code/by-key
|
||||||
|
* 取 pc_url)拼绝对 jumpUrl;手工基址表 jump-bases 已退役(C2)。
|
||||||
*/
|
*/
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
@@ -20,6 +21,12 @@ public class MsgCenterProperties {
|
|||||||
/** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */
|
/** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */
|
||||||
private String usermsgBaseUrl = "";
|
private String usermsgBaseUrl = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**);空 = 走服务目录
|
||||||
|
* flow-center(PlatformService 枚举兜底,key 与部署名对齐)。不可达时直批降级提示,不阻塞消息卡片。
|
||||||
|
*/
|
||||||
|
private String flowCenterBaseUrl = "";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址,
|
* 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址,
|
||||||
* 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。
|
* 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。
|
||||||
@@ -29,7 +36,4 @@ public class MsgCenterProperties {
|
|||||||
|
|
||||||
/** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
|
/** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
|
||||||
private Map<String, String> appNames = new LinkedHashMap<>();
|
private Map<String, String> appNames = new LinkedHashMap<>();
|
||||||
|
|
||||||
/** bizType → 应用 front 基址(jumpUrl = 基址 + "/#" + jumpPath);生产同主域用相对段如 /flow-center */
|
|
||||||
private Map<String, String> jumpBases = new LinkedHashMap<>();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package com.yunhai.userhome.controller.api;
|
|||||||
|
|
||||||
import com.yunhai.platform.utils.model.BoaUser;
|
import com.yunhai.platform.utils.model.BoaUser;
|
||||||
import com.yunhai.userhome.config.Config;
|
import com.yunhai.userhome.config.Config;
|
||||||
|
import com.yunhai.userhome.model.MsgHandleRequest;
|
||||||
|
import com.yunhai.userhome.service.MsgApprovalProxyService;
|
||||||
import com.yunhai.userhome.service.MsgCenterProxyService;
|
import com.yunhai.userhome.service.MsgCenterProxyService;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestParam;
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
@@ -24,6 +27,7 @@ import java.util.Map;
|
|||||||
public class MessageCenterController {
|
public class MessageCenterController {
|
||||||
|
|
||||||
private final MsgCenterProxyService proxy;
|
private final MsgCenterProxyService proxy;
|
||||||
|
private final MsgApprovalProxyService approvalProxy;
|
||||||
|
|
||||||
/** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */
|
/** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */
|
||||||
private Integer resolveSchId(BoaUser user) {
|
private Integer resolveSchId(BoaUser user) {
|
||||||
@@ -73,4 +77,16 @@ public class MessageCenterController {
|
|||||||
public void readAll(BoaUser authUserWithDept) {
|
public void readAll(BoaUser authUserWithDept) {
|
||||||
proxy.readAll(authUserWithDept.getId());
|
proxy.readAll(authUserWithDept.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片直批(B3 §2):同意/拒绝消息对应的审批待办。
|
||||||
|
* 红线:operatorId/schId/operatorName 一律取当前会话,绝不接受前端传参(防越权代批)。
|
||||||
|
*
|
||||||
|
* @return {ok, message}——ok=false 时 message 为可直接展示的文案(已处理/多节点/业务拒绝/不可达降级)
|
||||||
|
*/
|
||||||
|
@PostMapping("msgCenter/handle")
|
||||||
|
public Map<String, Object> handle(BoaUser authUserWithDept, @RequestBody MsgHandleRequest request) {
|
||||||
|
return approvalProxy.handle(authUserWithDept.getId(), resolveSchId(authUserWithDept),
|
||||||
|
authUserWithDept.getName(), request.getMsgId(), request.getAction(), request.getOpinion());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.yunhai.userhome.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片直批入参(B3 §2)。只含消息定位与动作——operatorId/schId/operatorName
|
||||||
|
* 一律服务端从当前会话取,绝不从前端接收(防越权代批)。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class MsgHandleRequest {
|
||||||
|
|
||||||
|
private Long msgId;
|
||||||
|
|
||||||
|
/** approve | reject */
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
/** 办理意见:拒绝必填,同意可选 */
|
||||||
|
private String opinion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
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.utils.exception.DException;
|
||||||
|
import com.yunhai.userhome.config.MsgCenterProperties;
|
||||||
|
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.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片直批代理(方案 §2,B3):消息卡片上的「同意/拒绝」在 UserHome 服务端完成
|
||||||
|
* 消息定位 → flow-center 待办匹配 → 办理 → 标已读 全链路。
|
||||||
|
* <ul>
|
||||||
|
* <li><b>红线</b>:operatorId / schId / operatorName 一律由控制器从当前 CAS 会话取
|
||||||
|
* (BoaUser),绝不接受前端传参——防越权代批;flow-center 侧再按任务办理人逐字复核,双层防护。</li>
|
||||||
|
* <li>taskId 定位(§2.3):我的待办(tasks/todo,assigneeId=登录人)里按 bizId 精确匹配。
|
||||||
|
* 实测契约:flow-center 待办消息 bizId=办件 id(instanceId),故匹配键 = instanceId,
|
||||||
|
* bizId(业务记录 id)作为通用回落;bizType 过滤键用消息 jumpApp(=流程业务类型,
|
||||||
|
* 消息 bizType「flow.task」是消息分类,与待办的 bizType 不同命名空间,勿混)。</li>
|
||||||
|
* <li>匹配 0 条=已处理或已失效;>1 条(同办件多出口/多节点)=请前往详情页办理(一期不做歧义猜选)。</li>
|
||||||
|
* <li>出站走 {@link YschOpenCall} 签名直调,目标 flow-center(服务目录/枚举兜底,
|
||||||
|
* flow-center-base-url 显式覆盖优先)。flow-center 不可达降级为提示,不影响消息卡片其余功能。</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class MsgApprovalProxyService {
|
||||||
|
|
||||||
|
private static final String TARGET_FLOW_CENTER = "flow-center";
|
||||||
|
|
||||||
|
/** 我的待办分页拉取上限:5 页 × 100 条(todo 量级远小于此,防御性封顶) */
|
||||||
|
private static final int TODO_MAX_PAGES = 5;
|
||||||
|
private static final int TODO_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
private final YschOpenCall openCall;
|
||||||
|
private final MsgCenterProxyService msgProxy;
|
||||||
|
private final MsgCenterProperties properties;
|
||||||
|
|
||||||
|
public MsgApprovalProxyService(YschOpenCall openCall, MsgCenterProxyService msgProxy,
|
||||||
|
MsgCenterProperties properties) {
|
||||||
|
this.openCall = openCall;
|
||||||
|
this.msgProxy = msgProxy;
|
||||||
|
this.properties = properties;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 办理消息对应的审批待办。action=approve|reject;reject 意见必填。
|
||||||
|
*
|
||||||
|
* @return {ok, message}——ok=false 时 message 为可直接展示的用户文案
|
||||||
|
*/
|
||||||
|
public Map<String, Object> handle(Integer userId, Integer schId, String operatorName,
|
||||||
|
Long msgId, String action, String opinion) {
|
||||||
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
boolean approve = "approve".equals(action);
|
||||||
|
if (!approve && !"reject".equals(action)) {
|
||||||
|
return fail(out, "不支持的操作类型");
|
||||||
|
}
|
||||||
|
if (!approve && !StringUtils.hasText(opinion)) {
|
||||||
|
return fail(out, "拒绝必须填写意见");
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode msg = msgProxy.findMessage(userId, msgId);
|
||||||
|
if (msg == null) {
|
||||||
|
return fail(out, "消息不存在或已失效");
|
||||||
|
}
|
||||||
|
String bizId = text(msg, "bizId");
|
||||||
|
if (!StringUtils.hasText(bizId)) {
|
||||||
|
return fail(out, "该消息不含可办理的业务标识");
|
||||||
|
}
|
||||||
|
String flowBizType = text(msg, "jumpApp");
|
||||||
|
|
||||||
|
List<JsonNode> matched = matchTodoTasks(schId, userId, flowBizType, bizId.trim());
|
||||||
|
if (matched == null) {
|
||||||
|
// flow-center 不可达 ≠ 已处理:降级提示,不误导用户
|
||||||
|
return fail(out, "流程中心暂不可用,请稍后重试");
|
||||||
|
}
|
||||||
|
if (matched.isEmpty()) {
|
||||||
|
return fail(out, "该待办已处理或已失效");
|
||||||
|
}
|
||||||
|
if (matched.size() > 1) {
|
||||||
|
return fail(out, "同一办件匹配到多个待办节点,请前往详情页办理");
|
||||||
|
}
|
||||||
|
long taskId = matched.get(0).path("taskId").asLong();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("schId", String.valueOf(schId == null ? 0 : schId));
|
||||||
|
query.put("operatorId", String.valueOf(userId));
|
||||||
|
query.put("operatorName", operatorName == null ? "" : operatorName);
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("opinion", opinion);
|
||||||
|
postFlowcenter("/oapi/flow/task/" + taskId + "/" + action, query, body);
|
||||||
|
} catch (DException e) {
|
||||||
|
// flow-center 业务码拒绝(非办理人/多出口需 edgeName 等)——原文透出给用户
|
||||||
|
log.info("[msgcenter] 直批被 flow-center 业务拒绝 msgId={} taskId={}:{}", msgId, taskId, e.getMessage());
|
||||||
|
return fail(out, e.getMessage());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[msgcenter] flow-center 直批不可达(降级提示)msgId={}:{}", msgId, e.toString());
|
||||||
|
return fail(out, "流程中心暂不可用,请稍后重试");
|
||||||
|
}
|
||||||
|
|
||||||
|
msgProxy.read(userId, java.util.Collections.singletonList(msgId));
|
||||||
|
out.put("ok", true);
|
||||||
|
out.put("message", "办理成功");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的待办中精确匹配目标办件:先按 flowBizType(=消息 jumpApp)过滤(未声明则不过滤),
|
||||||
|
* 再按 instanceId(flow-center 待办消息约定)或 bizId(业务方自发消息约定)匹配。
|
||||||
|
*
|
||||||
|
* @return 匹配行(可能为空);flow-center 不可达返回 null(区别于「已处理」的 0 匹配)
|
||||||
|
*/
|
||||||
|
private List<JsonNode> matchTodoTasks(Integer schId, Integer userId, String flowBizType, String bizId) {
|
||||||
|
List<JsonNode> matched = new ArrayList<>();
|
||||||
|
for (int page = 1; page <= TODO_MAX_PAGES; page++) {
|
||||||
|
List<JsonNode> content;
|
||||||
|
try {
|
||||||
|
content = fetchTodoPage(schId, userId, flowBizType, page);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[msgcenter] flow-center 待办查询不可达 msgId 定位失败:{}", e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (JsonNode task : content) {
|
||||||
|
String taskBizType = text(task, "bizType");
|
||||||
|
if (StringUtils.hasText(flowBizType) && !flowBizType.equals(taskBizType)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String instanceId = task.has("instanceId") && task.get("instanceId").isNumber()
|
||||||
|
? String.valueOf(task.get("instanceId").asLong()) : null;
|
||||||
|
if (bizId.equals(instanceId) || bizId.equals(text(task, "bizId"))) {
|
||||||
|
matched.add(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (content.size() < TODO_PAGE_SIZE) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<JsonNode> fetchTodoPage(Integer schId, Integer userId, String flowBizType, int page) {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("schId", String.valueOf(schId == null ? 0 : schId));
|
||||||
|
query.put("assigneeId", String.valueOf(userId));
|
||||||
|
if (StringUtils.hasText(flowBizType)) {
|
||||||
|
query.put("bizType", flowBizType);
|
||||||
|
}
|
||||||
|
query.put("page", String.valueOf(page));
|
||||||
|
query.put("size", String.valueOf(TODO_PAGE_SIZE));
|
||||||
|
JsonNode data = getFlowcenter("/oapi/flow/tasks/todo", query);
|
||||||
|
if (data != null && data.path("content").isArray()) {
|
||||||
|
List<JsonNode> rows = new ArrayList<>();
|
||||||
|
data.get("content").forEach(rows::add);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显式 base-url → ad-hoc TargetSpec(对齐 usermsg 通道形态);否则按名走服务目录/枚举兜底 */
|
||||||
|
private JsonNode getFlowcenter(String path, Map<String, String> query) {
|
||||||
|
String base = properties.getFlowCenterBaseUrl();
|
||||||
|
if (StringUtils.hasText(base)) {
|
||||||
|
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
|
||||||
|
}
|
||||||
|
return openCall.get(TARGET_FLOW_CENTER, path, query, JsonNode.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode postFlowcenter(String path, Map<String, String> query, Object body) {
|
||||||
|
String base = properties.getFlowCenterBaseUrl();
|
||||||
|
if (StringUtils.hasText(base)) {
|
||||||
|
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
|
||||||
|
}
|
||||||
|
return openCall.post(TARGET_FLOW_CENTER, path, query, body, JsonNode.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> fail(Map<String, Object> out, String message) {
|
||||||
|
out.put("ok", false);
|
||||||
|
out.put("message", message);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,11 @@ import org.springframework.util.StringUtils;
|
|||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
|
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
|
||||||
@@ -21,8 +23,11 @@ import java.util.Map;
|
|||||||
* <ul>
|
* <ul>
|
||||||
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li>
|
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li>
|
||||||
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li>
|
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li>
|
||||||
* <li>jumpUrl 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath
|
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
|
||||||
* (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。</li>
|
* 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
|
||||||
|
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
|
||||||
|
* <li>showJump 判权(C1):jumpApp 有值时按 /oapi/app/authorized-codes 集合判定,
|
||||||
|
* 无权 → showJump=false 且 jumpUrl 一并置 null;jumpApp 为空或 UserBase 不可达不判权放行。</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
|
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
|
||||||
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
|
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
|
||||||
@@ -36,10 +41,21 @@ import java.util.Map;
|
|||||||
public class MsgCenterProxyService {
|
public class MsgCenterProxyService {
|
||||||
|
|
||||||
private static final String TARGET_USERMSG = "usermsg";
|
private static final String TARGET_USERMSG = "usermsg";
|
||||||
|
private static final String TARGET_USERBASE = "userbase";
|
||||||
|
|
||||||
|
/** UserBase 查址失败短路窗口:列表逐行查址,下游停摆时避免每行都吃一次连接超时 */
|
||||||
|
static final long USERBASE_RETRY_MS = 30_000L;
|
||||||
|
|
||||||
|
/** 消息定位扫描上限:5 页 × 100 条(UserMsg 无按 id 直查端点,逐页扫描兜底) */
|
||||||
|
static final int MSG_SCAN_PAGES = 5;
|
||||||
|
static final int MSG_SCAN_PAGE_SIZE = 100;
|
||||||
|
|
||||||
private final YschOpenCall openCall;
|
private final YschOpenCall openCall;
|
||||||
private final MsgCenterProperties properties;
|
private final MsgCenterProperties properties;
|
||||||
|
|
||||||
|
/** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
|
||||||
|
private volatile long userbaseDownUntil;
|
||||||
|
|
||||||
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) {
|
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) {
|
||||||
this.openCall = openCall;
|
this.openCall = openCall;
|
||||||
this.properties = properties;
|
this.properties = properties;
|
||||||
@@ -66,12 +82,13 @@ public class MsgCenterProxyService {
|
|||||||
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
|
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl */
|
/** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl 与 showJump 判权出参 */
|
||||||
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) {
|
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) {
|
||||||
Map<String, Object> empty = emptyPage();
|
Map<String, Object> empty = emptyPage();
|
||||||
if (!enabled()) {
|
if (!enabled()) {
|
||||||
return empty;
|
return empty;
|
||||||
}
|
}
|
||||||
|
PerRequestAuthz authz = new PerRequestAuthz(userId);
|
||||||
try {
|
try {
|
||||||
Map<String, String> query = new LinkedHashMap<>();
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
query.put("userId", String.valueOf(userId));
|
query.put("userId", String.valueOf(userId));
|
||||||
@@ -82,7 +99,7 @@ public class MsgCenterProxyService {
|
|||||||
List<Map<String, Object>> rows = new ArrayList<>();
|
List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
if (data != null && data.path("content").isArray()) {
|
if (data != null && data.path("content").isArray()) {
|
||||||
for (JsonNode row : data.get("content")) {
|
for (JsonNode row : data.get("content")) {
|
||||||
rows.add(rewrite(row));
|
rows.add(rewrite(row, authz));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Map<String, Object> out = new HashMap<>();
|
Map<String, Object> out = new HashMap<>();
|
||||||
@@ -155,6 +172,41 @@ public class MsgCenterProxyService {
|
|||||||
post("/oapi/msg/readAll", new HashMap<>(), body);
|
post("/oapi/msg/readAll", new HashMap<>(), body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 msgId 定位当前登录人的消息(卡片直批 B3 用)。归属校验=按登录人分页拉列表后命中:
|
||||||
|
* 他人消息/已删除/已过期一律查无,统一返回 null(不区分原因,防越权探测)。
|
||||||
|
*/
|
||||||
|
public JsonNode findMessage(Integer userId, Long msgId) {
|
||||||
|
if (!enabled() || msgId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (int page = 1; page <= MSG_SCAN_PAGES; page++) {
|
||||||
|
try {
|
||||||
|
Map<String, String> query = new LinkedHashMap<>();
|
||||||
|
query.put("userId", String.valueOf(userId));
|
||||||
|
query.put("pageIndex", String.valueOf(page));
|
||||||
|
query.put("pageSize", String.valueOf(MSG_SCAN_PAGE_SIZE));
|
||||||
|
JsonNode data = get("/oapi/msg/list", query);
|
||||||
|
if (data == null || !data.path("content").isArray()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (JsonNode row : data.get("content")) {
|
||||||
|
if (row.has("msgId") && row.get("msgId").asLong() == msgId) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.get("content").size() < MSG_SCAN_PAGE_SIZE
|
||||||
|
|| data.path("total").asLong() <= (long) page * MSG_SCAN_PAGE_SIZE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[msgcenter] UserMsg 消息定位不可达 msgId={}:{}", msgId, e.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WS 握手票据:以应用身份为登录人换一次性票据(UserMsg /oapi/msg/ws/ticket),
|
* WS 握手票据:以应用身份为登录人换一次性票据(UserMsg /oapi/msg/ws/ticket),
|
||||||
* 拼上浏览器可达的 WS 端点返回。未配置 WS 地址 / UserMsg 不可达 → null(前端维持轮询兜底表现)。
|
* 拼上浏览器可达的 WS 端点返回。未配置 WS 地址 / UserMsg 不可达 → null(前端维持轮询兜底表现)。
|
||||||
@@ -182,8 +234,8 @@ public class MsgCenterProxyService {
|
|||||||
|
|
||||||
// ===== 出参改写 =====
|
// ===== 出参改写 =====
|
||||||
|
|
||||||
/** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且配置了 bizType 基址时)+ 应用名补全 */
|
/** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用名补全 */
|
||||||
private Map<String, Object> rewrite(JsonNode row) {
|
private Map<String, Object> rewrite(JsonNode row, PerRequestAuthz authz) {
|
||||||
Map<String, Object> out = new LinkedHashMap<>();
|
Map<String, Object> out = new LinkedHashMap<>();
|
||||||
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null);
|
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null);
|
||||||
out.put("title", text(row, "title"));
|
out.put("title", text(row, "title"));
|
||||||
@@ -191,6 +243,7 @@ public class MsgCenterProxyService {
|
|||||||
out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").asInt() : null);
|
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("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null);
|
||||||
out.put("jumpPath", text(row, "jumpPath"));
|
out.put("jumpPath", text(row, "jumpPath"));
|
||||||
|
out.put("jumpApp", text(row, "jumpApp"));
|
||||||
out.put("appKey", text(row, "appKey"));
|
out.put("appKey", text(row, "appKey"));
|
||||||
String appKey = text(row, "appKey");
|
String appKey = text(row, "appKey");
|
||||||
out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey));
|
out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey));
|
||||||
@@ -198,29 +251,146 @@ public class MsgCenterProxyService {
|
|||||||
out.put("bizId", text(row, "bizId"));
|
out.put("bizId", text(row, "bizId"));
|
||||||
out.put("sendTime", text(row, "sendTime"));
|
out.put("sendTime", text(row, "sendTime"));
|
||||||
out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0);
|
out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0);
|
||||||
out.put("jumpUrl", jumpUrl(row));
|
String jumpUrl = jumpUrl(row);
|
||||||
|
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
|
||||||
|
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
|
||||||
|
Boolean allowed = null;
|
||||||
|
String jumpApp = text(row, "jumpApp");
|
||||||
|
if (jumpUrl != null && StringUtils.hasText(jumpApp)) {
|
||||||
|
allowed = authz.authorized(jumpApp.trim());
|
||||||
|
}
|
||||||
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 基址 + "/#" + jumpPath;未配置基址/非应用内跳转/无路径 → null(前端不渲染跳转) */
|
/**
|
||||||
|
* 基址解析(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(JsonNode row) {
|
private String jumpUrl(JsonNode row) {
|
||||||
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null;
|
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null;
|
||||||
String jumpPath = text(row, "jumpPath");
|
String jumpPath = text(row, "jumpPath");
|
||||||
String bizType = text(row, "bizType");
|
String jumpApp = text(row, "jumpApp");
|
||||||
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)
|
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
|
||||||
|| !StringUtils.hasText(bizType)) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String base = properties.getJumpBases().get(bizType);
|
String path = jumpPath.trim();
|
||||||
if (!StringUtils.hasText(base)) {
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
if (userbaseDown()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String b = base.trim();
|
String p = path.startsWith("/") ? path : "/" + path;
|
||||||
while (b.endsWith("/")) {
|
String appKey = text(row, "appKey");
|
||||||
b = b.substring(0, b.length() - 1);
|
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 主查)。UserBase 不可达/查无 → null,失败进短路窗口 */
|
||||||
|
private String pcUrlByCode(String appCode) {
|
||||||
|
return lookupPcUrl("/oapi/app/by-code/" + appCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册表 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;
|
||||||
}
|
}
|
||||||
String path = jumpPath.startsWith("/") ? jumpPath : "/" + jumpPath;
|
|
||||||
return b + "/#" + path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Map<String, Object> emptyPage() {
|
private static Map<String, Object> emptyPage() {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ yh:
|
|||||||
app-key: 100001
|
app-key: 100001
|
||||||
app-secret: ec4e860206374c80938719fff8e14a86
|
app-secret: ec4e860206374c80938719fff8e14a86
|
||||||
open:
|
open:
|
||||||
# yh-openapi 服务目录:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx;
|
# yh-openapi 服务目录:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx;
|
||||||
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
|
||||||
base-host: http://base.yunhaixingtong.com
|
base-host: http://base.yunhaixingtong.com
|
||||||
base-path: /userbase
|
base-path: /userbase
|
||||||
@@ -90,21 +90,18 @@ userhome:
|
|||||||
# 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底);
|
# 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底);
|
||||||
# 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg
|
# 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg
|
||||||
usermsg-ws-url: ws://localhost:8080/ws/msg
|
usermsg-ws-url: ws://localhost:8080/ws/msg
|
||||||
|
# flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**)。空 = 走服务目录 flow-center
|
||||||
|
# (PlatformService 枚举兜底);不可达时直批降级提示,不阻塞消息卡片
|
||||||
|
flow-center-base-url: http://localhost:8085
|
||||||
# appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案)
|
# appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案)
|
||||||
app-names:
|
app-names:
|
||||||
100009: 通知公告
|
100009: 通知公告
|
||||||
100617: 通知公告
|
100617: 通知公告
|
||||||
100019: 流程中心
|
100019: 流程中心
|
||||||
100001: 用户主页
|
100001: 用户主页
|
||||||
# bizType → 应用 front 基址:jumpPath 是裸相对路径(如 /task/123)且落地页是 hash 路由,
|
# 跳转基址唯一来源 = 应用注册表(UserBase /oapi/app/by-code|by-key 取 pc_url,§3.2 归一化后
|
||||||
# 代理拼 jumpUrl = 基址 + "/#" + jumpPath。生产(Nacos userhome.yaml 覆盖)同主域相对段:
|
# 拼 "#" + jumpPath);已是绝对 URL 的存量消息直接透传。手工基址表 jump-bases 已退役(C2),
|
||||||
# jump-bases: { flow: /flow-center, flow.task: /flow-center, flow.result: /flow-center, flow.urge: /flow-center }
|
# 生产 Nacos userhome.yaml 若残留 jump-bases 段应一并清理(Spring 静默忽略,留着误导)。
|
||||||
jump-bases:
|
|
||||||
flow: http://localhost:8088
|
|
||||||
flow.task: http://localhost:8088
|
|
||||||
flow.result: http://localhost:8088
|
|
||||||
flow.urge: http://localhost:8088
|
|
||||||
notice: http://localhost:8077
|
|
||||||
|
|
||||||
# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。
|
# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。
|
||||||
# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患;
|
# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患;
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package com.yunhai.userhome.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.yunhai.platform.openapi.core.TargetSpec;
|
||||||
|
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||||
|
import com.yunhai.platform.utils.exception.DException;
|
||||||
|
import com.yunhai.userhome.config.MsgCenterProperties;
|
||||||
|
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.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片直批代理单测(B3 §2):taskId 匹配三态(0/1/>1)、
|
||||||
|
* <b>红线=身份只从会话参数出站</b>(operatorId/schId/operatorName 来自控制器入参,前端只有
|
||||||
|
* msgId/action/opinion)、业务拒绝原文透出、flow-center 不可达降级。
|
||||||
|
* 契约冻结(源码实测):flow-center 待办消息 bizId=办件 id → 匹配键 instanceId;
|
||||||
|
* bizType 过滤键=消息 jumpApp(流程业务类型),非消息分类 flow.task。
|
||||||
|
*/
|
||||||
|
@DisplayName("MsgApprovalProxyService - 卡片直批")
|
||||||
|
class MsgApprovalProxyServiceTest {
|
||||||
|
|
||||||
|
private static final String FLOW_CENTER_BASE = "http://flowcenter-service:10008/flow-center";
|
||||||
|
|
||||||
|
private YschOpenCall openCall;
|
||||||
|
private MsgCenterProxyService msgProxy;
|
||||||
|
private MsgCenterProperties properties;
|
||||||
|
private MsgApprovalProxyService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
openCall = mock(YschOpenCall.class);
|
||||||
|
msgProxy = mock(MsgCenterProxyService.class);
|
||||||
|
properties = new MsgCenterProperties();
|
||||||
|
properties.setFlowCenterBaseUrl(FLOW_CENTER_BASE);
|
||||||
|
service = new MsgApprovalProxyService(openCall, msgProxy, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode json(String s) throws Exception {
|
||||||
|
return new ObjectMapper().readTree(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** flow-center 发的待办消息(notifyTaskCreated 契约):bizId=办件 id、jumpApp=流程业务类型 */
|
||||||
|
private JsonNode flowTaskMessage() throws Exception {
|
||||||
|
return json("{\"msgId\":5,\"msgType\":2,\"bizType\":\"flow.task\","
|
||||||
|
+ "\"bizId\":\"123\",\"jumpApp\":\"notice-approval\",\"isRead\":0}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubTodo(String content) throws Exception {
|
||||||
|
when(openCall.get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), anyMap(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(json("{\"total\":1,\"content\":[" + content + "]}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectWithoutOpinion_rejectedBeforeAnyOutbound() {
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "reject", " ");
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("拒绝必须填写意见", out.get("message"));
|
||||||
|
verifyNoInteractions(openCall, msgProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidAction_rejected() {
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "delegate", null);
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("不支持的操作类型", out.get("message"));
|
||||||
|
verifyNoInteractions(openCall, msgProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void messageNotFound_stopsWithoutFlowcenterCall() throws Exception {
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(null);
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("消息不存在或已失效", out.get("message"), "含他人消息/已删除——统一文案防探测");
|
||||||
|
verifyNoInteractions(openCall);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void todoZeroMatch_reportProcessed() throws Exception {
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
|
||||||
|
when(openCall.get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), anyMap(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(json("{\"total\":0,\"content\":[]}"));
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("该待办已处理或已失效", out.get("message"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void flowCenterUnreachable_distinguishedFromZeroMatch() throws Exception {
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
|
||||||
|
when(openCall.get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), anyMap(), eq(JsonNode.class)))
|
||||||
|
.thenThrow(new RuntimeException("connect timeout"));
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("流程中心暂不可用,请稍后重试", out.get("message"), "不可达 ≠ 已处理,降级提示不误导");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleMatch_approve_postsSessionIdentity_andMarksRead() throws Exception {
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
|
||||||
|
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\","
|
||||||
|
+ "\"bizId\":\"9001\",\"assigneeId\":201}");
|
||||||
|
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(null);
|
||||||
|
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", "同意");
|
||||||
|
|
||||||
|
assertTrue((Boolean) out.get("ok"));
|
||||||
|
assertEquals("办理成功", out.get("message"));
|
||||||
|
|
||||||
|
// 红线断言:出站身份 = 会话入参(schId=3 / operatorId=201 / operatorName=张三),前端无身份入参
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Map<String, String>> postQuery = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(openCall).post(any(TargetSpec.class), eq("/oapi/flow/task/77/approve"),
|
||||||
|
postQuery.capture(), any(), eq(JsonNode.class));
|
||||||
|
assertEquals("3", postQuery.getValue().get("schId"));
|
||||||
|
assertEquals("201", postQuery.getValue().get("operatorId"));
|
||||||
|
assertEquals("张三", postQuery.getValue().get("operatorName"));
|
||||||
|
|
||||||
|
ArgumentCaptor<List<Long>> readIds = ArgumentCaptor.forClass(List.class);
|
||||||
|
verify(msgProxy).read(eq(201), readIds.capture());
|
||||||
|
assertTrue(readIds.getValue().contains(5L), "办理成功后标记已读");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void todoFilteredByJumpApp_notMessageBizType() throws Exception {
|
||||||
|
// bizType 过滤键 = 消息 jumpApp(流程业务类型 notice-approval),不是消息分类 flow.task
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
|
||||||
|
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\",\"bizId\":\"9001\"}");
|
||||||
|
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(null);
|
||||||
|
|
||||||
|
service.handle(201, 3, "张三", 5L, "approve", null);
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
ArgumentCaptor<Map<String, String>> todoQuery = ArgumentCaptor.forClass(Map.class);
|
||||||
|
verify(openCall).get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), todoQuery.capture(),
|
||||||
|
eq(JsonNode.class));
|
||||||
|
assertEquals("notice-approval", todoQuery.getValue().get("bizType"), "消息分类 flow.task 与待办 bizType 不同命名空间");
|
||||||
|
assertEquals("201", todoQuery.getValue().get("assigneeId"), "待办按登录人维度拉取");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multiMatch_sameInstanceMultipleNodes_goDetail() throws Exception {
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
|
||||||
|
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\"},"
|
||||||
|
+ "{\"taskId\":78,\"instanceId\":123,\"bizType\":\"notice-approval\"}");
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("同一办件匹配到多个待办节点,请前往详情页办理", out.get("message"));
|
||||||
|
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void businessReject_passthroughServerMessage() throws Exception {
|
||||||
|
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
|
||||||
|
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\"}");
|
||||||
|
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class)))
|
||||||
|
.thenThrow(new DException("当前用户不是该任务办理人"));
|
||||||
|
|
||||||
|
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
|
||||||
|
|
||||||
|
assertFalse((Boolean) out.get("ok"));
|
||||||
|
assertEquals("当前用户不是该任务办理人", out.get("message"), "业务拒绝原文透出(flow-center 逐字复核)");
|
||||||
|
verify(msgProxy, never()).read(any(), any());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,15 +11,17 @@ import org.junit.jupiter.api.DisplayName;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyMap;
|
import static org.mockito.ArgumentMatchers.anyMap;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
@@ -27,16 +29,23 @@ import static org.mockito.Mockito.when;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约
|
* 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约
|
||||||
* (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与
|
* (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)、
|
||||||
* jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。
|
* jumpUrl 改写规则与 showJump 判权出参(C1/C2 收口后口径)。
|
||||||
* UserMsg 不可达降级空结果(铃铛不 500)。
|
* <p>
|
||||||
|
* jumpUrl(C2 收口):jumpType=1 时绝对 URL 直接透传;相对路径走应用注册表严格二选一——
|
||||||
|
* jumpApp(值域=app_code)有值只认 by-code 结果,jumpApp 为空才按发送方 appKey 走 by-key
|
||||||
|
* (自发场景);pc_url 空就是空,不再回落、jump-bases 手工基址表已退役。
|
||||||
|
* <p>
|
||||||
|
* showJump(C1 判权):jumpApp 有值且 jumpUrl 非空时按 /oapi/app/authorized-codes 集合判定;
|
||||||
|
* 无权 → showJump=false 且 jumpUrl 一并置 null(双保险);jumpApp 为空 / UserBase 不可达不判权放行;
|
||||||
|
* 判权集合每请求懒加载一次,行间共享。
|
||||||
* <p>
|
* <p>
|
||||||
* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0):
|
* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0):
|
||||||
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
|
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
|
||||||
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
|
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
|
||||||
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
|
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
|
||||||
*/
|
*/
|
||||||
@DisplayName("MsgCenterProxyService - YschOpenCall 代理与 jumpUrl 改写")
|
@DisplayName("MsgCenterProxyService - YschOpenCall 代理、jumpUrl 改写与 showJump 判权")
|
||||||
class MsgCenterProxyServiceTest {
|
class MsgCenterProxyServiceTest {
|
||||||
|
|
||||||
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
|
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
|
||||||
@@ -51,7 +60,6 @@ class MsgCenterProxyServiceTest {
|
|||||||
openCall = mock(YschOpenCall.class);
|
openCall = mock(YschOpenCall.class);
|
||||||
properties = new MsgCenterProperties();
|
properties = new MsgCenterProperties();
|
||||||
properties.setUsermsgBaseUrl(PROD_BASE_URL);
|
properties.setUsermsgBaseUrl(PROD_BASE_URL);
|
||||||
properties.getJumpBases().put("flow.task", "http://localhost:8088/");
|
|
||||||
service = new MsgCenterProxyService(openCall, properties);
|
service = new MsgCenterProxyService(openCall, properties);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +67,20 @@ class MsgCenterProxyServiceTest {
|
|||||||
return new ObjectMapper().readTree(s);
|
return new ObjectMapper().readTree(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */
|
||||||
|
private void stubAuthorizedCodes(String... codes) throws Exception {
|
||||||
|
StringBuilder arr = new StringBuilder("[");
|
||||||
|
for (int i = 0; i < codes.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
arr.append(',');
|
||||||
|
}
|
||||||
|
arr.append('"').append(codes[i]).append('"');
|
||||||
|
}
|
||||||
|
arr.append(']');
|
||||||
|
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(json(arr.toString()));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
|
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
|
||||||
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
|
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
|
||||||
@@ -76,6 +98,9 @@ class MsgCenterProxyServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void list_buildsQueryParams_andRewritesJumpUrl() throws Exception {
|
void list_buildsQueryParams_andRewritesJumpUrl() throws Exception {
|
||||||
properties.getAppNames().put("100617", "通知公告");
|
properties.getAppNames().put("100617", "通知公告");
|
||||||
|
// 自发消息无 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)))
|
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)))
|
||||||
.thenReturn(json("{\"total\":2,\"content\":["
|
.thenReturn(json("{\"total\":2,\"content\":["
|
||||||
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
|
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
|
||||||
@@ -97,10 +122,13 @@ class MsgCenterProxyServiceTest {
|
|||||||
assertEquals(2, out.get("total"));
|
assertEquals(2, out.get("total"));
|
||||||
java.util.List<?> rows = (java.util.List<?>) out.get("list");
|
java.util.List<?> rows = (java.util.List<?>) out.get("list");
|
||||||
Map<?, ?> first = (Map<?, ?>) rows.get(0);
|
Map<?, ?> first = (Map<?, ?>) rows.get(0);
|
||||||
assertEquals("http://localhost:8088/#/task/123", first.get("jumpUrl"), "基址+/#+裸路径");
|
assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
|
||||||
assertEquals("flow.task", first.get("bizType"));
|
assertEquals("flow.task", first.get("bizType"));
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
|
||||||
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
|
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
|
||||||
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写");
|
Map<?, ?> second = (Map<?, ?>) rows.get(1);
|
||||||
|
assertNull(second.get("jumpUrl"), "非应用内跳转不改写");
|
||||||
|
assertEquals(Boolean.FALSE, second.get("showJump"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -127,15 +155,104 @@ class MsgCenterProxyServiceTest {
|
|||||||
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
|
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** jumpApp 与 appKey 均空 → 注册表无源可查 → null(前端不渲染跳转),且不产生判权/查址出站 */
|
||||||
@Test
|
@Test
|
||||||
void list_noBaseConfigured_jumpUrlNull() throws Exception {
|
void list_noRegistrySource_jumpUrlNull() throws Exception {
|
||||||
properties.getJumpBases().clear();
|
|
||||||
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(JsonNode.class)))
|
||||||
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
|
||||||
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
|
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
|
||||||
Map<String, Object> out = service.list(201, 1, 10, null);
|
Map<?, ?> first = firstRow();
|
||||||
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
|
assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
|
||||||
assertNull(first.get("jumpUrl"), "bizType 未配基址 → 不改写(前端不渲染跳转)");
|
assertEquals(Boolean.FALSE, first.get("showJump"));
|
||||||
|
verify(openCall, never()).get(eq("userbase"), anyString(), anyMap(), eq(JsonNode.class));
|
||||||
|
verify(openCall, never()).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 =====
|
||||||
|
|
||||||
|
/** 归属应用 jumpApp 主查注册表 by-code,pc_url 拼接 + 判权放行 */
|
||||||
|
@Test
|
||||||
|
void list_jumpApp_byCodeHit_authorized_showJumpTrue() throws Exception {
|
||||||
|
stubUserbaseGet("/oapi/app/by-code/notice",
|
||||||
|
"{\"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,"
|
||||||
|
+ "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\","
|
||||||
|
+ "\"isRead\":0}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
|
||||||
|
"归属应用 jumpApp 主查 by-code");
|
||||||
|
assertEquals("notice", first.get("jumpApp"), "jumpApp 逐字段透传");
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"), "有权 → 显示跳转入口");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 存量兼容:改造前落库的绝对 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,"
|
||||||
|
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\","
|
||||||
|
+ "\"bizType\":\"flow.task\",\"isRead\":0}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"),
|
||||||
|
"绝对 URL 原样透传");
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 旧发送端未升级(无 jumpApp)的自发消息 → 按发送方 appKey 走 by-key(严格二选一的第二分支) */
|
||||||
|
@Test
|
||||||
|
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}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey");
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** jumpApp 查无 pc_url 且 appKey 为空 → null(不做猜测) */
|
||||||
|
@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,"
|
||||||
|
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\","
|
||||||
|
+ "\"isRead\":0}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertNull(first.get("jumpUrl"));
|
||||||
|
assertEquals(Boolean.FALSE, first.get("showJump"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** P2 缺陷回归:jumpApp 有值但注册表查无 pc_url → 严格 null,绝不回落发送方 appKey */
|
||||||
|
@Test
|
||||||
|
void list_jumpAppPresent_byCodeMiss_noSenderAppKeyFallback() throws Exception {
|
||||||
|
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}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空");
|
||||||
|
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** pc_url 尾斜杠归一:注册表基址带尾斜杠也不产出 //(归一化并入注册表路径) */
|
||||||
|
@Test
|
||||||
|
void list_registryPcUrlTrailingSlash_normalized() throws Exception {
|
||||||
|
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}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
|
||||||
|
"尾斜杠归一 + 无前导斜杠路径补斜杠");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -251,4 +368,111 @@ class MsgCenterProxyServiceTest {
|
|||||||
Map<String, Object> out = service.summary(201, 1);
|
Map<String, Object> out = service.summary(201, 1);
|
||||||
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
|
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== B2(跳转基址统一):注册表 pc_url 主链路 =====
|
||||||
|
|
||||||
|
private void stubUserbaseGet(String path, String body) throws Exception {
|
||||||
|
when(openCall.get(eq("userbase"), eq(path), isNull(), eq(JsonNode.class)))
|
||||||
|
.thenReturn(body == null ? null : json(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<?, ?> firstRow() throws Exception {
|
||||||
|
Map<String, Object> out = service.list(201, 1, 10, null);
|
||||||
|
return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */
|
||||||
|
@Test
|
||||||
|
void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
|
||||||
|
stubUserbaseGet("/oapi/app/by-code/flow-center",
|
||||||
|
"{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
|
||||||
|
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
|
||||||
|
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}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
|
||||||
|
"丢 fragment + # 拼接,不产第二个 #");
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== C1(showJump 判权):无权隐藏、不可达放行、每请求一次出站 =====
|
||||||
|
|
||||||
|
/** 无权:showJump=false 且 jumpUrl 一并置 null(双保险,前端漏改也不可点) */
|
||||||
|
@Test
|
||||||
|
void list_jumpApp_unauthorized_showJumpFalse_jumpUrlNull() throws Exception {
|
||||||
|
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}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口");
|
||||||
|
assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** jumpApp 为空 → 不判权(避免误伤无归属消息),且不触发 authorized-codes 出站 */
|
||||||
|
@Test
|
||||||
|
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}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"));
|
||||||
|
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UserBase 判权不可达 → 不判权放行:绝对 URL 消息 showJump 保持 true(绝不批量隐藏) */
|
||||||
|
@Test
|
||||||
|
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,"
|
||||||
|
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\","
|
||||||
|
+ "\"isRead\":0}]}"));
|
||||||
|
Map<?, ?> first = firstRow();
|
||||||
|
assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行");
|
||||||
|
assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判权集合每请求懒加载一次:同列表多行带 jumpApp 共用一次出站(禁按行重复) */
|
||||||
|
@Test
|
||||||
|
void list_authzFetchedOncePerRequest_sharedAcrossRows() throws Exception {
|
||||||
|
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\":["
|
||||||
|
+ "{\"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);
|
||||||
|
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"));
|
||||||
|
verify(openCall, org.mockito.Mockito.times(1))
|
||||||
|
.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UserBase 停摆:首行查址失败进 30s 短路窗口,后续行不再出站;相对路径无基址可解 → 入口整体隐藏 */
|
||||||
|
@Test
|
||||||
|
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\":["
|
||||||
|
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0},"
|
||||||
|
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}"));
|
||||||
|
Map<String, Object> out = service.list(201, 1, 10, null);
|
||||||
|
java.util.List<?> rows = (java.util.List<?>) out.get("list");
|
||||||
|
assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null");
|
||||||
|
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"));
|
||||||
|
assertEquals(Boolean.FALSE, ((Map<?, ?>) rows.get(0)).get("showJump"));
|
||||||
|
// 首行仅 by-key 一次(无 jumpApp 不查 by-code)→ 短路后第二行 0 次,共 1 次
|
||||||
|
verify(openCall, org.mockito.Mockito.times(1)).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user