[v2.1] A 应用内审批地基:oapi 增 GET /tasks/by-biz——按单据查当前人待办(0/1/N 均 200 空数组不泄露,校 schId/bizType/assignee 三 match,IT 11 例)
build / build-push (push) Successful in 2m2s

This commit is contained in:
chenxing
2026-10-19 16:44:35 +08:00
parent 8ceb2a4247
commit 132919b029
4 changed files with 385 additions and 0 deletions
@@ -0,0 +1,255 @@
package com.yunhai.flowcenter.oapi;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.service.OapiFlowService;
import com.yunhai.flowcenter.support.FlowTestSupport;
import com.yunhai.flowcenter.support.OapiSignedClient;
import com.yunhai.platform.apistd.BizException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.context.annotation.Bean;
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;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* GET /oapi/flow/tasks/by-biz 契约(v2.1 应用内审批·业务详情卡片数据源)。
* 核心是 0/1/N 三种计数语义与"不探测他人办件":办件不存在、错校、错 bizType、他人待办一律
* HTTP 200 + tasks=[],出参不区分(区分即等于告诉调用方"这个单据上有个待办,只是不是你的")。
* 注意:本类必须单类独跑(平台 IT 判据)。
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class OapiFlowTasksByBizTest extends FlowTestSupport {
private static final String APP_KEY = "100018";
private static final String APP_SECRET = "svc-hall-dev-secret-100018";
private static final String BIZ = "notice";
@LocalServerPort
private int port;
@Autowired
private OapiFlowService flowService;
private OapiSignedClient client;
@TestConfiguration
static class StubConfig {
@Bean
@Primary
public com.yunhai.flowcenter.stub.StubUserClient stubUserClient() {
return new com.yunhai.flowcenter.stub.StubUserClient();
}
}
@BeforeEach
void setUp() {
cleanDb();
client = new OapiSignedClient("http://localhost:" + port, APP_KEY, APP_SECRET);
}
/** 代发起一件并回带 instanceId(bizId 唯一,供 by-biz 定位) */
private Long applyOne(FlwDef def, String bizId, String title) {
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"" + BIZ + "\",\"bizId\":\"" + bizId + "\",\"bizTitle\":\"" + title + "\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"}", JsonNode.class);
assertEquals(0, resp.getBody().get("code").asInt(), "代发起应成功:" + resp.getBody());
return resp.getBody().get("data").get("instanceId").asLong();
}
private JsonNode byBiz(String bizId, int operatorId) {
return byBiz(SCH, BIZ, bizId, operatorId);
}
private JsonNode byBiz(long schId, String bizType, String bizId, int operatorId) {
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/tasks/by-biz?schId=" + schId
+ "&bizType=" + bizType + "&bizId=" + bizId + "&operatorId=" + operatorId, JsonNode.class);
assertEquals(200, resp.getStatusCodeValue(), "by-biz 恒 HTTP 200(空结果不报错)");
assertEquals(0, resp.getBody().get("code").asInt(), "业务码须为成功:" + resp.getBody());
return resp.getBody().get("data");
}
/** 签名强制:无签名头 401(与其余 /oapi 端点同纪律) */
@Test
void requiresAppSignature_401() {
assertEquals(401, client.getRaw(
"/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x&operatorId=101").getStatusCodeValue());
}
/** 缺参/非正数 → 业务码拒绝(身份参数不允许"可选"降级成越权查询) */
@Test
void badParams_rejected() {
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x",
JsonNode.class).getBody(), "缺 operatorId 应拒绝");
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizId=x&operatorId=101",
JsonNode.class).getBody(), "缺 bizType 应拒绝");
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=0&bizType=notice&bizId=x&operatorId=101",
JsonNode.class).getBody(), "schId<=0 应拒绝");
assertNotEqualsZero(client.get("/oapi/flow/tasks/by-biz?schId=1&bizType=notice&bizId=x&operatorId=0",
JsonNode.class).getBody(), "operatorId<=0 应拒绝");
}
/** 0 条·无办件:instanceId/status/currentNodes 均 null,tasks 空数组(不是 null,前端不必判空指针) */
@Test
void noInstance_empty() {
mapDept("ybb", 101, "甲");
saveAndPublishDef("bb1", "无办件", linearDef("ybb", "审核"));
JsonNode data = byBiz("ghost-1", 101);
assertTrue(data.get("instanceId").isNull(), "无办件 instanceId 须 null");
assertTrue(data.get("status").isNull(), "无办件 status 须 null");
assertTrue(data.get("currentNodes").isNull(), "无办件 currentNodes 须 null");
assertEquals(0, data.get("tasks").size());
assertEquals("notice", data.get("bizType").asText(), "入参 bizType 原样回显");
assertEquals("ghost-1", data.get("bizId").asText(), "入参 bizId 原样回显");
}
/** 0 条·办件存在但本人无待办(已办结):仍 200 + 空,且带出办件状态供卡片渲染终态 */
@Test
void finishedInstance_emptyWithStatus() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb2", "已办结", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "done-1", "已办结通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
assertEquals(0, client.post("/oapi/flow/task/" + taskId
+ "/approve?schId=1&operatorId=101&operatorName=甲", "{}", JsonNode.class)
.getBody().get("code").asInt(), "办理人同意应成功");
JsonNode data = byBiz("done-1", 101);
assertEquals(0, data.get("tasks").size(), "办结后本人无待办");
assertEquals(20, data.get("status").asInt(), "终态可展示:回带办件状态");
assertEquals(instanceId, data.get("instanceId").asLong());
}
/** 1 条·单出口:outlets 空数组(与 FlowQueryService.detail 同口径,单出口不给前端多余分支) */
@Test
void oneTask_singleOutlet_noOutlets() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb3", "单出口", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "one-1", "单出口通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
JsonNode data = byBiz("one-1", 101);
assertEquals(1, data.get("tasks").size());
JsonNode task = data.get("tasks").get(0);
assertEquals(taskId, task.get("taskId").asLong());
assertEquals("n1", task.get("nodeId").asText());
assertEquals("审核", task.get("nodeName").asText());
assertNotNull(task.get("arriveTime").asText());
assertEquals(0, task.get("outlets").size(), "单出口不透出 outlets");
assertEquals(10, data.get("status").asInt(), "审批中");
// currentNodes 是办件上的节点 **id** 逗号串(与 /oapi/flow/instances、progress 同一原文口径,
// 本端点不做名字化——改名会让同名字段在两个 oapi 里语义分叉)。人读节点名取 tasks[].nodeName。
assertEquals("n1", data.get("currentNodes").asText());
}
/** 1 条·多出口:outlets 逐字给出 name/toNodeId/toNodeName,供卡片"通过"弹窗选出口 */
@Test
void oneTask_multiOutlet_exposesOutlets() {
mapDept("ybb", 101, "甲");
mapDept("kb", 102, "乙");
FlwDef def = saveAndPublishDef("bb4", "多出口", multiOutletDef("ybb", "kb"));
Long instanceId = applyOne(def, "mo-1", "多出口通知");
JsonNode data = byBiz("mo-1", 101);
assertEquals(instanceId, data.get("instanceId").asLong(), "多出口件同样回带办件定位");
JsonNode task = data.get("tasks").get(0);
assertEquals("入口审核", task.get("nodeName").asText());
JsonNode outlets = task.get("outlets");
assertEquals(2, outlets.size(), "n1 出边 2 条须全部透出");
assertEquals("转乙科", outlets.get(0).get("name").asText());
assertEquals("n2", outlets.get(0).get("toNodeId").asText());
assertEquals("复核", outlets.get(0).get("toNodeName").asText());
assertEquals("通过", outlets.get(1).get("name").asText());
assertEquals("end", outlets.get(1).get("toNodeId").asText());
}
/** N 条:并行两支都指派同一人 → tasks 两条、按 taskId 升序;卡片据此引导列表/任务页,不猜选 */
@Test
void multiTask_returnsAllSorted() {
mapDept("ka", 101, "甲");
mapDept("kb", 101, "甲");
FlwDef def = saveAndPublishDef("bb5", "并行同人", parallelDef("ka", "kb"));
Long instanceId = applyOne(def, "mt-1", "多待办通知");
List<Long> pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0)
.stream().map(com.yunhai.flowcenter.entity.FlwTask::getId)
.collect(java.util.stream.Collectors.toList());
assertEquals(2, pending.size(), "前置数据:本人两条并行待办");
JsonNode data = byBiz("mt-1", 101);
JsonNode tasks = data.get("tasks");
assertEquals(2, tasks.size(), "N 条全返回,不合并、不猜选");
long first = tasks.get(0).get("taskId").asLong();
long second = tasks.get(1).get("taskId").asLong();
assertTrue(first < second, "须按 taskId 升序:" + first + " -> " + second);
}
/** 他人待办 → 空(关键防探测:并行两支分属两人时,非办理人查同一 bizId 得到 tasks=[]) */
@Test
void otherAssignee_empty() {
mapDept("ka", 101, "甲");
mapDept("kb", 102, "乙");
FlwDef def = saveAndPublishDef("bb6", "并行他人", parallelDef("ka", "kb"));
Long instanceId = applyOne(def, "ot-1", "他人待办通知");
assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size(),
"前置数据:两条待办分属 101/102");
JsonNode asOutsider = byBiz("ot-1", 999);
assertEquals(0, asOutsider.get("tasks").size(), "非办理人 → 空");
assertNotNull(asOutsider.get("instanceId"), "但办件定位字段照常回带");
assertEquals(1, byBiz("ot-1", 101).get("tasks").size(), "办理人本人查得到");
// 出参不得泄露他人办理人
assertFalse(byBiz("ot-1", 101).toString().contains("102"), "不得透出他人 assigneeId");
assertFalse(byBiz("ot-1", 101).toString().contains("乙"), "不得透出他人姓名");
}
/** 错学校 / 错 bizType → 空(租户与业务类型收敛在定位查询本身) */
@Test
void wrongSchoolOrBizType_empty() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb7", "跨租户", linearDef("ybb", "审核"));
applyOne(def, "ws-1", "跨租户通知");
JsonNode otherSchool = byBiz(2L, BIZ, "ws-1", 101);
assertEquals(0, otherSchool.get("tasks").size(), "错学校 → 空");
assertTrue(otherSchool.get("instanceId").isNull(), "错学校不得回带 instanceId");
JsonNode otherType = byBiz(SCH, "svc_item", "ws-1", 101);
assertEquals(0, otherType.get("tasks").size(), "错 bizType → 空");
assertTrue(otherType.get("instanceId").isNull(), "错 bizType 不得回带 instanceId");
}
/** 定义缺失(def 行被清理)→ 任务照常返回,outlets 降级空数组,不 500 */
@Test
void missingDef_degradesWithoutOutlets() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("bb8", "定义缺失", linearDef("ybb", "审核"));
Long instanceId = applyOne(def, "md-1", "定义缺失通知");
Long taskId = taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getId();
defRepository.delete(def);
JsonNode task = byBiz("md-1", 101).get("tasks").get(0);
assertEquals(0, task.get("outlets").size(), "无定义时 outlet 计算降级空");
assertEquals(taskId, task.get("taskId").asLong(), "任务本身照常返回");
}
/** 服务层直调:空白 bizId 拒绝(URL 上 bizId= 空串不得退化成"查全部") */
@Test
void service_blankBizParams_rejected() {
assertThrows(BizException.class, () -> flowService.tasksByBiz(1L, "notice", " ", 101));
assertThrows(BizException.class, () -> flowService.tasksByBiz(1L, null, "1", 101));
assertThrows(BizException.class, () -> flowService.tasksByBiz(null, "notice", "1", 101));
}
private static void assertNotEqualsZero(JsonNode body, String message) {
org.junit.jupiter.api.Assertions.assertNotEquals(Integer.valueOf(0),
Integer.valueOf(body.get("code").asInt()), message);
}
}