feat(oapi): 应用内审批 oapi——待办/办件查询 + 办理(operator 复核)+ 待办跳转 bizUrl 优先
build / build-push (push) Successful in 1m29s

- GET /oapi/flow/tasks/todo?schId&assigneeId&bizType&page&size(出参含 biz 定位/快照/版本)
- GET /oapi/flow/instances?schId&bizType&status&page&size(查询不限办理人)
- POST /oapi/flow/task/{taskId}/approve|reject?schId&operatorId&operatorName:
  operatorId 按任务办理人逐字复核(非办理人→业务码拒绝不 500),拒绝必填意见,
  opinion 经事件链透传到回调 payload;审批后回调/待办减一自动复用
- 待办通知 jumpPath:办件 bizUrl 优先(空回落 /task/{taskId})

契约 IT +3(todo 过滤分页/instances 状态过滤/办理三态+payload 断言),
单测 UserMsgNotifierBizUrlTest 两态;全量 99 单测 + 11 契约 IT 绿。
This commit is contained in:
chenxing
2026-09-22 16:54:29 +08:00
parent d2395c20ce
commit f2126cdf32
8 changed files with 519 additions and 4 deletions
@@ -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<OapiModels.TodoTaskVo> 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<OapiModels.InstanceItemVo> 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());
}
}
@@ -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;
}
}
@@ -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);
}
@@ -29,4 +29,11 @@ public interface FlwInstanceRepository extends JpaRepository<FlwInstance, Long>
+ "and (:keyword is null or i.bizTitle like concat('%', :keyword, '%')) order by i.id desc")
Page<FlwInstance> 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<FlwInstance> pageByBiz(@Param("schId") Long schId, @Param("bizType") String bizType,
@Param("status") Integer status, Pageable pageable);
}
@@ -57,6 +57,17 @@ public interface FlwTaskRepository extends JpaRepository<FlwTask, Long> {
+ "order by t.arriveTime asc, t.id asc")
Page<FlwTask> 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<FlwTask> pageTodoBiz(@Param("schId") Long schId, @Param("assigneeId") Integer assigneeId,
@Param("bizType") String bizType, Pageable pageable);
/**
* 我的已办(一个办件一行,2026-09-18):同一办件被本人办理多次(多节点/退回重办/移交后
* 再办)时只保留本人最后办理的那一条(该办件下本人已办行的最大 id),避免列表里同一办件
@@ -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<OapiModels.TodoTaskVo> 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<FlwTask> p = taskRepository.pageTodoBiz(schId, assigneeId, emptyToNull(bizType),
PageRequest.of(normPage(page), normSize(size)));
List<Long> instanceIds = p.getContent().stream()
.map(FlwTask::getInstanceId).distinct().collect(java.util.stream.Collectors.toList());
Map<Long, FlwInstance> instances = instanceIds.isEmpty()
? java.util.Collections.emptyMap()
: instanceRepository.findAllById(instanceIds).stream()
.collect(java.util.stream.Collectors.toMap(FlwInstance::getId, i -> i));
List<OapiModels.TodoTaskVo> 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<OapiModels.InstanceItemVo> pageInstances(Long schId, String bizType, Integer status,
Integer page, Integer size) {
if (schId == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId");
}
Page<FlwInstance> p = instanceRepository.pageByBiz(schId, emptyToNull(bizType), status,
PageRequest.of(normPage(page), normSize(size)));
List<OapiModels.InstanceItemVo> 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 <T> BasePageResult<T> toPageResult(long total, List<T> content, Integer page, Integer size) {
BasePageResult<T> 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 一并外溢)。治理口径:
@@ -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<Integer> 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<Integer> 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);
}
}
@@ -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<JsonNode> 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<JsonNode> 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<String> 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<JsonNode> 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<JsonNode> 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<JsonNode> 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<JsonNode> noOpinion = client.post(
"/oapi/flow/task/" + taskId2 + "/reject?schId=1&operatorId=101&operatorName=甲",
"{}", JsonNode.class);
assertNotEquals(0, noOpinion.getBody().get("code").asInt(), "拒绝必须填写意见");
ResponseEntity<JsonNode> 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<String> 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");
}