From 7c5391458c45cc9997445a063518988b96b02c7a Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 19 Oct 2026 16:55:41 +0800 Subject: [PATCH] =?UTF-8?q?[v2.1]=20B=20=E5=AE=A1=E6=89=B9=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E8=A1=A5=E9=BD=90=EF=BC=9A=E5=A2=9E=20ByBiz/RecordUrl?= =?UTF-8?q?/TaskPageUrl=20=E7=AB=AF=E7=82=B9=EF=BC=8CApprove=20=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=20edgeName=EF=BC=9B=E8=BA=AB=E4=BB=BD=E4=BB=8D?= =?UTF-8?q?=E5=85=A8=E5=8F=96=20CAS=20=E4=BC=9A=E8=AF=9D=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B8=8D=E5=8F=AF=E4=BC=A0=EF=BC=9B=E8=A1=A5=20Controller/Clie?= =?UTF-8?q?nt=20=E5=8D=95=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/NoticeApprovalController.java | 88 +++++- .../service/flow/NoticeApprovalClient.java | 35 +++ .../api/NoticeApprovalControllerTest.java | 279 ++++++++++++++++++ .../notice/flow/NoticeApprovalClientTest.java | 52 ++++ 4 files changed, 452 insertions(+), 2 deletions(-) create mode 100644 src/test/java/com/yunhai/notice/controller/api/NoticeApprovalControllerTest.java diff --git a/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java b/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java index d74294f..8511da6 100644 --- a/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java +++ b/src/main/java/com/yunhai/notice/controller/api/NoticeApprovalController.java @@ -1,7 +1,11 @@ package com.yunhai.notice.controller.api; import com.yunhai.notice.config.Config; +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.repository.NoticeRepository; import com.yunhai.notice.service.flow.NoticeApprovalClient; +import com.yunhai.notice.service.flow.NoticeFlowEmbedService; +import com.yunhai.notice.service.flow.NoticeFlowGuard; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.model.BoaUser; @@ -15,15 +19,19 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Map; /** - * 应用内审批(第三步 T-C4):"我的待审核"数据面。前端不直调 oapi—— - * operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段, + * 应用内审批(第三步 T-C4):"我的待审核"数据面 + v2.1 业务详情卡片/记录/完整办理换址。 + * 前端不直调 oapi——operatorId/operatorName 一律取 CAS 登录态(BoaUser),请求参数里不收身份字段, * 防伪造办理人;对端 flow-center 再按任务办理人复核(双层)。 * */ @@ -33,6 +41,9 @@ import java.util.Map; public class NoticeApprovalController { private final NoticeApprovalClient approvalClient; + private final NoticeFlowEmbedService embedService; + private final NoticeRepository noticeRepository; + private final NoticeFlowGuard noticeFlowGuard; /** 我的待审核(列表页数据源) */ @GetMapping("Todo") @@ -72,6 +83,79 @@ public class NoticeApprovalController { body == null ? null : body.get("opinion")); } + /** + * 当前人在该通知上的待办(v2.1 卡片数据源)。身份只取会话;{@code serid} 先过与记录页 + * 同一套可见性守卫(未发布通知仅提交人/管理员/办理人/被抄送人可见)。 + *

出参 {@code tasks} 的 0/1/N 由前端据此决定"只读 / 可办理 / 多个待办引导去列表", + * 本端不猜选、不合并。办件不存在与"有办件但不是我的待办"同形(空 tasks),不用于探测。 + */ + @GetMapping("ByBiz") + public Map byBiz(@RequestParam("serid") Integer serid, BoaUser user) { + Operator op = requireOperator(user); + requireVisible(serid, user); + return approvalClient.byBiz(op.schId, op.userId, serid); + } + + /** + * 审批记录抽屉地址(token 只读页):直接复用 {@link NoticeFlowEmbedService#embedUrl}, + * 那里已做通知存在性 + 未发布可见性 + "尚未进入流程"三重校验,且 token 现取现发不缓存。 + * 与 {@code /api/notice/flow/Embed} 同一条实现,避免两个入口对同一 Drawer 漂移出不同守卫口径。 + */ + @GetMapping("RecordUrl") + public Map recordUrl(@RequestParam("serid") Integer serid, BoaUser user) { + requireOperator(user); + return embedService.embedUrl(serid, user); + } + + /** + * 完整办理任务页地址(CAS 会话页,无 token)。 + *

先打 byBiz 确认 {@code taskId} 确实在当前人该通知的待办里才签发——任务页是写面, + * 顺手签发一个别人的 taskId 等于替他人挂一个办理入口。flow-center 侧仍会按 assigneeId + * 逐字复核,本处只是不把无效地址散播到前端。 + */ + @GetMapping("TaskPageUrl") + public Map taskPageUrl(@RequestParam("serid") Integer serid, + @RequestParam("taskId") Long taskId, BoaUser user) { + Operator op = requireOperator(user); + requireVisible(serid, user); + if (!isMyPendingTask(serid, taskId, op)) { + throw new BizException(ErrorCode.FORBIDDEN, "该任务不是你在本通知上的待办"); + } + return approvalClient.taskPageUrl(op.schId, taskId); + } + + /** byBiz 出参里是否含该 taskId(对端 tasks 元素为 {taskId,nodeId,nodeName,arriveTime,outlets}) */ + @SuppressWarnings("unchecked") + private boolean isMyPendingTask(Integer serid, Long taskId, Operator op) { + Map data = approvalClient.byBiz(op.schId, op.userId, serid); + Object tasks = data == null ? null : data.get("tasks"); + if (!(tasks instanceof List)) { + return false; + } + for (Object row : (List) tasks) { + if (row instanceof Map) { + Object id = ((Map) row).get("taskId"); + if (id instanceof Number && taskId.equals(((Number) id).longValue())) { + return true; + } + } + } + return false; + } + + /** + * 通知可见性守卫(口径与 {@link NoticeFlowEmbedService#embedUrl} 逐字一致):存在性 + + * 未发布(status!=1)走 {@link NoticeFlowGuard}。已发布通知照常放行(全校可见,审批数据面 + * 只对办理人出 tasks)。 + */ + private void requireVisible(Integer noticeId, BoaUser user) { + Notice notice = noticeRepository.findById(noticeId) + .orElseThrow(() -> new BizException(ErrorCode.FORBIDDEN, "通知不存在: " + noticeId)); + if (notice.getStatus() == null || notice.getStatus() != 1) { + noticeFlowGuard.requireUnpublishedVisible(notice, user); + } + } + /** 登录态提参:无身份字段可被请求注入;姓名兜底账号(与 NoticeFlowService 提交人口径一致) */ private static Operator requireOperator(BoaUser user) { if (user == null || user.getId() == null) { diff --git a/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java b/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java index e326a0a..9263f86 100644 --- a/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java +++ b/src/main/java/com/yunhai/notice/service/flow/NoticeApprovalClient.java @@ -72,6 +72,41 @@ public class NoticeApprovalClient { return openCall.get(TARGET, "/oapi/flow/instances", query, Map.class); } + /** + * 当前人在本通知上的待办(v2.1 业务详情卡片数据源,{@code /oapi/flow/tasks/by-biz})。 + *

返回 Map 即对端 {@code ByBizTasksVo} JSON:{@code tasks[]} 0/1/N 三种计数都走 HTTP 200, + * 调用方不得据 tasks 反推"这个通知上有没有别人的待办"——办件不存在、错学校、错 bizType、 + * 非办理人四种情形出参完全同形(都是空 tasks),这是对端的防探测设计,本端不要试图区分。 + *

{@code currentNodes} 是节点 id 逗号串(办件原文口径),人读名称取 {@code tasks[].nodeName}。 + */ + @SuppressWarnings("unchecked") + public Map byBiz(Integer schId, Integer operatorId, Integer noticeId) { + requireEnabled(); + Map query = new LinkedHashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("bizType", FlowCenterClient.BIZ_TYPE); + query.put("bizId", String.valueOf(noticeId)); + query.put("operatorId", String.valueOf(operatorId)); + return openCall.get(TARGET, "/oapi/flow/tasks/by-biz", query, Map.class); + } + + /** + * 完整办理任务页地址({@code /oapi/flow/embed/url?mode=task}):返回 CAS 会话页 + * {@code {front}/#/task/{taskId}},无 token(写面,办理权由该页加载时的用户面接口按 + * assigneeId 复核)。仍走 {@link YschOpenCall} 泛化调用——nexus 已发布快照的 + * {@code YschFlowOpenApi} 只有 embedUrl/todoTasks/instances,没有 taskPageUrl 门面方法, + * 而本批纪律是不动 BasePlatform。SDK 补齐后可平替(URL 不变,测试锁死)。 + */ + @SuppressWarnings("unchecked") + public Map taskPageUrl(Integer schId, Long taskId) { + requireEnabled(); + Map query = new LinkedHashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("mode", "task"); + query.put("taskId", String.valueOf(taskId)); + return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class); + } + /** 同意(opinion 可空;多出口 edgeName 透传) */ public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion, String edgeName) { diff --git a/src/test/java/com/yunhai/notice/controller/api/NoticeApprovalControllerTest.java b/src/test/java/com/yunhai/notice/controller/api/NoticeApprovalControllerTest.java new file mode 100644 index 0000000..3ed3994 --- /dev/null +++ b/src/test/java/com/yunhai/notice/controller/api/NoticeApprovalControllerTest.java @@ -0,0 +1,279 @@ +package com.yunhai.notice.controller.api; + +import com.yunhai.notice.entity.Notice; +import com.yunhai.notice.repository.NoticeRepository; +import com.yunhai.notice.service.flow.NoticeApprovalClient; +import com.yunhai.notice.service.flow.NoticeFlowEmbedService; +import com.yunhai.notice.service.flow.NoticeFlowGuard; +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.model.BoaRoleInfo; +import com.yunhai.platform.utils.model.BoaUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +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.when; + +/** + * v2.1 应用内审批代理端点单测(ByBiz / RecordUrl / TaskPageUrl)。 + *

本类的存在意义全在"身份与地址"这两条纪律上: + *

    + *
  • 身份只能来自 CAS 会话(BoaUser),请求参数里没有 operatorId/schId 的位置—— + * 断言的是"传给出站客户端的正是会话里的值",而不是"参数被透传";
  • + *
  • 任务页是写面:taskId 不在当前人待办里就不签发地址(宁可前端拿不到按钮, + * 也不发一个能点到别人办理页的地址)。
  • + *
+ */ +class NoticeApprovalControllerTest { + + private static final int SCH = 27; + private static final int USER_ID = 10006; + + private NoticeApprovalClient approvalClient; + private NoticeFlowEmbedService embedService; + private NoticeRepository noticeRepository; + private NoticeFlowGuard noticeFlowGuard; + private NoticeApprovalController controller; + + @BeforeEach + void setUp() { + approvalClient = mock(NoticeApprovalClient.class); + embedService = mock(NoticeFlowEmbedService.class); + noticeRepository = mock(NoticeRepository.class); + noticeFlowGuard = mock(NoticeFlowGuard.class); + controller = new NoticeApprovalController(approvalClient, embedService, noticeRepository, noticeFlowGuard); + } + + private BoaUser user() { + BoaUser user = new BoaUser(); + user.setId(USER_ID); + user.setAccount("liuwenjuan"); + user.setName("刘文娟"); + user.setType(0); + BoaRoleInfo role = new BoaRoleInfo(); + role.setSchId(SCH); + user.setCurrentRole(role); + return user; + } + + private void stubNotice(Integer serid, Integer status) { + Notice notice = new Notice(); + notice.setId(serid); + notice.setSchId(SCH); + notice.setStatus(status); + when(noticeRepository.findById(serid)).thenReturn(Optional.of(notice)); + } + + /** byBiz 出参里的 tasks(模拟对端 JSON 反序列化后的 Map 形态) */ + private Map tasksOf(Long... taskIds) { + List rows = new ArrayList<>(); + for (Long id : taskIds) { + Map row = new LinkedHashMap<>(); + row.put("taskId", id); + row.put("nodeId", "n1"); + row.put("nodeName", "后勤资产处"); + row.put("outlets", Collections.emptyList()); + rows.add(row); + } + Map data = new HashMap<>(); + data.put("instanceId", 123L); + data.put("tasks", rows); + return data; + } + + // ===== ByBiz ===== + + /** 身份取自会话:出站用的是 BoaUser 的 schId/userId,前端参数里只有 serid */ + @Test + void byBiz_identityFromSessionNotRequest() { + stubNotice(71, 1); + when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf(442L)); + Map out = controller.byBiz(71, user()); + verify(approvalClient).byBiz(eq(SCH), eq(USER_ID), eq(71)); + assertEquals(1, ((List) out.get("tasks")).size(), "1 条待办原样透出给卡片"); + } + + /** 已发布通知(status=1)不走未发布守卫;未发布(status=0)必须走 NoticeFlowGuard */ + @Test + void byBiz_unpublishedNotice_goesThroughVisibilityGuard() { + stubNotice(70, 0); + BoaUser user = user(); + controller.byBiz(70, user); + verify(noticeFlowGuard).requireUnpublishedVisible(any(Notice.class), eq(user)); + } + + @Test + void byBiz_publishedNotice_skipsUnpublishedGuard() { + stubNotice(71, 1); + controller.byBiz(71, user()); + verify(noticeFlowGuard, never()).requireUnpublishedVisible(any(Notice.class), any(BoaUser.class)); + } + + /** 通知不存在 → 拒绝,且不出站(不给流程中心发一个查不到对象的 byBiz) */ + @Test + void byBiz_noticeMissing_rejectedAndNoOutbound() { + when(noticeRepository.findById(999)).thenReturn(Optional.empty()); + BizException e = assertThrows(BizException.class, () -> controller.byBiz(999, user())); + assertTrue(e.getMessage().contains("通知不存在"), "文案须是查不到语义:" + e.getMessage()); + verify(approvalClient, never()).byBiz(anyInt(), anyInt(), anyInt()); + } + + /** 不可见(守卫抛 FORBIDDEN)→ 原样拒绝 */ + @Test + void byBiz_notVisible_guardThrows() { + stubNotice(70, 0); + org.mockito.Mockito.doThrow(new BizException( + com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "通知不存在或未发布")) + .when(noticeFlowGuard).requireUnpublishedVisible(any(Notice.class), any(BoaUser.class)); + assertThrows(BizException.class, () -> controller.byBiz(70, user())); + verify(approvalClient, never()).byBiz(anyInt(), anyInt(), anyInt()); + } + + /** 未登录(BoaUser 空)→ 拒绝,身份字段无从伪造 */ + @Test + void byBiz_noSession_rejected() { + stubNotice(71, 1); + assertThrows(BizException.class, () -> controller.byBiz(71, null)); + BoaUser noSch = user(); + noSch.setCurrentRole(null); + assertThrows(BizException.class, () -> controller.byBiz(71, noSch)); + } + + /** 0 条:对端 tasks=[] 时不得报错,卡片据此渲染只读 */ + @Test + void byBiz_zeroTasks_passesThroughEmpty() { + stubNotice(72, 1); + Map empty = tasksOf(); + when(approvalClient.byBiz(SCH, USER_ID, 72)).thenReturn(empty); + assertEquals(0, ((List) controller.byBiz(72, user()).get("tasks")).size()); + } + + // ===== TaskPageUrl ===== + + /** taskId 在当前人待办里 → 签发任务页地址 */ + @Test + void taskPageUrl_taskIsMine_issued() { + stubNotice(71, 1); + when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf(442L, 443L)); + Map page = new HashMap<>(); + page.put("pageUrl", "http://localhost:8088/#/task/443"); + when(approvalClient.taskPageUrl(SCH, 443L)).thenReturn(page); + assertEquals("http://localhost:8088/#/task/443", + controller.taskPageUrl(71, 443L, user()).get("pageUrl")); + } + + /** 核心越权用例:taskId 不属于当前人待办 → 拒绝签发,且不调用换址端点 */ + @Test + void taskPageUrl_taskNotMine_notIssued() { + stubNotice(71, 1); + when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf(442L)); + BizException e = assertThrows(BizException.class, () -> controller.taskPageUrl(71, 999L, user())); + assertTrue(e.getMessage().contains("不是你在本通知上的待办"), "文案须说明拒因:" + e.getMessage()); + verify(approvalClient, never()).taskPageUrl(anyInt(), anyLong()); + } + + /** 本人无待办(tasks=[])→ 同样拒绝,不为"空 tasks"开一条签发旁路 */ + @Test + void taskPageUrl_noTasks_rejected() { + stubNotice(71, 1); + when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(tasksOf()); + assertThrows(BizException.class, () -> controller.taskPageUrl(71, 442L, user())); + verify(approvalClient, never()).taskPageUrl(anyInt(), anyLong()); + } + + /** 脏出参(tasks 缺位/非列表)按"不在待办"处理,不因解析失败而放行 */ + @Test + void taskPageUrl_malformedPayload_failClosed() { + stubNotice(71, 1); + Map garbage = new HashMap<>(); + garbage.put("tasks", "not-a-list"); + when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(garbage); + assertThrows(BizException.class, () -> controller.taskPageUrl(71, 442L, user())); + when(approvalClient.byBiz(SCH, USER_ID, 71)).thenReturn(null); + assertThrows(BizException.class, () -> controller.taskPageUrl(71, 442L, user())); + } + + /** 不可见通知不得换任务页地址(与 ByBiz 同一道守卫) */ + @Test + void taskPageUrl_unvisible_rejected() { + stubNotice(70, 0); + org.mockito.Mockito.doThrow(new BizException( + com.yunhai.platform.utils.exception.ErrorCode.FORBIDDEN, "通知不存在或未发布")) + .when(noticeFlowGuard).requireUnpublishedVisible(any(Notice.class), any(BoaUser.class)); + assertThrows(BizException.class, () -> controller.taskPageUrl(70, 442L, user())); + verify(approvalClient, never()).taskPageUrl(anyInt(), anyLong()); + } + + // ===== RecordUrl ===== + + /** 记录页复用 NoticeFlowEmbedService(三重校验与 token 现取现发都在那里),本端只做转发 */ + @Test + void recordUrl_delegatesToEmbedService() { + BoaUser user = user(); + when(embedService.embedUrl(71, user)) + .thenReturn(Collections.singletonMap("pageUrl", "http://localhost:8088/#/embed/instance?token=x")); + assertEquals("http://localhost:8088/#/embed/instance?token=x", + controller.recordUrl(71, user).get("pageUrl")); + verify(embedService).embedUrl(71, user); + } + + @Test + void recordUrl_notLoggedIn_rejected() { + assertThrows(BizException.class, () -> controller.recordUrl(71, null)); + verify(embedService, never()).embedUrl(anyInt(), any(BoaUser.class)); + } + + // ===== Approve / Reject(回归位:确认改造后 edgeName 不丢、驳回意见仍被拦) ===== + + @Test + void approve_edgeNameStillPropagated() { + Map body = new LinkedHashMap<>(); + body.put("opinion", "同意"); + body.put("edgeName", "转乙科"); + controller.approve(442L, user(), body); + verify(approvalClient).approve(442L, SCH, USER_ID, "刘文娟", "同意", "转乙科"); + } + + /** 单出口快速通过:body 为空/不带 edgeName 时,出站就是 null,不擅自补一个出口名 */ + @Test + void approve_withoutEdge_sendsNullEdgeName() { + controller.approve(442L, user(), Collections.emptyMap()); + verify(approvalClient).approve(442L, SCH, USER_ID, "刘文娟", null, null); + } + + /** 无会话即拒绝,且不出站——operator 永远不来自请求 */ + @Test + void approve_noSession_rejected() { + assertThrows(BizException.class, () -> controller.approve(442L, null, new HashMap<>())); + verify(approvalClient, never()).approve(anyLong(), anyInt(), anyInt(), any(), any(), any()); + } + + /** 驳回意见必填由出站客户端前置强制;控制器原样抛出,不吞成"成功" */ + @Test + void reject_blankOpinion_propagates() { + org.mockito.Mockito.doThrow(new BizException( + com.yunhai.platform.utils.exception.ErrorCode.PARAM_ERROR, "驳回必须填写意见")) + .when(approvalClient).reject(eq(442L), eq(SCH), eq(USER_ID), eq("刘文娟"), any()); + BizException e = assertThrows(BizException.class, + () -> controller.reject(442L, user(), Collections.singletonMap("opinion", " "))); + assertTrue(e.getMessage().contains("意见"), "须把拦截原因交给前端:" + e.getMessage()); + } +} diff --git a/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java b/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java index feac915..2d3b54b 100644 --- a/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java +++ b/src/test/java/com/yunhai/notice/flow/NoticeApprovalClientTest.java @@ -127,6 +127,58 @@ class NoticeApprovalClientTest { properties.setEnabled(false); assertThrows(BizException.class, () -> client.todoTasks(27, 10006, null, null)); assertThrows(BizException.class, () -> client.approve(1L, 27, 1, "甲", null, null)); + assertThrows(BizException.class, () -> client.byBiz(27, 10006, 71)); + assertThrows(BizException.class, () -> client.taskPageUrl(27, 1L)); + } + + // ===== v2.1 应用内审批:by-biz 卡片数据源 + 完整办理任务页换址 ===== + + @Test + @SuppressWarnings("unchecked") + void byBiz_urlAndParams_charExact() { + Map data = new HashMap<>(); + when(call.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class))).thenReturn(data); + client.byBiz(27, 10006, 71); + Map expected = new LinkedHashMap<>(); + expected.put("schId", "27"); + expected.put("bizType", "notice"); + expected.put("bizId", "71"); + expected.put("operatorId", "10006"); + verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), eq(expected), eq(Map.class)); + } + + /** by-biz 四个参数一律必传(缺一即可能被对端当"可选过滤"放宽成越权查询) */ + @Test + void byBiz_allFourParamsAlwaysSent() { + when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))).thenReturn(new HashMap<>()); + client.byBiz(27, 10006, 71); + verify(call).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), + Mockito.argThat(q -> q.size() == 4 && q.containsKey("schId") && q.containsKey("bizType") + && q.containsKey("bizId") && q.containsKey("operatorId")), eq(Map.class)); + } + + /** 任务页换址走 mode=task(CAS 页、无 token);本批不动 BasePlatform,故仍是泛化调用 */ + @Test + @SuppressWarnings("unchecked") + void taskPageUrl_modeTaskContractShape() { + Map page = new HashMap<>(); + page.put("pageUrl", "http://localhost:8088/#/task/442"); + when(call.get(eq(TARGET), eq("/oapi/flow/embed/url"), anyMap(), eq(Map.class))).thenReturn(page); + Map out = client.taskPageUrl(27, 442L); + assertEquals("http://localhost:8088/#/task/442", out.get("pageUrl")); + Map expected = new LinkedHashMap<>(); + expected.put("schId", "27"); + expected.put("mode", "task"); + expected.put("taskId", "442"); + verify(call).get(eq(TARGET), eq("/oapi/flow/embed/url"), eq(expected), eq(Map.class)); + } + + /** 流程中心不可达:原样抛出,不吞异常、不伪装成"无待办"(卡片据此提示稍后重试而非渲染只读空态) */ + @Test + void byBiz_unreachable_propagatesNotSwallowed() { + when(call.get(any(String.class), any(String.class), anyMap(), eq(Map.class))) + .thenThrow(new RuntimeException("connect timeout")); + assertThrows(RuntimeException.class, () -> client.byBiz(27, 10006, 71)); } @Test