[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
@@ -32,6 +32,7 @@ import java.util.List;
* 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方 * 应用内审批(2026-09 第三步,原"刻意不提供审批动作"立场由该方案修订:登录身份由调用方
* 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断): * 应用按其 CAS 会话保证并以 operatorId 传入,本端按任务办理人逐字复核,审计链不断):
* - GET /oapi/flow/tasks/todo?schId=&assigneeId=&bizType=&page=&size= → data=分页待办 * - 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=分页办件 * - 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}/approve?schId=&operatorId=&operatorName=(body: opinion/edgeName)
* - POST /oapi/flow/task/{taskId}/reject?schId=&operatorId=&operatorName=(body: opinion 必填) * - 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); 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 可选过滤 */ /** 办件分页(oapi):bizType/status 可选过滤 */
@GetMapping("/instances") @GetMapping("/instances")
public BasePageResult<OapiModels.InstanceItemVo> instances( public BasePageResult<OapiModels.InstanceItemVo> instances(
@@ -3,6 +3,7 @@ package com.yunhai.flowcenter.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -266,4 +267,45 @@ public final class OapiModels {
/** 多出口节点办理时所选出口名(单出口可省) */ /** 多出口节点办理时所选出口名(单出口可省) */
private String edgeName; 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<ByBizTaskVo> 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<ByBizOutletVo> outlets = new ArrayList<>();
}
/** by-biz 出口(多出口节点办理时须选其一,透传给 approve 的 edgeName) */
@Data
public static class ByBizOutletVo {
private String name;
private String toNodeId;
private String toNodeName;
}
} }
@@ -349,6 +349,79 @@ public class OapiFlowService {
return toPageResult(p.getTotalElements(), items, page, size); return toPageResult(p.getTotalElements(), items, page, size);
} }
/**
* 按业务单据查当前人待办(v2.1 应用内审批的业务详情卡片数据源):
* 补齐 tasks/todo 缺的那一侧——todo 是"人 → 单据列表",本方法是"单据 → 我的待办",
* 子应用据此判断"当前人是否该办件的待办人",不必再拉全量待办在前端过滤。
* <p>治理口径:
* <ul>
* <li>只返回 {@code assigneeId=operatorId} 且 status=PENDING 的任务;他人待办与"办件不存在"
* 一律出空数组(HTTP 200),出参不区分二者,避免用本端点探测别人的办件;</li>
* <li>租户收敛在定位查询本身({@code findBySchIdAndBizTypeAndBizId}),bizType 不匹配即空;</li>
* <li>{@code outlets} 只为当前人的任务计算,且出边 &gt;1 才透出(与
* {@code FlowQueryService.detail} 的办理面板口径一致——单出口无需选择,不给前端多余分支);</li>
* <li>不透出他人办理人、意见或定义内部 id;taskId 是办理必需的运行时身份,保留。</li>
* </ul>
*/
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<FlwTask> 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<FlowDefModel.Edge> 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 由调用方应用按其登录态传入,本端按任务办理人 * 同意(oapi,应用内审批):operatorId 由调用方应用按其登录态传入,本端按任务办理人
* 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。 * 复核({@code task.assigneeId == operatorId},不满足→业务码拒绝,应用签名不替代"谁在办")。
@@ -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);
}
}