From ed3c9e6a7250ea27d16e020b71b17d4b47e4bae4 Mon Sep 17 00:00:00 2001 From: chenxing Date: Mon, 19 Oct 2026 23:41:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(p3.1):=20=E5=BA=94=E7=94=A8=E5=86=85?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E5=90=8E=E7=AB=AF=E4=BB=A3=E7=90=86=E2=80=94?= =?UTF-8?q?=E2=80=94SvcApprovalClient=20+=20/api/apply/approval/=207=20?= =?UTF-8?q?=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SvcBizTypes:bizType 终值/存量单一取值点(svc-hall=H-1 实核 appKey=100008 注册行 app_code; svc_item=存量,双值双查先新后旧——方案B,P3.2 落地前的读面兼容口径) - SvcApprovalClient(沿 NoticeApprovalClient 形态,YschOpenCall 服务目录命名目标): todo/byBiz 双值双查合并、approve/reject 写面、embed 记录页 instanceId 形态、taskPageUrl - SvcApprovalController:Todo/ByBiz/RecordUrl/TaskPageUrl/Approve/Reject/My · 定位链=requireOperator→findByIdAndSchId(会话校)→ApplyViewGuard→byBiz 归属预检(P1-01) · 身份三字段只取 CAS 会话,请求参数出现身份字段一律拒(P1-10 择一取严) · 写面参数形态与中心同构(taskId/applyId 查询参数,opinion/edgeName JSON body) · My 端点复用本地 myApplies + FlowPage 信封显式映射(total/list→total/content+页元数据) · 不代理 /oapi/flow/instances(全校视角);本地 flow_status 仍只由回调推进 - 测试 30 例全绿(client 10 + MockMvc standalone 20,显式装 BoaUserArgumentResolver/ GlobalExceptionHandler/ApiResponseAdvice;真实 CasSsoApiRedirectFilter 过滤链未登录=200+code302; 拒绝路径断言审批出站=0);全量回归 157/157 绿(Stage S2/P3.1) --- .../controller/api/SvcApprovalController.java | 239 ++++++++++ .../yunhai/svchall/service/SvcBizTypes.java | 29 ++ .../service/impl/SvcApprovalClient.java | 190 ++++++++ .../api/SvcApprovalControllerTest.java | 429 ++++++++++++++++++ .../service/impl/SvcApprovalClientTest.java | 246 ++++++++++ 5 files changed, 1133 insertions(+) create mode 100644 src/main/java/com/yunhai/svchall/controller/api/SvcApprovalController.java create mode 100644 src/main/java/com/yunhai/svchall/service/SvcBizTypes.java create mode 100644 src/main/java/com/yunhai/svchall/service/impl/SvcApprovalClient.java create mode 100644 src/test/java/com/yunhai/svchall/controller/api/SvcApprovalControllerTest.java create mode 100644 src/test/java/com/yunhai/svchall/service/impl/SvcApprovalClientTest.java diff --git a/src/main/java/com/yunhai/svchall/controller/api/SvcApprovalController.java b/src/main/java/com/yunhai/svchall/controller/api/SvcApprovalController.java new file mode 100644 index 0000000..b038e9d --- /dev/null +++ b/src/main/java/com/yunhai/svchall/controller/api/SvcApprovalController.java @@ -0,0 +1,239 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.service.ApplyViewGuard; +import com.yunhai.svchall.service.impl.ServiceApplyService; +import com.yunhai.svchall.service.impl.SvcApprovalClient; +import lombok.RequiredArgsConstructor; +import org.springframework.util.StringUtils; +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; + +import java.util.List; +import java.util.Map; + +/** + * 应用内审批代理(P3.1,/api/apply/approval/**,CAS 登录):hall 前端被验签墙挡住不能直调 + * flow-center oapi,读面(列表/卡片/换址)与写面(通过/驳回)全部经本控制器中转。 + *

+ * 安全口径(沿 NoticeApprovalClient 范本 + svc-hall 加严,R3.0 P1-01/P1-10 择一取严): + *

+ * 本地 flow_status 一律由既有回调链推进,本控制器不写本地状态。 + */ +@RestController +@RequestMapping("/api/apply/approval/") +@RequiredArgsConstructor +public class SvcApprovalController { + + /** 请求参数里的身份字段黑名单(出现即拒——身份只能来自会话) */ + private static final List IDENTITY_PARAMS = + java.util.Arrays.asList("operatorId", "operatorName", "schId", "assigneeId"); + + private final SvcApprovalClient approvalClient; + private final ServiceApplyRepository applyRepository; + private final ApplyViewGuard applyViewGuard; + private final ServiceApplyService applyService; + + /** 待我审批(/todo 页"待办"页签数据源;双值双查在 client 内合并) */ + @GetMapping("Todo") + public Map todo(BoaUser user, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + return approvalClient.todoTasks(op.schId, op.userId, page, size); + } + + /** + * 当前人在该办件上的待办(详情卡片数据源,0/1/N 均 200 空 tasks)。 + * 先按会话学校定位办件行再过 Guard——跨校行在此即"办件不存在"。 + */ + @GetMapping("ByBiz") + public Map byBiz(@RequestParam("applyId") Integer applyId, BoaUser user, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + ServiceApply apply = requireVisibleApply(applyId, op, user); + return approvalClient.byBiz(op.schId, op.userId, String.valueOf(apply.getId())); + } + + /** + * 审批记录抽屉地址(token 只读页,instanceId 形态):本地 flow_instance_id 有值才可能换址—— + * 未进流程(待提交/发起失败)直接业务错,不打中心。 + */ + @GetMapping("RecordUrl") + public Map recordUrl(@RequestParam("applyId") Integer applyId, BoaUser user, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + ServiceApply apply = requireVisibleApply(applyId, op, user); + if (apply.getFlowInstanceId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "办件尚未进入流程,暂无审批记录"); + } + return approvalClient.embedRecordUrl(op.schId, apply.getFlowInstanceId()); + } + + /** + * 完整办理任务页地址(CAS 会话页,无 token):先打 byBiz 确认 taskId 确实在当前人 + * 该办件的待办里才签发——顺手签发他人 taskId 等于替他人挂办理入口;对端仍按 assigneeId 复核。 + */ + @GetMapping("TaskPageUrl") + public Map taskPageUrl(@RequestParam("applyId") Integer applyId, + @RequestParam("taskId") Long taskId, BoaUser user, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + ServiceApply apply = requireVisibleApply(applyId, op, user); + requireMyPendingTask(apply, taskId, op); + return approvalClient.taskPageUrl(op.schId, taskId); + } + + /** 通过(body: opinion 可选 / edgeName 多出口可选) */ + @PostMapping("Approve") + public void approve(@RequestParam("applyId") Integer applyId, + @RequestParam("taskId") Long taskId, BoaUser user, + @RequestBody(required = false) Map body, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + ServiceApply apply = requireVisibleApply(applyId, op, user); + requireMyPendingTask(apply, taskId, op); + approvalClient.approve(taskId, op.schId, op.userId, op.name, + body == null ? null : body.get("opinion"), + body == null ? null : body.get("edgeName")); + } + + /** 拒绝(opinion 必填,前置校验给前端更快反馈;对端仍会强制) */ + @PostMapping("Reject") + public void reject(@RequestParam("applyId") Integer applyId, + @RequestParam("taskId") Long taskId, BoaUser user, + @RequestBody(required = false) Map body, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + String opinion = body == null ? null : body.get("opinion"); + if (opinion == null || opinion.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "驳回必须填写意见"); + } + ServiceApply apply = requireVisibleApply(applyId, op, user); + requireMyPendingTask(apply, taskId, op); + approvalClient.reject(taskId, op.schId, op.userId, op.name, opinion); + } + + /** + * 我提交的(/todo 页"我提交的"页签数据源):不代理中心——复用本地 + * {@link ServiceApplyService#myApplies}(数据带事项名/快照等业务字段,列表体验完整; + * 中心 instances 是全校视角不可用)。信封显式映射 {@code {total,list}} → 组件 + * {@code FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext}},参数名 + * pageSize→size 由本端消化。 + */ + @GetMapping("My") + public Map my(BoaUser user, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size, + @RequestParam(required = false) Map rawParams) { + Operator op = requireOperator(user); + rejectIdentityParams(rawParams); + int pageIndex = page == null || page < 1 ? 1 : page; + int pageSize = size == null || size < 1 ? 10 : Math.min(size, 100); + Map raw = applyService.myApplies(user, null, pageIndex, pageSize); + long total = raw.get("total") instanceof Number ? ((Number) raw.get("total")).longValue() : 0L; + Object content = raw.get("list"); + long totalPages = (total + pageSize - 1) / pageSize; + Map out = new java.util.LinkedHashMap<>(); + out.put("total", total); + out.put("content", content); + out.put("pageIndex", pageIndex); + out.put("pageSize", pageSize); + out.put("totalPages", totalPages); + out.put("hasNext", pageIndex < totalPages); + return out; + } + + /** 定位链中段:按会话学校取行(跨校=不存在)→ Guard 三分支(提交人/管理员/办理人) */ + private ServiceApply requireVisibleApply(Integer applyId, Operator op, BoaUser user) { + ServiceApply apply = applyRepository.findByIdAndSchId(applyId, op.schId.longValue()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在")); + applyViewGuard.requireVisible(apply, user); + return apply; + } + + /** byBiz 出参里是否含该 taskId(tasks 元素为 {taskId,nodeId,nodeName,arriveTime,outlets}) */ + @SuppressWarnings("unchecked") + private void requireMyPendingTask(ServiceApply apply, Long taskId, Operator op) { + Map data = approvalClient.byBiz(op.schId, op.userId, String.valueOf(apply.getId())); + Object tasks = data == null ? null : data.get("tasks"); + boolean mine = false; + if (tasks instanceof List) { + for (Object row : (List) tasks) { + if (row instanceof Map) { + Object id = ((Map) row).get("taskId"); + if (id instanceof Number && taskId.equals(((Number) id).longValue())) { + mine = true; + break; + } + } + } + } + if (!mine) { + throw new BizException(ErrorCode.FORBIDDEN, "该任务不是你在本办件上的待办"); + } + } + + /** 请求参数出现身份字段一律拒绝(身份只能来自 CAS 会话,不接收任何注入) */ + private static void rejectIdentityParams(Map rawParams) { + if (rawParams == null) { + return; + } + for (String key : rawParams.keySet()) { + if (IDENTITY_PARAMS.contains(key)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "不允许在请求参数中携带身份字段:" + key + "(身份取自登录会话)"); + } + } + } + + /** 登录态提参(沿 NoticeApprovalController 口径):登录 → 学校归属 → 姓名兜底账号 */ + private static Operator requireOperator(BoaUser user) { + if (user == null || user.getId() == null) { + throw new BizException(ErrorCode.FORBIDDEN, "未登录"); + } + if (user.getCurrentRole() == null || user.getCurrentRole().getSchId() == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "当前身份无学校归属"); + } + String name = StringUtils.hasText(user.getName()) ? user.getName() : user.getAccount(); + return new Operator(user.getId(), user.getCurrentRole().getSchId(), name, user); + } + + private static final class Operator { + final Integer userId; + final Integer schId; + final String name; + final BoaUser user; + + Operator(Integer userId, Integer schId, String name, BoaUser user) { + this.userId = userId; + this.schId = schId; + this.name = name; + this.user = user; + } + } +} diff --git a/src/main/java/com/yunhai/svchall/service/SvcBizTypes.java b/src/main/java/com/yunhai/svchall/service/SvcBizTypes.java new file mode 100644 index 0000000..b154bf9 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/SvcBizTypes.java @@ -0,0 +1,29 @@ +package com.yunhai.svchall.service; + +import java.util.Arrays; +import java.util.List; + +/** + * svc-hall 流程业务类型(bizType)终值与存量兼容口径(P3.2 方案B,用户裁决 2026-10-19)。 + *

+ * 终值以 S0-H1 实核为准:UserBase application_info appKey=100008(svc-hall 生产出站身份) + * 注册行 app_code = {@code svc-hall}。存量值 {@code svc_item} 是 2026-10-19 前建件的出站值; + * S0-H2 实测中心 flw_instance 已无 svc_item 行,读路径双查属防御性兼容(防未来存量事件重放/ + * 重提),收敛条件=另一类型不再可能产生事件(覆盖 0 态重试/30/40 重提/积压核清),本轮不收敛单值。 + */ +public final class SvcBizTypes { + + /** 终值(新件出站 + 读路径首查键) */ + public static final String CURRENT = "svc-hall"; + + /** 存量值(2026-10-19 前建件;读路径次查键,回调白名单双值之一) */ + public static final String LEGACY = "svc_item"; + + private SvcBizTypes() { + } + + /** 读路径双查序:先终值后存量,结果按序合并(方案B 双值双查补丁的统一取值点) */ + public static List queryOrder() { + return Arrays.asList(CURRENT, LEGACY); + } +} diff --git a/src/main/java/com/yunhai/svchall/service/impl/SvcApprovalClient.java b/src/main/java/com/yunhai/svchall/service/impl/SvcApprovalClient.java new file mode 100644 index 0000000..f042e69 --- /dev/null +++ b/src/main/java/com/yunhai/svchall/service/impl/SvcApprovalClient.java @@ -0,0 +1,190 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.PlatformService; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.SvcBizTypes; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 应用内审批出站客户端(P3.1,沿 NoticeApprovalClient 形态):YschOpenCall 泛化调用打 + * flow-center {@code /oapi/flow/tasks|task|embed/**},走服务目录命名目标 flow-center + * (yh.call.targets > yh.open.services,与 {@link FlowCenterClient} 同一条解析链)。 + *

+ * 与 Notice 的差异(P3.1 加严): + *

    + *
  • 读面 bizType 不固定单值——{@link SvcBizTypes#queryOrder()} 双值双查(先终值后存量, + * P3.2 方案B,防存量事件/重提漏查);
  • + *
  • embed 记录页走 instanceId 形态(本地 flow_instance_id 镜像有值才可能换址成功, + * bizType+bizId 形态在 bizType 换值后对存量件必然"办件不存在")。
  • + *
+ * 身份口径:operatorId/operatorName/schId 由调用方(controller)从 CAS 会话取,本类不猜身份; + * 对端按任务办理人逐字复核。 + */ +@Slf4j +@Service +public class SvcApprovalClient { + + private static final String TARGET = PlatformService.FLOW_CENTER.key(); + + private final YschOpenCall openCall; + private final FlowClient flowClient; + + public SvcApprovalClient(ObjectProvider openCallProvider, FlowClient flowClient) { + this.openCall = openCallProvider.getIfAvailable(); + this.flowClient = flowClient; + } + + /** + * 我的待办分页:双值双查后合并(同一 assignee 在两个 bizType 下都可能有历史待办的过渡期口径; + * S0-H2 实测存量=0,合并退化为单查结果)。Map 形态即 flow-center BasePageResult JSON。 + */ + public Map todoTasks(Integer schId, Integer assigneeId, Integer page, Integer size) { + requireEnabled(); + List> pages = new ArrayList<>(); + for (String bizType : SvcBizTypes.queryOrder()) { + Map query = new LinkedHashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("assigneeId", String.valueOf(assigneeId)); + query.put("bizType", bizType); + if (page != null) { + query.put("page", String.valueOf(page)); + } + if (size != null) { + query.put("size", String.valueOf(size)); + } + pages.add(openCall.get(TARGET, "/oapi/flow/tasks/todo", query, Map.class)); + } + return mergePages(pages, page, size); + } + + /** + * 当前人在该办件上的待办(卡片数据源,{@code /oapi/flow/tasks/by-biz})。 + *

双值双查:一个 apply 行只可能命中其中一个 bizType 的实例——按序返回首个 + * {@code instanceId != null} 的应答;两者皆空(无实例/非办理人/错学校同形)返回首查(终值) + * 应答,保持对端"空 tasks 不探测"语义。调用方不得据 tasks 反推他人待办。 + */ + public Map byBiz(Integer schId, Integer operatorId, String bizId) { + requireEnabled(); + Map first = null; + for (String bizType : SvcBizTypes.queryOrder()) { + Map query = new LinkedHashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("bizType", bizType); + query.put("bizId", bizId); + query.put("operatorId", String.valueOf(operatorId)); + Map data = openCall.get(TARGET, "/oapi/flow/tasks/by-biz", query, Map.class); + if (data != null && data.get("instanceId") != null) { + return data; + } + if (first == null) { + first = data; + } + } + return first; + } + + /** 同意(opinion 可空;多出口 edgeName 透传) */ + public void approve(Long taskId, Integer schId, Integer operatorId, String operatorName, + String opinion, String edgeName) { + requireEnabled(); + Map body = new LinkedHashMap<>(); + if (opinion != null && !opinion.trim().isEmpty()) { + body.put("opinion", opinion.trim()); + } + if (edgeName != null && !edgeName.trim().isEmpty()) { + body.put("edgeName", edgeName.trim()); + } + openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/approve", + operatorQuery(schId, operatorId, operatorName), body, Void.class); + } + + /** 拒绝(opinion 必填由 controller 前置校验;本方法为薄出站) */ + public void reject(Long taskId, Integer schId, Integer operatorId, String operatorName, String opinion) { + requireEnabled(); + Map body = new LinkedHashMap<>(); + if (opinion != null && !opinion.trim().isEmpty()) { + body.put("opinion", opinion.trim()); + } + openCall.post(TARGET, "/oapi/flow/task/" + taskId + "/reject", + operatorQuery(schId, operatorId, operatorName), body, Void.class); + } + + /** + * 审批记录抽屉地址(token 只读页,{@code /oapi/flow/embed/url?mode=record&instanceId=})。 + * instanceId 形态:本地 flow_instance_id 镜像即中心实例 ID,天然免疫 bizType 换值。 + */ + public Map embedRecordUrl(Integer schId, Long instanceId) { + requireEnabled(); + Map query = new LinkedHashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("mode", "record"); + query.put("instanceId", String.valueOf(instanceId)); + return openCall.get(TARGET, "/oapi/flow/embed/url", query, Map.class); + } + + /** + * 完整办理任务页地址(CAS 会话页,无 token,{@code mode=task&taskId=})。 + * 任务页不定位办件(仅拼 taskId,免签 CAS 页),办理权由该页按 assigneeId 自校; + * 调用方(controller)仍须先做 byBiz 归属预检,不把他人 taskId 散播到前端。 + */ + 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); + } + + /** 双查分页合并:total 求和、content 拼接、页元数据按首查重算(存量=0 时与单查逐字同形) */ + private static Map mergePages(List> pages, Integer page, Integer size) { + Map merged = new LinkedHashMap<>(); + long total = 0L; + List content = new ArrayList<>(); + for (Map p : pages) { + if (p == null) { + continue; + } + if (p.get("total") instanceof Number) { + total += ((Number) p.get("total")).longValue(); + } + if (p.get("content") instanceof List) { + content.addAll((List) p.get("content")); + } + } + merged.put("total", total); + merged.put("content", content); + int pageIndex = page == null ? 1 : page; + int pageSize = size == null ? 10 : size; + long totalPages = pageSize <= 0 ? 0 : (total + pageSize - 1) / pageSize; + merged.put("pageIndex", pageIndex); + merged.put("pageSize", pageSize); + merged.put("totalPages", totalPages); + merged.put("hasNext", pageIndex < totalPages); + return merged; + } + + private static Map operatorQuery(Integer schId, Integer operatorId, String operatorName) { + Map query = new LinkedHashMap<>(); + query.put("schId", String.valueOf(schId)); + query.put("operatorId", String.valueOf(operatorId)); + query.put("operatorName", operatorName); + return query; + } + + private void requireEnabled() { + if (!flowClient.enabled() || openCall == null) { + throw new BizException(ErrorCode.FORBIDDEN, "流程中心未接入,审批功能不可用"); + } + } +} diff --git a/src/test/java/com/yunhai/svchall/controller/api/SvcApprovalControllerTest.java b/src/test/java/com/yunhai/svchall/controller/api/SvcApprovalControllerTest.java new file mode 100644 index 0000000..48bdfbb --- /dev/null +++ b/src/test/java/com/yunhai/svchall/controller/api/SvcApprovalControllerTest.java @@ -0,0 +1,429 @@ +package com.yunhai.svchall.controller.api; + +import com.yunhai.platform.apistd.spring.GlobalExceptionHandler; +import com.yunhai.platform.auth.BoaUserContext; +import com.yunhai.platform.auth.spring.BoaUserArgumentResolver; +import com.yunhai.platform.auth.spring.CasSsoApiRedirectFilter; +import com.yunhai.platform.utils.model.BoaUser; +import com.yunhai.svchall.entity.ServiceApply; +import com.yunhai.svchall.repository.ServiceApplyRepository; +import com.yunhai.svchall.service.ApplyViewGuard; +import com.yunhai.svchall.service.FlowClient; +import com.yunhai.svchall.service.impl.ServiceApplyService; +import com.yunhai.svchall.service.impl.SvcApprovalClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Collections; +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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyMap; +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; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * P3.1 审批代理三层测试之 MVC 契约层:MockMvc standalone 显式装配 + * {@link BoaUserArgumentResolver}(身份只来自 {@link BoaUserContext})与 + * {@link GlobalExceptionHandler}(R3.0 P1-04:范本 NoticeApprovalControllerTest 是方法单测, + * 证明不了解析器/advice 装配)。 + *

+ * 断言口径分开:HTTP 状态恒 200(统一信封);未登录=业务码 3(FORBIDDEN); + * 无学校=业务码 2(PARAM);拒绝路径审批出站必须为 0(verifyNoInteractions)。 + * 另有一组挂真实 {@link CasSsoApiRedirectFilter} 的过滤链用例:API 头未登录=HTTP200+code302 + * (平台 CAS 口径,非 HTTP 403——R3.0 勘误)。 + */ +@DisplayName("SvcApprovalController - MVC 契约(双闸/身份注入/信封映射)") +class SvcApprovalControllerTest { + + private SvcApprovalClient approvalClient; + private ServiceApplyRepository applyRepository; + private ApplyViewGuard applyViewGuard; + private ServiceApplyService applyService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + approvalClient = mock(SvcApprovalClient.class); + applyRepository = mock(ServiceApplyRepository.class); + FlowClient flowClient = mock(FlowClient.class); + applyViewGuard = new ApplyViewGuard(flowClient); + applyService = mock(ServiceApplyService.class); + SvcApprovalController controller = new SvcApprovalController( + approvalClient, applyRepository, applyViewGuard, applyService); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setCustomArgumentResolvers(new BoaUserArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(null), + new com.yunhai.platform.apistd.spring.ApiResponseAdvice()) + .build(); + } + + @AfterEach + void tearDown() { + BoaUserContext.clear(); + } + + /** 深桩用户(沿 ServiceApplyFlowSubmitTest 形态):roleIds 可指定以驱动 Guard 管理员分支 */ + private BoaUser user(int id, int schId, Integer... roleIds) { + BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(user.getId()).thenReturn(id); + when(user.getName()).thenReturn("张三"); + when(user.getAccount()).thenReturn("zhangsan"); + when(user.getCurrentRole().getSchId()).thenReturn(schId); + when(user.getRoleIds()).thenReturn(roleIds == null || roleIds.length == 0 ? java.util.Collections.emptyList() : java.util.Arrays.asList(roleIds)); + when(user.getType()).thenReturn(null); + return user; + } + + private ServiceApply apply(int id, int schId, Integer submitterId, Long instanceId) { + ServiceApply apply = new ServiceApply(); + org.springframework.test.util.ReflectionTestUtils.setField(apply, "id", id); + apply.setSchId((long) schId); + apply.setSubmitterId(submitterId); + apply.setFlowInstanceId(instanceId); + return apply; + } + + private static Map byBizTasks(Long instanceId, Map... tasks) { + Map m = new LinkedHashMap<>(); + m.put("instanceId", instanceId); + m.put("tasks", java.util.Arrays.asList(tasks)); + return m; + } + + private static Map task(int taskId) { + Map t = new LinkedHashMap<>(); + t.put("taskId", taskId); + t.put("nodeName", "部门负责人"); + return t; + } + + private static Map row(int id) { + Map m = new LinkedHashMap<>(); + m.put("id", id); + return m; + } + + private static Map pair(Object value) { + Map m = new LinkedHashMap<>(); + m.put("total", 0L); + m.put("list", value == null ? new java.util.ArrayList<>() : Collections.singletonList(value)); + return m; + } + + /** RecordUrl 换址应答 {pageUrl} */ + private static Map pageUrl(String url) { + Map m = new LinkedHashMap<>(); + m.put("pageUrl", url); + return m; + } + + // ===== 身份层 ===== + + @Test + @DisplayName("未登录(无 BoaUser):HTTP200 + 业务码 3,零出站") + void todoWithoutLogin() throws Exception { + mockMvc.perform(get("/api/apply/approval/Todo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(3)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("会话无学校归属:HTTP200 + 业务码 2,零出站") + void noSchoolIdentity() throws Exception { + BoaUser user = mock(BoaUser.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(user.getId()).thenReturn(10006); + when(user.getCurrentRole()).thenReturn(null); + BoaUserContext.set(user); + mockMvc.perform(get("/api/apply/approval/Todo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("请求参数携带身份字段(operatorId/schId/…):一律拒绝,零出站") + void identityParamsRejected() throws Exception { + BoaUserContext.set(user(10006, 3)); + mockMvc.perform(get("/api/apply/approval/Todo").param("operatorId", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42").param("schId", "3")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + mockMvc.perform(post("/api/apply/approval/Approve") + .param("applyId", "42").param("taskId", "442").param("operatorName", "李四") + .contentType("application/json").content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + verifyNoInteractions(approvalClient, applyRepository); + } + + // ===== Todo / My ===== + + @Test + @DisplayName("Todo:会话身份出站(双值双查在 client),信封透传") + void todoHappyPath() throws Exception { + BoaUserContext.set(user(10006, 3)); + Map data = new LinkedHashMap<>(); + data.put("total", 0); + data.put("content", new java.util.ArrayList()); + when(approvalClient.todoTasks(3, 10006, 2, 10)).thenReturn(data); + mockMvc.perform(get("/api/apply/approval/Todo").param("page", "2").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(0)); + } + + @Test + @DisplayName("My:{total,list} → FlowPage{total,content,pageIndex,pageSize,totalPages,hasNext} 显式映射") + void myEnvelopeMapping() throws Exception { + BoaUserContext.set(user(10006, 3)); + Map local = new LinkedHashMap<>(); + local.put("total", 21L); + local.put("list", java.util.Arrays.asList(row(42), row(43))); + when(applyService.myApplies(any(), any(), eq(3), eq(10))).thenReturn(local); + mockMvc.perform(get("/api/apply/approval/My").param("page", "3").param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.total").value(21)) + .andExpect(jsonPath("$.data.content.length()").value(2)) + .andExpect(jsonPath("$.data.pageIndex").value(3)) + .andExpect(jsonPath("$.data.pageSize").value(10)) + .andExpect(jsonPath("$.data.totalPages").value(3)) + .andExpect(jsonPath("$.data.hasNext").value(false)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("My 非法页码/页大小归一(page<1→1,size<1→10,size>100→100)") + void myNormalizesPaging() throws Exception { + BoaUserContext.set(user(10006, 3)); + when(applyService.myApplies(any(), any(), anyInt(), anyInt())).thenReturn(pair(0L)); + mockMvc.perform(get("/api/apply/approval/My").param("page", "0").param("size", "500")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.pageIndex").value(1)) + .andExpect(jsonPath("$.data.pageSize").value(100)); + } + + // ===== ByBiz 双闸 ===== + + @Test + @DisplayName("ByBiz 跨校/不存在(findByIdAndSchId 空):业务码 2,零审批出站") + void byBizCrossSchool() throws Exception { + BoaUserContext.set(user(10006, 3)); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.empty()); + mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("ByBiz 同校陌生人(Guard 拒):业务码 3,零审批出站") + void byBizStrangerRejected() throws Exception { + BoaUserContext.set(user(10006, 3)); + ServiceApply row = apply(42, 3, 20001, 139L); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + // flowClient.enabled()=false → Guard 办理人分支 fail-closed;非提交人/非管理员 → 拒 + mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(3)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("ByBiz 管理员(Guard 放行=只读可见):放行查询,出站=byBiz") + void byBizAdminReadonly() throws Exception { + BoaUserContext.set(user(10006, 3, 7)); + ServiceApply row = apply(42, 3, 20001, 139L); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); + mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.instanceId").value(139)); + } + + // ===== 写面双闸(Approve/Reject/TaskPageUrl) ===== + + @Test + @DisplayName("Approve:归属预检不过(管理员可见但非办理人)→ 业务码 3,approve 零出站") + void approveGuardFailsForNonAssigneeAdmin() throws Exception { + BoaUserContext.set(user(10006, 3, 7)); + ServiceApply row = apply(42, 3, 20001, 139L); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L)); // tasks 空=非本人待办 + mockMvc.perform(post("/api/apply/approval/Approve") + .param("applyId", "42").param("taskId", "442") + .contentType("application/json").content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(3)); + verify(approvalClient, never()).approve(any(), any(), any(), any(), any(), any()); + } + + @Test + @DisplayName("Approve:预检命中本人待办 → approve 一次(opinion/edgeName 从 body)") + void approveHappyPath() throws Exception { + BoaUserContext.set(user(10006, 3, 7)); + ServiceApply row = apply(42, 3, 20001, 139L); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442))); + mockMvc.perform(post("/api/apply/approval/Approve") + .param("applyId", "42").param("taskId", "442") + .contentType("application/json") + .content("{\"opinion\":\"同意\",\"edgeName\":\"通过\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + verify(approvalClient).approve(442L, 3, 10006, "张三", "同意", "通过"); + } + + @Test + @DisplayName("Reject:空白 opinion 前置拒(业务码 2),零出站") + void rejectBlankOpinion() throws Exception { + BoaUserContext.set(user(10006, 3)); + mockMvc.perform(post("/api/apply/approval/Reject") + .param("applyId", "42").param("taskId", "442") + .contentType("application/json").content("{\"opinion\":\" \"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("Reject:空 body/缺 opinion → 业务码 2") + void rejectMissingBody() throws Exception { + BoaUserContext.set(user(10006, 3)); + mockMvc.perform(post("/api/apply/approval/Reject") + .param("applyId", "42").param("taskId", "442") + .contentType("application/json").content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("Reject:预检+opinion 齐 → reject 一次") + void rejectHappyPath() throws Exception { + BoaUserContext.set(user(10006, 3)); + ServiceApply row = apply(42, 3, 10006, 139L); // 提交人=本人(Guard owner 短路) + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(442))); + mockMvc.perform(post("/api/apply/approval/Reject") + .param("applyId", "42").param("taskId", "442") + .contentType("application/json").content("{\"opinion\":\"材料不全\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + verify(approvalClient).reject(442L, 3, 10006, "张三", "材料不全"); + } + + @Test + @DisplayName("TaskPageUrl:非本人待办 → 业务码 3,taskPageUrl 零出站") + void taskPageUrlNotMyTask() throws Exception { + BoaUserContext.set(user(10006, 3)); + ServiceApply row = apply(42, 3, 10006, 139L); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + when(approvalClient.byBiz(3, 10006, "42")).thenReturn(byBizTasks(139L, task(999))); + mockMvc.perform(get("/api/apply/approval/TaskPageUrl") + .param("applyId", "42").param("taskId", "442")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(3)); + verify(approvalClient, never()).taskPageUrl(any(), any()); + } + + // ===== RecordUrl ===== + + @Test + @DisplayName("RecordUrl:未进流程(instanceId 空)→ 业务码 2,零出站") + void recordUrlNoInstance() throws Exception { + BoaUserContext.set(user(10006, 3)); + ServiceApply row = apply(42, 3, 10006, null); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(2)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("RecordUrl:instanceId 形态换址(pageUrl 透传)") + void recordUrlHappyPath() throws Exception { + BoaUserContext.set(user(10006, 3)); + ServiceApply row = apply(42, 3, 10006, 139L); + when(applyRepository.findByIdAndSchId(42, 3L)).thenReturn(Optional.of(row)); + when(approvalClient.embedRecordUrl(3, 139L)).thenReturn(pageUrl("/embed/instance?token=x")); + mockMvc.perform(get("/api/apply/approval/RecordUrl").param("applyId", "42")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.pageUrl").value("/embed/instance?token=x")); + verify(approvalClient).embedRecordUrl(3, 139L); + } + + // ===== 参数形态(与中心端点同构) ===== + + @Test + @DisplayName("缺参 applyId:非 0 业务码(GlobalExceptionHandler 兜底,code=6),零出站") + void missingParam() throws Exception { + BoaUserContext.set(user(10006, 3)); + mockMvc.perform(get("/api/apply/approval/ByBiz")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(6)); + verifyNoInteractions(approvalClient); + } + + @Test + @DisplayName("非法数字 applyId=abc:非 0 业务码(code=6),零出站") + void illegalNumber() throws Exception { + BoaUserContext.set(user(10006, 3)); + mockMvc.perform(get("/api/apply/approval/ByBiz").param("applyId", "abc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(6)); + verifyNoInteractions(approvalClient); + } + + // ===== 真实 Servlet 过滤链(平台 CAS 口径) ===== + + /** + * 挂真实 {@link CasSsoApiRedirectFilter}(yh-auth starter 同一实现类):API 头(XHR)未登录 + * → HTTP 200 + code 302 + urlToRedirectTo(平台口径,不是 HTTP 403——R3.0 P1-04 勘误)。 + * 无 API 头的请求过滤器直通(浏览器导航场景走 CAS 重定向链,另由整合测试覆盖)。 + */ + @Test + void realFilterChainUnauthenticatedApiGetsCode302() throws Exception { + CasSsoApiRedirectFilter filter = new CasSsoApiRedirectFilter( + "X-Requested-With", "XMLHttpRequest", "http://sso.yunhaixingtong.com/cas", "http://localhost"); + MockMvc withFilter = MockMvcBuilders.standaloneSetup( + new SvcApprovalController(approvalClient, applyRepository, applyViewGuard, applyService)) + .setCustomArgumentResolvers(new BoaUserArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(null), + new com.yunhai.platform.apistd.spring.ApiResponseAdvice()) + .addFilter(filter, "/api/*") + .build(); + withFilter.perform(get("/api/apply/approval/Todo").header("X-Requested-With", "XMLHttpRequest")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(302)) + .andExpect(jsonPath("$.urlToRedirectTo").exists()); + verifyNoInteractions(approvalClient); + } +} diff --git a/src/test/java/com/yunhai/svchall/service/impl/SvcApprovalClientTest.java b/src/test/java/com/yunhai/svchall/service/impl/SvcApprovalClientTest.java new file mode 100644 index 0000000..15d4dd1 --- /dev/null +++ b/src/test/java/com/yunhai/svchall/service/impl/SvcApprovalClientTest.java @@ -0,0 +1,246 @@ +package com.yunhai.svchall.service.impl; + +import com.yunhai.platform.apistd.BizException; +import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.svchall.service.FlowClient; +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.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +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.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * P3.1 应用内审批出站契约测试:端点 URL、参数拼装与双值双查(P3.2 方案B)行为。 + * 读面 bizType 必须按 {@code [svc-hall, svc_item]} 先终值后存量各查一次(todo 合并、byBiz 择命中); + * 写面/换址形态与中心端点同构(taskId 在路径、opinion/edgeName 在 body、embed 记录页 instanceId 形态)。 + */ +@DisplayName("SvcApprovalClient - oapi 契约与双值双查") +class SvcApprovalClientTest { + + private static final String TARGET = "flow-center"; + + private YschOpenCall openCall; + private FlowClient flowClient; + private SvcApprovalClient client; + + /** 固定返回的 ObjectProvider 桩(构造注入用,沿 Notice FlowCenterClientTest 形态) */ + static class FixedProvider implements org.springframework.beans.factory.ObjectProvider { + private final T value; + + FixedProvider(T value) { + this.value = value; + } + + @Override + public T getObject() { + return value; + } + + @Override + public T getObject(Object... args) { + return value; + } + + @Override + public T getIfAvailable() { + return value; + } + + @Override + public T getIfUnique() { + return value; + } + } + + @BeforeEach + void setUp() { + openCall = mock(YschOpenCall.class); + flowClient = mock(FlowClient.class); + when(flowClient.enabled()).thenReturn(true); + client = new SvcApprovalClient(new FixedProvider<>(openCall), flowClient); + } + + + private static Map taskIdRow(int id) { + Map m = new LinkedHashMap<>(); + m.put("taskId", id); + return m; + } + + private static Map page(long total, Object... rows) { + Map m = new LinkedHashMap<>(); + m.put("total", total); + m.put("content", Arrays.asList(rows)); + m.put("pageIndex", 1); + m.put("pageSize", 10); + m.put("totalPages", 1); + m.put("hasNext", false); + return m; + } + + private static Map byBizData(Long instanceId, Object... tasks) { + Map m = new LinkedHashMap<>(); + m.put("instanceId", instanceId); + m.put("bizType", "svc-hall"); + m.put("bizId", "42"); + m.put("status", 10); + m.put("currentNodes", "n1"); + m.put("tasks", Arrays.asList(tasks)); + return m; + } + + @Test + @DisplayName("todo:双值双查(先 svc-hall 后 svc_item)+ 分页合并") + void todoQueriesBothBizTypesInOrderAndMerges() { + Map cur = page(2, taskIdRow(1), taskIdRow(2)); + Map legacy = page(1, taskIdRow(3)); + when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/todo"), anyMap(), eq(Map.class))) + .thenReturn(cur, legacy); + + Map out = client.todoTasks(3, 10006, 1, 10); + + ArgumentCaptor> q1 = ArgumentCaptor.forClass(Map.class); + ArgumentCaptor> q2 = ArgumentCaptor.forClass(Map.class); + verify(openCall, times(2)).get(eq(TARGET), eq("/oapi/flow/tasks/todo"), + q1.capture(), eq(Map.class)); + verify(openCall, times(2)).get(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any()); + assertEquals("svc-hall", q1.getAllValues().get(0).get("bizType")); + assertEquals("svc_item", q1.getAllValues().get(1).get("bizType")); + assertEquals("3", q1.getAllValues().get(0).get("schId")); + assertEquals("10006", q1.getAllValues().get(0).get("assigneeId")); + assertEquals(3L, out.get("total")); + assertEquals(3, ((List) out.get("content")).size()); + assertEquals(1L, out.get("totalPages")); + assertEquals(false, out.get("hasNext")); + } + + @Test + @DisplayName("byBiz:终值命中即返回(不查存量则不消耗第二次出站——命中即短路)") + void byBizReturnsCurrentHit() { + Map cur = byBizData(139L, taskIdRow(442)); + when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class))) + .thenReturn(cur); + Map out = client.byBiz(3, 10006, "42"); + assertEquals(139L, ((Number) out.get("instanceId")).longValue()); + verify(openCall, times(1)).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class)); + } + + @Test + @DisplayName("byBiz:终值 miss(instanceId=null)时续查存量并返回命中行") + void byBizFallsBackToLegacy() { + Map curMiss = byBizData(null); + Map legacyHit = byBizData(9011L); + legacyHit.put("bizType", "svc_item"); + when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class))) + .thenReturn(curMiss, legacyHit); + Map out = client.byBiz(3, 10006, "42"); + assertEquals(9011L, ((Number) out.get("instanceId")).longValue()); + ArgumentCaptor> q = ArgumentCaptor.forClass(Map.class); + verify(openCall, times(2)).get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), q.capture(), eq(Map.class)); + assertEquals("svc-hall", q.getAllValues().get(0).get("bizType")); + assertEquals("svc_item", q.getAllValues().get(1).get("bizType")); + assertEquals("42", q.getAllValues().get(0).get("bizId")); + assertEquals("10006", q.getAllValues().get(0).get("operatorId")); + } + + @Test + @DisplayName("byBiz:双查皆空返回首查应答(保持空 tasks 不探测语义)") + void byBizBothMissReturnsFirst() { + Map curMiss = byBizData(null); + Map legacyMiss = byBizData(null); + when(openCall.get(eq(TARGET), eq("/oapi/flow/tasks/by-biz"), anyMap(), eq(Map.class))) + .thenReturn(curMiss, legacyMiss); + Map out = client.byBiz(3, 10006, "42"); + assertTrue(((List) out.get("tasks")).isEmpty()); + } + + @Test + @DisplayName("approve:taskId 入路径、身份入查询串、opinion/edgeName 入 body 且 trim") + void approveAssembly() { + client.approve(442L, 3, 10006, "张三", " 同意 ", " 退回 "); + @SuppressWarnings("unchecked") + ArgumentCaptor> q = ArgumentCaptor.forClass(Map.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> body = ArgumentCaptor.forClass(Map.class); + verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/442/approve"), + q.capture(), body.capture(), eq(Void.class)); + assertEquals("3", q.getValue().get("schId")); + assertEquals("10006", q.getValue().get("operatorId")); + assertEquals("张三", q.getValue().get("operatorName")); + assertEquals("同意", body.getValue().get("opinion")); + assertEquals("退回", body.getValue().get("edgeName")); + } + + @Test + @DisplayName("approve:空 opinion/edgeName 不入 body") + void approveOmitsBlankFields() { + client.approve(442L, 3, 10006, "张三", " ", null); + @SuppressWarnings("unchecked") + ArgumentCaptor> body = ArgumentCaptor.forClass(Map.class); + verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/442/approve"), + anyMap(), body.capture(), eq(Void.class)); + assertTrue(body.getValue().isEmpty()); + } + + @Test + @DisplayName("reject:opinion 入 body;路径 /task/{id}/reject") + void rejectAssembly() { + client.reject(443L, 3, 10006, "张三", "不同意"); + @SuppressWarnings("unchecked") + ArgumentCaptor> body = ArgumentCaptor.forClass(Map.class); + verify(openCall).post(eq(TARGET), eq("/oapi/flow/task/443/reject"), + anyMap(), body.capture(), eq(Void.class)); + assertEquals("不同意", body.getValue().get("opinion")); + } + + @Test + @DisplayName("embedRecordUrl:mode=record + instanceId 形态(非 bizType+bizId)") + void embedRecordUrlUsesInstanceIdForm() { + client.embedRecordUrl(3, 139L); + @SuppressWarnings("unchecked") + ArgumentCaptor> q = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(eq(TARGET), eq("/oapi/flow/embed/url"), q.capture(), eq(Map.class)); + assertEquals("record", q.getValue().get("mode")); + assertEquals("139", q.getValue().get("instanceId")); + assertEquals("3", q.getValue().get("schId")); + } + + @Test + @DisplayName("taskPageUrl:mode=task + taskId") + void taskPageUrlAssembly() { + client.taskPageUrl(3, 442L); + @SuppressWarnings("unchecked") + ArgumentCaptor> q = ArgumentCaptor.forClass(Map.class); + verify(openCall).get(eq(TARGET), eq("/oapi/flow/embed/url"), q.capture(), eq(Map.class)); + assertEquals("task", q.getValue().get("mode")); + assertEquals("442", q.getValue().get("taskId")); + } + + @Test + @DisplayName("流程中心未接入:一律 FORBIDDEN 且零出站") + void disabledRefusesAll() { + when(flowClient.enabled()).thenReturn(false); + assertThrows(BizException.class, () -> client.todoTasks(3, 10006, 1, 10)); + assertThrows(BizException.class, () -> client.byBiz(3, 10006, "42")); + assertThrows(BizException.class, () -> client.approve(1L, 3, 1, "n", null, null)); + assertThrows(BizException.class, () -> client.embedRecordUrl(3, 1L)); + verify(openCall, never()).get(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any()); + verify(openCall, never()).post(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString(), anyMap(), any(), any()); + } +}