diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java index 17eef7c..cd7fd20 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -2,6 +2,7 @@ package com.yunhai.flowcenter.controller.oapi; import com.yunhai.flowcenter.model.OapiModels; import com.yunhai.flowcenter.service.OapiFlowService; +import com.yunhai.platform.utils.model.BasePageResult; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -25,8 +26,12 @@ import java.util.List; * - GET /oapi/flow/progress?instanceId=|bizType=&bizId=&schId= → data={status,steps:[…]} * - GET /oapi/flow/diagram/url?instanceId=|flowGuid=&mode=&schId= → data={imgUrl,expiresIn} * - POST /oapi/flow/callback/reg?schId= → data={secret} - * 刻意不提供审批动作(同意/拒绝/退回/移交/催办):那是"人"的决策,必须带登录身份走 CAS 用户面, - * 应用间签名不得代签——谁办理谁负责,审计链不能断在应用上。 + * 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方 + * 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断): + * - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办 + * - GET /oapi/flow/instances?schId=&bizType=&status=&page=&size= → data=分页办件 + * - POST /oapi/flow/task/{taskId}/approve?schId=&operatorId=&operatorName=(body: opinion/edgeName) + * - POST /oapi/flow/task/{taskId}/reject?schId=&operatorId=&operatorName=(body: opinion 必填) */ @RestController @RequestMapping("/oapi/flow") @@ -102,4 +107,51 @@ public class OapiFlowController { @RequestBody OapiModels.CallbackRegBody body) { return flowService.registerCallback(schId, appKey, body); } + + // ===== 应用内审批(第三步):查询 + 办理 ===== + + /** 我的待办(oapi):assigneeId 维度分页,bizType 可选过滤 */ + @GetMapping("/tasks/todo") + public BasePageResult todoTasks( + @RequestParam("schId") Long schId, + @RequestParam("assigneeId") Integer assigneeId, + @RequestParam(value = "bizType", required = false) String bizType, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size) { + return flowService.pageTodoTasks(schId, assigneeId, bizType, page, size); + } + + /** 办件分页(oapi):bizType/status 可选过滤 */ + @GetMapping("/instances") + public BasePageResult instances( + @RequestParam("schId") Long schId, + @RequestParam(value = "bizType", required = false) String bizType, + @RequestParam(value = "status", required = false) Integer status, + @RequestParam(value = "page", required = false) Integer page, + @RequestParam(value = "size", required = false) Integer size) { + return flowService.pageInstances(schId, bizType, status, page, size); + } + + /** 同意(oapi):operatorId 按任务办理人逐字复核,非办理人→业务码拒绝 */ + @PostMapping("/task/{taskId}/approve") + public void approve(@RequestParam("schId") Long schId, + @RequestParam("operatorId") Integer operatorId, + @RequestParam("operatorName") String operatorName, + @PathVariable("taskId") Long taskId, + @RequestBody(required = false) OapiModels.HandleBody body) { + flowService.approveTask(schId, taskId, operatorId, operatorName, + body == null ? null : body.getOpinion(), + body == null ? null : body.getEdgeName()); + } + + /** 拒绝(oapi):opinion 必填,非办理人→业务码拒绝 */ + @PostMapping("/task/{taskId}/reject") + public void reject(@RequestParam("schId") Long schId, + @RequestParam("operatorId") Integer operatorId, + @RequestParam("operatorName") String operatorName, + @PathVariable("taskId") Long taskId, + @RequestBody(required = false) OapiModels.HandleBody body) { + flowService.rejectTask(schId, taskId, operatorId, operatorName, + body == null ? null : body.getOpinion()); + } } diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index a672b98..082f8b2 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -145,4 +145,47 @@ public final class OapiModels { /** 相对 token 有效期(秒) */ private long expiresIn; } + + /** 待办任务行(tasks/todo 元素):审批列表页数据源,带办件定位与快照 */ + @Data + public static class TodoTaskVo { + private Long taskId; + private Long instanceId; + /** 办件当前版本(办理前对账用) */ + private Integer instanceVersion; + private String bizType; + private String bizId; + private String bizTitle; + /** 办件快照原文(JSON 字符串,发起时未传则 null) */ + private String bizSnapshot; + private String nodeId; + private String nodeName; + private Integer assigneeId; + private String assigneeName; + private String arriveTime; + } + + /** 办件行(instances 元素):应用侧列表/对账数据源 */ + @Data + public static class InstanceItemVo { + private Long instanceId; + private Integer status; + private Integer version; + private String bizType; + private String bizId; + private String bizTitle; + private String bizUrl; + private String submitterName; + private String submitTime; + private String finishTime; + private String currentNodes; + } + + /** 办理入参(approve/reject 请求体;opinion 可空,拒绝侧服务层强制必填) */ + @Data + public static class HandleBody { + private String opinion; + /** 多出口节点办理时所选出口名(单出口可省) */ + private String edgeName; + } } diff --git a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java index 67bfe98..cb709ad 100644 --- a/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java +++ b/src/main/java/com/yunhai/flowcenter/notify/UserMsgNotifier.java @@ -15,7 +15,8 @@ import java.util.Collections; /** * 站内信/待办角标通知(概览 §七 契约,回调通知"业务系统"、本类通知"人",两通道互不替代): - * - 任务到达:msgType=2、bizType=flow.task、msgNo=task:{taskId}、jumpPath=/task/{taskId}、todo +1 + * - 任务到达:msgType=2、bizType=flow.task、msgNo=task:{taskId}、jumpPath=bizUrl 优先(空回落 + * /task/{taskId})、todo +1 * - 任务被办理:todo -1(办理人) * - 完成/拒绝/撤回:msgType=1、bizType=flow.result、content 必填(结果+意见)、msgNo 带 version * (重提重启多轮终态互不幂等吞掉) @@ -68,10 +69,14 @@ public class UserMsgNotifier { String title = "您有新的待办:" + safe(instance.getBizTitle()); String content = "「" + safe(instance.getBizTitle()) + "」等待您审批" + (task.getNodeName() == null ? "" : "(" + task.getNodeName() + ")"); + // 跳转路径:办件自带 bizUrl(应用详情页)优先——应用内审批免跳流程中心; + // 未传 bizUrl 回落流程中心办理页 /task/{taskId} + String jumpPath = instance.getBizUrl() == null || instance.getBizUrl().trim().isEmpty() + ? "/task/" + task.getId() : instance.getBizUrl(); userMsgClient.sendUserMsg(instance.getSchId().intValue(), Collections.singletonList(task.getAssigneeId()), 2, "flow.task", String.valueOf(instance.getId()), title, content, - "/task/" + task.getId(), "task:" + task.getId(), 30); + jumpPath, "task:" + task.getId(), 30); userMsgClient.todoInc(instance.getSchId().intValue(), Collections.singletonList(task.getAssigneeId()), 1); } diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java index c3a1a51..6afeed0 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwInstanceRepository.java @@ -29,4 +29,11 @@ public interface FlwInstanceRepository extends JpaRepository + "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc") Page pageAdmin(@Param("schId") Long schId, @Param("status") Integer status, @Param("keyword") String keyword, Pageable pageable); + + /** 按业务类型/状态分页(oapi instances 查询;bizType/status 均可空=不过滤) */ + @Query("select i from FlwInstance i where i.schId = :schId " + + "and (:bizType is null or i.bizType = :bizType) " + + "and (:status is null or i.status = :status) order by i.id desc") + Page pageByBiz(@Param("schId") Long schId, @Param("bizType") String bizType, + @Param("status") Integer status, Pageable pageable); } diff --git a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java index 29cb2d4..e55c747 100644 --- a/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java +++ b/src/main/java/com/yunhai/flowcenter/repository/FlwTaskRepository.java @@ -57,6 +57,17 @@ public interface FlwTaskRepository extends JpaRepository { + "order by t.arriveTime asc, t.id asc") Page pageTodo(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, Pageable pageable); + /** + * 我的待办(oapi 版,bizType 可选过滤):join 办件表过滤业务类型,排序口径与 {@link #pageTodo} + * 一致(到达时间→id)。bizType 传 null 即全量(与 pageTodo 等价)。 + */ + @Query("select t from FlwTask t join FlwInstance i on i.id = t.instanceId " + + "where t.schId = :schId and t.assigneeId = :assigneeId and t.status = 0 " + + "and (:bizType is null or i.bizType = :bizType) " + + "order by t.arriveTime asc, t.id asc") + Page pageTodoBiz(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId, + @Param("bizType") String bizType, Pageable pageable); + /** * 我的已办(一个办件一行,2026-09-18):同一办件被本人办理多次(多节点/退回重办/移交后 * 再办)时只保留本人最后办理的那一条(该办件下本人已办行的最大 id),避免列表里同一办件 diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index 9ac4bd0..9880e88 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -15,11 +15,14 @@ import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwTaskRepository; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; +import com.yunhai.platform.utils.model.BasePageResult; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.openapi.base.OpenApiUserClient; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -61,6 +64,7 @@ public class OapiFlowService { private final FlwTaskRepository taskRepository; private final FlwCallbackRegRepository regRepository; private final FlowInstanceService instanceService; + private final FlowTaskService taskService; private final FlowEngine flowEngine; private final ObjectMapper objectMapper; private final OpenApiUserClient userClient; @@ -119,6 +123,133 @@ public class OapiFlowService { return instance == null ? null : toInstanceVo(instance); } + // ===== 应用内审批(2026-09 第三步):查询 + 办理 ===== + + /** + * 我的待办(oapi,应用内审批列表页数据源):assigneeId 维度分页,bizType 可选过滤。 + * 办理人判定以 taskId 为准——每条待办任务行恰好一个 assignee,不再做集合判断。 + */ + public BasePageResult pageTodoTasks(Long schId, Integer assigneeId, + String bizType, Integer page, Integer size) { + if (schId == null || assigneeId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/assigneeId"); + } + Page p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType), + PageRequest.of(normPage(page), normSize(size))); + List instanceIds = p.getContent().stream() + .map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList()); + Map instances = instanceIds.isEmpty() + ? java.util.Collections.emptyMap() + : instanceRepository.findAllById(instanceIds).stream() + .collect(java.util.stream.Collectors.toMap(FlwInstance::getId, i -> i)); + List items = p.getContent().stream() + .map(t -> toTodoTaskVo(t, instances.get(t.getInstanceId()))) + .collect(java.util.stream.Collectors.toList()); + return toPageResult(p.getTotalElements(), items, page, size); + } + + /** 办件分页(oapi,应用侧列表/对账):bizType/status 可选过滤 */ + public BasePageResult pageInstances(Long schId, String bizType, Integer status, + Integer page, Integer size) { + if (schId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId"); + } + Page p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status, + PageRequest.of(normPage(page), normSize(size))); + List items = p.getContent().stream() + .map(this::toInstanceItemVo).collect(java.util.stream.Collectors.toList()); + return toPageResult(p.getTotalElements(), items, page, size); + } + + /** + * 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人 + * 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。 + * operatorName 供应用侧审计透传,事件流仍以任务行 assigneeName 为准。多出口节点必带 + * edgeName({@code FlowTaskService.handle} 内校验)。审批后的回调/待办减一等事件链自动复用。 + */ + @Transactional + public FlwTask approveTask(Long schId, Long taskId, Integer operatorId, String operatorName, + String opinion, String edgeName) { + requireOperator(schId, taskId, operatorId, operatorName); + return taskService.handle(schId, taskId, true, opinion, operatorId, edgeName); + } + + /** 拒绝(oapi):opinion 必填由 {@code FlowTaskService.handle} 强制;其余口径同 {@link #approveTask} */ + @Transactional + public FlwTask rejectTask(Long schId, Long taskId, Integer operatorId, String operatorName, + String opinion) { + requireOperator(schId, taskId, operatorId, operatorName); + return taskService.handle(schId, taskId, false, opinion, operatorId); + } + + private void requireOperator(Long schId, Long taskId, Integer operatorId, String operatorName) { + if (schId == null || taskId == null || operatorId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/taskId/operatorId"); + } + if (operatorName == null || operatorName.trim().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:operatorName(操作人姓名,审计用)"); + } + } + + private OapiModels.TodoTaskVo toTodoTaskVo(FlwTask t, FlwInstance instance) { + OapiModels.TodoTaskVo vo = new OapiModels.TodoTaskVo(); + vo.setTaskId(t.getId()); + vo.setInstanceId(t.getInstanceId()); + vo.setInstanceVersion(instance == null ? null : instance.getVersion()); + vo.setBizType(instance == null ? null : instance.getBizType()); + vo.setBizId(instance == null ? null : instance.getBizId()); + vo.setBizTitle(instance == null ? null : instance.getBizTitle()); + vo.setBizSnapshot(instance == null ? null : instance.getBizSnapshot()); + vo.setNodeId(t.getNodeId()); + vo.setNodeName(t.getNodeName()); + vo.setAssigneeId(t.getAssigneeId()); + vo.setAssigneeName(t.getAssigneeName()); + vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString()); + return vo; + } + + private OapiModels.InstanceItemVo toInstanceItemVo(FlwInstance instance) { + OapiModels.InstanceItemVo vo = new OapiModels.InstanceItemVo(); + vo.setInstanceId(instance.getId()); + vo.setStatus(instance.getStatus()); + vo.setVersion(instance.getVersion()); + vo.setBizType(instance.getBizType()); + vo.setBizId(instance.getBizId()); + vo.setBizTitle(instance.getBizTitle()); + vo.setBizUrl(instance.getBizUrl()); + vo.setSubmitterName(instance.getSubmitterName()); + vo.setSubmitTime(instance.getSubmitTime() == null ? null : instance.getSubmitTime().toString()); + vo.setFinishTime(instance.getFinishTime() == null ? null : instance.getFinishTime().toString()); + vo.setCurrentNodes(instance.getCurrentNodes()); + return vo; + } + + /** 与 FlowQueryService 同口径(page<1→0;size 空/越界→10) */ + private static int normPage(Integer page) { + return page == null || page < 1 ? 0 : page - 1; + } + + private static int normSize(Integer size) { + return size == null || size < 1 || size > 100 ? 10 : size; + } + + private static BasePageResult toPageResult(long total, List content, Integer page, Integer size) { + BasePageResult result = new BasePageResult<>(); + result.setTotal((int) total); + result.setContent(content); + int pageIndex = page == null || page < 1 ? 1 : page; + int pageSize = size == null || size < 1 ? content.size() : size; + result.setPageIndex(pageIndex); + result.setPageSize(pageSize); + result.setTotalPages(pageSize == 0 ? 0 : (int) Math.ceil((double) total / pageSize)); + result.setHasNext((long) pageIndex * pageSize < total); + return result; + } + + private static String emptyToNull(String s) { + return s == null || s.trim().isEmpty() ? null : s.trim(); + } + /** * 撤回(托管接入件,2026-09-16 补:原先只有 CAS 用户面 {@code /api/flow/instance/{id}/withdraw}, * 业务应用只能让浏览器直调,票据与 CORS 一并外溢)。治理口径: diff --git a/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java b/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java new file mode 100644 index 0000000..89ab7aa --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/notify/UserMsgNotifierBizUrlTest.java @@ -0,0 +1,98 @@ +package com.yunhai.flowcenter.notify; + +import com.yunhai.flowcenter.engine.FlowEvent; +import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.entity.FlwTask; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwTaskRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +/** + * 待办通知 jumpPath 两态(第三步 T-C3):办件 bizUrl 非空=直达应用详情页(应用内审批免跳 + * 流程中心);空/空白=回落流程中心办理页 /task/{taskId}。 + */ +class UserMsgNotifierBizUrlTest { + + private FlwTaskRepository taskRepository; + private FlwInstanceRepository instanceRepository; + private CapturingUserMsgClient userMsg; + private UserMsgNotifier notifier; + + /** 捕获 jumpPath 的哑客户端 */ + static class CapturingUserMsgClient extends UserMsgClient { + String lastJumpPath; + + CapturingUserMsgClient() { + super(null, null, false, ""); + } + + @Override + public void sendUserMsg(Integer schId, List userIds, Integer msgType, String bizType, + String bizId, String title, String content, String jumpPath, String msgNo, + Integer expireDays) { + this.lastJumpPath = jumpPath; + } + + @Override + public void todoInc(Integer schId, List userIds, int delta) { + // 哑实现 + } + } + + @BeforeEach + void setUp() { + taskRepository = Mockito.mock(FlwTaskRepository.class); + instanceRepository = Mockito.mock(FlwInstanceRepository.class); + userMsg = new CapturingUserMsgClient(); + notifier = new UserMsgNotifier(userMsg, taskRepository, instanceRepository); + } + + @Test + void bizUrlPresent_jumpsToAppDetail() { + FlwTask task = new FlwTask(); + task.setId(77L); + task.setAssigneeId(101); + task.setInstanceId(5L); + FlwInstance instance = new FlwInstance(); + instance.setId(5L); + instance.setSchId(1L); + instance.setBizTitle("张三的事项"); + instance.setBizUrl("http://base.yunhaixingtong.com/notice/front/detail/27"); + when(taskRepository.findById(77L)).thenReturn(Optional.of(task)); + when(instanceRepository.findById(5L)).thenReturn(Optional.of(instance)); + + notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED, 5L, 77L, 101, "甲", null)); + + assertEquals("http://base.yunhaixingtong.com/notice/front/detail/27", userMsg.lastJumpPath); + } + + @Test + void bizUrlBlank_fallsBackToTaskPage() { + FlwTask task = new FlwTask(); + task.setId(78L); + task.setAssigneeId(101); + task.setInstanceId(6L); + FlwInstance instance = new FlwInstance(); + instance.setId(6L); + instance.setSchId(1L); + instance.setBizTitle("李四的事项"); + instance.setBizUrl(" "); + when(taskRepository.findById(78L)).thenReturn(Optional.of(task)); + when(instanceRepository.findById(6L)).thenReturn(Optional.of(instance)); + + notifier.onFlowEvent(FlowEvent.of(FlowEvent.Type.TASK_CREATED, 6L, 78L, 101, "乙", null)); + + assertEquals("/task/78", userMsg.lastJumpPath); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java index 4d19369..082ff29 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java @@ -16,6 +16,8 @@ import org.springframework.context.annotation.Primary; import org.springframework.http.ResponseEntity; import org.springframework.test.context.ActiveProfiles; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -242,6 +244,172 @@ class OapiFlowContractIT extends FlowTestSupport { assertEquals(10, instanceRepository.findById(instanceId).get().getStatus()); } + // ===== 应用内审批(第三步):tasks/todo + instances + approve/reject ===== + + /** 待办分页(bizType 过滤 + 出参含 biz 定位/快照/版本);查询不限办理人(办理才校验) */ + @Test + void todoTasks_pagedAndFilteredByBizType() { + mapDept("ybb", 101, "甲"); + FlwDef def = saveAndPublishDef("t20", "待办查询", linearDef("ybb", "审核")); + String noticeBody = "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"notice\",\"bizId\":\"td-1\",\"bizTitle\":\"通知审核\"," + + "\"bizUrl\":\"http://front/#/notice/1\"," + + "\"bizSnapshot\":{\"title\":\"通知审核\",\"summary\":\"摘要\"}," + + "\"submitterId\":201,\"submitterName\":\"张三\"}"; + String itemBody = "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"td-2\",\"bizTitle\":\"事项审核\"," + + "\"submitterId\":202,\"submitterName\":\"李四\"}"; + unwrap(client.post("/oapi/flow/apply?schId=1", noticeBody, JsonNode.class).getBody()); + unwrap(client.post("/oapi/flow/apply?schId=1", itemBody, JsonNode.class).getBody()); + + // 全量待办 + ResponseEntity all = client.get( + "/oapi/flow/tasks/todo?schId=1&assigneeId=101", JsonNode.class); + assertEquals(200, all.getStatusCodeValue()); + assertEquals(0, all.getBody().get("code").asInt()); + JsonNode allData = unwrap(all.getBody()); + assertEquals(2, allData.get("total").asInt()); + JsonNode firstRow = allData.get("content").get(0); + assertNotNull(firstRow.get("taskId").asLong()); + assertNotNull(firstRow.get("instanceId").asLong()); + assertNotNull(firstRow.get("instanceVersion")); + assertEquals("notice", firstRow.get("bizType").asText()); + assertEquals("td-1", firstRow.get("bizId").asText()); + assertTrue(firstRow.get("bizSnapshot").asText().contains("摘要")); + assertEquals("审核", firstRow.get("nodeName").asText()); + assertEquals(101, firstRow.get("assigneeId").asInt()); + + // bizType 过滤 + ResponseEntity onlyItem = client.get( + "/oapi/flow/tasks/todo?schId=1&assigneeId=101&bizType=svc_item", JsonNode.class); + JsonNode itemData = unwrap(onlyItem.getBody()); + assertEquals(1, itemData.get("total").asInt()); + assertEquals("td-2", itemData.get("content").get(0).get("bizId").asText()); + } + + /** 办件分页(bizType/status 过滤;查询不做办理人校验) */ + @Test + void instances_pagedByBizTypeAndStatus() { + mapDept("ybb", 101, "甲"); + FlwDef def = saveAndPublishDef("t21", "办件查询", linearDef("ybb", "审核")); + unwrap(client.post("/oapi/flow/apply?schId=1", "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"notice\",\"bizId\":\"in-1\",\"bizTitle\":\"办件一\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class).getBody()); + unwrap(client.post("/oapi/flow/apply?schId=1", "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"in-2\",\"bizTitle\":\"办件二\"," + + "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody()); + + JsonNode byType = unwrap(client.get( + "/oapi/flow/instances?schId=1&bizType=notice", JsonNode.class).getBody()); + assertEquals(1, byType.get("total").asInt()); + assertEquals("办件一", byType.get("content").get(0).get("bizTitle").asText()); + + JsonNode approving = unwrap(client.get( + "/oapi/flow/instances?schId=1&status=10", JsonNode.class).getBody()); + assertEquals(2, approving.get("total").asInt()); + JsonNode finished = unwrap(client.get( + "/oapi/flow/instances?schId=1&status=20", JsonNode.class).getBody()); + assertEquals(0, finished.get("total").asInt()); + } + + /** 办理(第三步 T-C1b):非办理人业务码拒绝不 500;同意推进+opinion 进回调 payload;拒绝必填意见 */ + @Test + void approveAndReject_operatorChecked_opinionPropagates() throws Exception { + mapDept("ybb", 101, "甲"); + FlwDef def = saveAndPublishDef("t22", "办理", linearDef("ybb", "审核")); + // 本地回调接收 sink(探活 POST + 事件推送都回 200),直接捕获 payload 断言 + List receivedPayloads = java.util.Collections.synchronizedList(new java.util.ArrayList<>()); + com.sun.net.httpserver.HttpServer sink = com.sun.net.httpserver.HttpServer.create( + new java.net.InetSocketAddress(java.net.InetAddress.getLoopbackAddress(), 0), 0); + sink.createContext("/", exchange -> { + java.io.ByteArrayOutputStream buf = new java.io.ByteArrayOutputStream(); + byte[] chunk = new byte[4096]; + int n; + while ((n = exchange.getRequestBody().read(chunk)) > 0) { + buf.write(chunk, 0, n); + } + receivedPayloads.add(new String(buf.toByteArray(), java.nio.charset.StandardCharsets.UTF_8)); + exchange.sendResponseHeaders(200, -1); + exchange.close(); + }); + sink.start(); + try { + String regBody = "{\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"callbackUrl\":\"http://localhost:" + sink.getAddress().getPort() + "/cb\"}"; + assertEquals(0, client.post("/oapi/flow/callback/reg?schId=1", regBody, JsonNode.class) + .getBody().get("code").asInt(), "回调注册(指向本地 sink)应探活通过"); + Long instanceId = unwrap(client.post("/oapi/flow/apply?schId=1", "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"ap-1\",\"bizTitle\":\"办理件\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class).getBody()) + .get("instanceId").asLong(); + Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId(); + + // 缺 operatorName → 业务码拒绝 + ResponseEntity noName = client.post( + "/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101", "{}", JsonNode.class); + assertNotEquals(0, noName.getBody().get("code").asInt(), "缺 operatorName 应被拒绝"); + + // 非办理人(999)→ 业务码拒绝(code!=0,HTTP 200),任务仍在待办 + ResponseEntity forged = client.post( + "/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=999&operatorName=路人", + "{\"opinion\":\"越权\"}", JsonNode.class); + assertEquals(200, forged.getStatusCodeValue()); + assertNotEquals(0, forged.getBody().get("code").asInt(), "非办理人应被业务码拒绝"); + assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(), + "越权后任务仍在待办"); + + // 办理人(101)同意 → 任务 1、单节点单人会签放行 → 办件 20;opinion 进回调 payload + ResponseEntity ok = client.post( + "/oapi/flow/task/" + taskId + "/approve?schId=1&operatorId=101&operatorName=甲", + "{\"opinion\":\"同意,情况属实\"}", JsonNode.class); + assertEquals(0, ok.getBody().get("code").asInt(), "办理人同意应成功"); + assertEquals(1, taskRepository.findById(taskId).get().getStatus()); + assertEquals(20, instanceRepository.findById(instanceId).get().getStatus()); + assertTrue(awaitPayloadContains(receivedPayloads, "同意,情况属实"), + "opinion 应透传到回调 payload"); + + // 第二件:拒绝无意见 → 业务码拒绝;带意见 → 任务 2、办件 30 + Long id2 = unwrap(client.post("/oapi/flow/apply?schId=1", "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"ap-2\",\"bizTitle\":\"驳回件\"," + + "\"submitterId\":202,\"submitterName\":\"李四\"}", JsonNode.class).getBody()) + .get("instanceId").asLong(); + Long taskId2 = taskRepository.findByInstanceIdAndStatus(id2, 0).get(0).getId(); + ResponseEntity noOpinion = client.post( + "/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲", + "{}", JsonNode.class); + assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见"); + ResponseEntity rejected = client.post( + "/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲", + "{\"opinion\":\"材料不全\"}", JsonNode.class); + assertEquals(0, rejected.getBody().get("code").asInt()); + assertEquals(2, taskRepository.findById(taskId2).get().getStatus()); + assertEquals(30, instanceRepository.findById(id2).get().getStatus()); + assertTrue(awaitPayloadContains(receivedPayloads, "材料不全"), + "拒绝意见也应透传到回调 payload"); + } finally { + sink.stop(0); + } + } + + /** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */ + private boolean awaitPayloadContains(List payloads, String needle) throws InterruptedException { + for (int i = 0; i < 25; i++) { + for (String p : payloads) { + if (p.contains(needle)) { + return true; + } + } + Thread.sleep(200); + } + return false; + } + private JsonNode unwrap(JsonNode body) { return body.get("data"); }