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
@@ -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");
}