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 faf1d2a..25ca676 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -32,6 +32,7 @@ import java.util.List; * 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方 * 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断): * - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办 + * - GET /oapi/flow/tasks/by-biz?schId=&bizType=&bizId=&operatorId= → data=当前人在该单据的待办(0/1/N) * - 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 必填) @@ -158,6 +159,20 @@ public class OapiFlowController { return flowService.pageTodoTasks(schId, assigneeId, bizType, page, size); } + /** + * 按业务单据查当前人待办(v2.1 业务详情卡片数据源):与 tasks/todo 互补——那个是"人→单据", + * 这个是"单据→我的待办"。0/1/N 三种计数都由 HTTP 200 + tasks 数组表达,办件不存在与 + * 他人待办同样出空(不用于探测)。operatorId 由子应用后端从 CAS 会话取,前端不可传。 + */ + @GetMapping("/tasks/by-biz") + public OapiModels.ByBizTasksVo tasksByBiz( + @RequestParam("schId") Long schId, + @RequestParam("bizType") String bizType, + @RequestParam("bizId") String bizId, + @RequestParam("operatorId") Integer operatorId) { + return flowService.tasksByBiz(schId, bizType, bizId, operatorId); + } + /** 办件分页(oapi):bizType/status 可选过滤 */ @GetMapping("/instances") public BasePageResult instances( diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index 52250f8..4760a6c 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -3,6 +3,7 @@ package com.yunhai.flowcenter.model; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import lombok.Data; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -266,4 +267,45 @@ public final class OapiModels { /** 多出口节点办理时所选出口名(单出口可省) */ private String edgeName; } + + /** + * 按业务单据定位的当前人待办(tasks/by-biz 出参,v2.1 应用内审批卡片数据源)。 + * 无办件与"有办件但本人无待办"同为 tasks=[] + HTTP 200,出参不区分二者(防探测)。 + */ + @Data + public static class ByBizTasksVo { + /** 办件 id;无办件时 null */ + private Long instanceId; + private String bizType; + private String bizId; + /** 办件状态;无办件时 null */ + private Integer status; + /** + * 办件当前活跃节点——**节点 id** 逗号串,原文照抄 {@code flw_instance.current_nodes}, + * 与 {@code instances}/{@code progress} 同口径(不是节点名,渲染人读名称请取 + * {@code tasks[].nodeName})。无办件时 null。 + */ + private String currentNodes; + /** 当前人 PENDING 任务,按 taskId 升序;无待办为空数组 */ + private List tasks = new ArrayList<>(); + } + + /** by-biz 单条待办:只含办理该任务必需的定位信息,不透出他人办理人/意见 */ + @Data + public static class ByBizTaskVo { + private Long taskId; + private String nodeId; + private String nodeName; + private String arriveTime; + /** 出口列表;单出口节点为空数组(与 FlowQueryService.detail 的 outlets 口径一致) */ + private List outlets = new ArrayList<>(); + } + + /** by-biz 出口(多出口节点办理时须选其一,透传给 approve 的 edgeName) */ + @Data + public static class ByBizOutletVo { + private String name; + private String toNodeId; + private String toNodeName; + } } diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index 914afb5..0693b14 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -349,6 +349,79 @@ public class OapiFlowService { return toPageResult(p.getTotalElements(), items, page, size); } + /** + * 按业务单据查当前人待办(v2.1 应用内审批的业务详情卡片数据源): + * 补齐 tasks/todo 缺的那一侧——todo 是"人 → 单据列表",本方法是"单据 → 我的待办", + * 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。 + *

治理口径: + *

    + *
  • 只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在" + * 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;
  • + *
  • 租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;
  • + *
  • {@code outlets} 只为当前人的任务计算,且出边 >1 才透出(与 + * {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);
  • + *
  • 不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。
  • + *
+ */ + public OapiModels.ByBizTasksVo tasksByBiz(Long schId, String bizType, String bizId, Integer operatorId) { + if (schId == null || schId <= 0 || operatorId == null || operatorId <= 0) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/operatorId(均须为正)"); + } + String cleanBizType = emptyToNull(bizType); + String cleanBizId = emptyToNull(bizId); + if (cleanBizType == null || cleanBizId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:bizType/bizId"); + } + OapiModels.ByBizTasksVo vo = new OapiModels.ByBizTasksVo(); + vo.setBizType(cleanBizType); + vo.setBizId(cleanBizId); + FlwInstance instance = instanceRepository + .findBySchIdAndBizTypeAndBizId(schId, cleanBizType, cleanBizId).orElse(null); + if (instance == null) { + return vo; + } + vo.setInstanceId(instance.getId()); + vo.setStatus(instance.getStatus()); + vo.setCurrentNodes(instance.getCurrentNodes()); + List mine = taskRepository + .findByInstanceIdAndStatus(instance.getId(), FlwEnums.TaskStatus.PENDING.getCode()).stream() + .filter(t -> operatorId.equals(t.getAssigneeId())) + .sorted(java.util.Comparator.comparing(FlwTask::getId)) + .collect(java.util.stream.Collectors.toList()); + if (mine.isEmpty()) { + return vo; + } + FlwDef def = defRepository.findById(instance.getDefId()).orElse(null); + FlowDefModel model = def == null ? null : flowEngine.parseModel(def); + for (FlwTask t : mine) { + vo.getTasks().add(toByBizTaskVo(t, model)); + } + return vo; + } + + private OapiModels.ByBizTaskVo toByBizTaskVo(FlwTask t, FlowDefModel model) { + OapiModels.ByBizTaskVo vo = new OapiModels.ByBizTaskVo(); + vo.setTaskId(t.getId()); + vo.setNodeId(t.getNodeId()); + vo.setNodeName(t.getNodeName()); + vo.setArriveTime(t.getArriveTime() == null ? null : t.getArriveTime().toString()); + if (model == null) { + return vo; + } + List outs = model.outEdges(t.getNodeId()); + if (outs.size() > 1) { + for (FlowDefModel.Edge e : outs) { + FlowDefModel.Node to = model.node(e.to); + OapiModels.ByBizOutletVo outlet = new OapiModels.ByBizOutletVo(); + outlet.setName(e.name); + outlet.setToNodeId(e.to); + outlet.setToNodeName(to == null || to.name == null || to.name.isEmpty() ? e.to : to.name); + vo.getOutlets().add(outlet); + } + } + return vo; + } + /** * 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人 * 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。 diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java new file mode 100644 index 0000000..346c21a --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowTasksByBizTest.java @@ -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 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 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 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); + } +}