From 847f13445dd137ed6f60ea3b9084fb315529b872 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 28 Sep 2026 13:30:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(msgcenter):=20=E5=8D=A1=E7=89=87=E7=9B=B4?= =?UTF-8?q?=E6=89=B9=E2=80=94=E2=80=94MsgApprovalProxyService=20=E5=AE=9A?= =?UTF-8?q?=E4=BD=8D=E5=BE=85=E5=8A=9E=E5=8A=9E=20approve/reject=EF=BC=88B?= =?UTF-8?q?3=20=C2=A72=EF=BC=89=EF=BC=9A=E8=BA=AB=E4=BB=BD=E5=85=A8?= =?UTF-8?q?=E5=8F=96=E4=BC=9A=E8=AF=9D=E9=98=B2=E4=BB=A3=E6=89=B9=E3=80=81?= =?UTF-8?q?bizId=3D=E5=8A=9E=E4=BB=B6=20id=20=E5=8C=B9=E9=85=8D=20instance?= =?UTF-8?q?Id=E3=80=810/>1=20=E5=8C=B9=E9=85=8D=E6=98=8E=E7=A1=AE=E6=96=87?= =?UTF-8?q?=E6=A1=88=E3=80=81=E4=B8=9A=E5=8A=A1=E6=8B=92=E7=BB=9D=E5=8E=9F?= =?UTF-8?q?=E6=96=87=E9=80=8F=E5=87=BA=E3=80=81=E4=B8=8D=E5=8F=AF=E8=BE=BE?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=8D=E9=98=BB=E5=A1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userhome/config/MsgCenterProperties.java | 6 + .../api/MessageCenterController.java | 16 ++ .../userhome/model/MsgHandleRequest.java | 19 ++ .../service/MsgApprovalProxyService.java | 194 ++++++++++++++++++ src/main/resources/application.yml | 3 + .../service/MsgApprovalProxyServiceTest.java | 185 +++++++++++++++++ 6 files changed, 423 insertions(+) create mode 100644 src/main/java/com/yunhai/userhome/model/MsgHandleRequest.java create mode 100644 src/main/java/com/yunhai/userhome/service/MsgApprovalProxyService.java create mode 100644 src/test/java/com/yunhai/userhome/service/MsgApprovalProxyServiceTest.java diff --git a/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java b/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java index 863e0af..7a41438 100644 --- a/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java +++ b/src/main/java/com/yunhai/userhome/config/MsgCenterProperties.java @@ -20,6 +20,12 @@ public class MsgCenterProperties { /** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */ private String usermsgBaseUrl = ""; + /** + * flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**);空 = 走服务目录 + * flowcenter(PlatformService 枚举兜底)。不可达时直批降级提示,不阻塞消息卡片。 + */ + private String flowcenterBaseUrl = ""; + /** * 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址, * 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。 diff --git a/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java b/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java index 110d21c..6b4a7c7 100644 --- a/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java +++ b/src/main/java/com/yunhai/userhome/controller/api/MessageCenterController.java @@ -2,10 +2,13 @@ package com.yunhai.userhome.controller.api; import com.yunhai.platform.utils.model.BoaUser; 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 lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -24,6 +27,7 @@ import java.util.Map; public class MessageCenterController { private final MsgCenterProxyService proxy; + private final MsgApprovalProxyService approvalProxy; /** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */ private Integer resolveSchId(BoaUser user) { @@ -73,4 +77,16 @@ public class MessageCenterController { public void readAll(BoaUser authUserWithDept) { proxy.readAll(authUserWithDept.getId()); } + + /** + * 卡片直批(B3 §2):同意/拒绝消息对应的审批待办。 + * 红线:operatorId/schId/operatorName 一律取当前会话,绝不接受前端传参(防越权代批)。 + * + * @return {ok, message}——ok=false 时 message 为可直接展示的文案(已处理/多节点/业务拒绝/不可达降级) + */ + @PostMapping("msgCenter/handle") + public Map handle(BoaUser authUserWithDept, @RequestBody MsgHandleRequest request) { + return approvalProxy.handle(authUserWithDept.getId(), resolveSchId(authUserWithDept), + authUserWithDept.getName(), request.getMsgId(), request.getAction(), request.getOpinion()); + } } diff --git a/src/main/java/com/yunhai/userhome/model/MsgHandleRequest.java b/src/main/java/com/yunhai/userhome/model/MsgHandleRequest.java new file mode 100644 index 0000000..85b213f --- /dev/null +++ b/src/main/java/com/yunhai/userhome/model/MsgHandleRequest.java @@ -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; +} diff --git a/src/main/java/com/yunhai/userhome/service/MsgApprovalProxyService.java b/src/main/java/com/yunhai/userhome/service/MsgApprovalProxyService.java new file mode 100644 index 0000000..2abaca8 --- /dev/null +++ b/src/main/java/com/yunhai/userhome/service/MsgApprovalProxyService.java @@ -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 待办匹配 → 办理 → 标已读 全链路。 + * + */ +@Slf4j +@Service +public class MsgApprovalProxyService { + + private static final String TARGET_FLOWCENTER = "flowcenter"; + + /** 我的待办分页拉取上限: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 handle(Integer userId, Integer schId, String operatorName, + Long msgId, String action, String opinion) { + Map 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 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 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 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 matchTodoTasks(Integer schId, Integer userId, String flowBizType, String bizId) { + List matched = new ArrayList<>(); + for (int page = 1; page <= TODO_MAX_PAGES; page++) { + List 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 fetchTodoPage(Integer schId, Integer userId, String flowBizType, int page) { + Map 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 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 query) { + String base = properties.getFlowcenterBaseUrl(); + if (StringUtils.hasText(base)) { + return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class); + } + return openCall.get(TARGET_FLOWCENTER, path, query, JsonNode.class); + } + + private JsonNode postFlowcenter(String path, Map 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_FLOWCENTER, path, query, body, JsonNode.class); + } + + private static Map fail(Map 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; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fd20b42..e6837de 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,9 @@ userhome: # 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底); # 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg usermsg-ws-url: ws://localhost:8080/ws/msg + # flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**)。空 = 走服务目录 flowcenter + # (PlatformService 枚举兜底);不可达时直批降级提示,不阻塞消息卡片 + flowcenter-base-url: http://localhost:8085 # appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案) app-names: 100009: 通知公告 diff --git a/src/test/java/com/yunhai/userhome/service/MsgApprovalProxyServiceTest.java b/src/test/java/com/yunhai/userhome/service/MsgApprovalProxyServiceTest.java new file mode 100644 index 0000000..7f764cd --- /dev/null +++ b/src/test/java/com/yunhai/userhome/service/MsgApprovalProxyServiceTest.java @@ -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)、 + * 红线=身份只从会话参数出站(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 FLOWCENTER_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(FLOWCENTER_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 out = service.handle(201, 3, "张三", 5L, "reject", " "); + assertFalse((Boolean) out.get("ok")); + assertEquals("拒绝必须填写意见", out.get("message")); + verifyNoInteractions(openCall, msgProxy); + } + + @Test + void invalidAction_rejected() { + Map 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 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 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 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 out = service.handle(201, 3, "张三", 5L, "approve", "同意"); + + assertTrue((Boolean) out.get("ok")); + assertEquals("办理成功", out.get("message")); + + // 红线断言:出站身份 = 会话入参(schId=3 / operatorId=201 / operatorName=张三),前端无身份入参 + @SuppressWarnings("unchecked") + ArgumentCaptor> 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> 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> 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 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 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()); + } +}