From fd4e207ce0b88a53e6e7f36d1958cd93326c9865 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 13 Oct 2026 12:05:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E8=B5=B7=E6=97=B6=E6=8C=87?= =?UTF-8?q?=E6=B4=BE=E2=80=94=E2=80=94assignPolicy=20=E9=85=8D=E7=BD=AE/as?= =?UTF-8?q?sign=5Fjson=20=E5=BF=AB=E7=85=A7/=E5=BC=95=E6=93=8E=E6=94=B6?= =?UTF-8?q?=E7=AA=84/preview+apply=20=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/oapi/OapiFlowController.java | 7 + .../flowcenter/engine/AssignDesignations.java | 29 ++++ .../yunhai/flowcenter/engine/AssigneeRef.java | 10 +- .../flowcenter/engine/DefValidator.java | 18 ++ .../flowcenter/engine/FlowDefModel.java | 16 ++ .../yunhai/flowcenter/engine/FlowEngine.java | 8 +- .../yunhai/flowcenter/entity/FlwInstance.java | 4 + .../yunhai/flowcenter/model/OapiModels.java | 42 +++++ .../service/FlowInstanceService.java | 4 + .../flowcenter/service/OapiFlowService.java | 142 +++++++++++++++- .../flowcenter/engine/DefValidatorTest.java | 48 ++++++ .../flowcenter/engine/FlowEngineIT.java | 160 +++++++++++++++++- .../flowcenter/oapi/OapiFlowContractIT.java | 125 ++++++++++++++ 13 files changed, 607 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/yunhai/flowcenter/engine/AssignDesignations.java 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 5c23ee0..bdba1c0 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java @@ -62,6 +62,13 @@ public class OapiFlowController { return flowService.apply(schId, appKey, body); } + /** 发起预览:返回开启发起时指派的节点与候选人(已剔除发起人本人),供子应用提交页选人 */ + @GetMapping("/apply/preview") + public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId, + @RequestParam("flowGuid") String flowGuid, @RequestParam("submitterId") Integer submitterId) { + return flowService.applyPreview(schId, flowGuid, submitterId); + } + @GetMapping("/instance") public OapiModels.InstanceVo instance(@RequestParam("bizType") String bizType, @RequestParam("bizId") String bizId, diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssignDesignations.java b/src/main/java/com/yunhai/flowcenter/engine/AssignDesignations.java new file mode 100644 index 0000000..4192d92 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/engine/AssignDesignations.java @@ -0,0 +1,29 @@ +package com.yunhai.flowcenter.engine; + +/** + * 发起时指派快照解析(2026-10-13 方案):assign_json 列文本 → nodeId → 指派人员快照。 + * 脏 JSON 只 log warn 返回空 Map(宽松回退口径,见方案 D3/I5)——引擎永不因缺指派拒绝或挂起。 + */ +public final class AssignDesignations { + private static final org.slf4j.Logger log = + org.slf4j.LoggerFactory.getLogger(AssignDesignations.class); + private static final com.fasterxml.jackson.databind.ObjectMapper MAPPER = + new com.fasterxml.jackson.databind.ObjectMapper(); + + private AssignDesignations() {} + + /** @return nodeId → 指派人员快照;null/空串/脏 JSON → 空 Map */ + public static java.util.Map> parse(String assignJson) { + if (assignJson == null || assignJson.trim().isEmpty()) { + return java.util.Collections.emptyMap(); + } + try { + java.util.Map> result = MAPPER.readValue(assignJson, + new com.fasterxml.jackson.core.type.TypeReference>>() {}); + return result == null ? java.util.Collections.emptyMap() : result; + } catch (Exception e) { + log.warn("[flow-center] assign_json 解析失败,按无指派回退全量候选:{}", e.getMessage()); + return java.util.Collections.emptyMap(); + } + } +} diff --git a/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java b/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java index cf6bb5d..c2ea35c 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java +++ b/src/main/java/com/yunhai/flowcenter/engine/AssigneeRef.java @@ -1,11 +1,17 @@ package com.yunhai.flowcenter.engine; -import lombok.Value; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; /** * 办理人解析结果(id+姓名快照),任务创建与通知共用。 */ -@Value +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor public class AssigneeRef { Integer userId; String userName; diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java index ba6c661..1d4cdf0 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -335,6 +335,24 @@ public final class DefValidator { if (n.deadlineHours != null && n.deadlineHours <= 0) { problems.add(nodeLabel(n) + " deadlineHours 必须为正整数"); } + // 发起时指派策略(2026-10-13):存在即开启;发布期拦明显配错(mode 非法/SINGLE 带非 1 边界/边界倒挂) + if (n.assignPolicy != null) { + FlowDefModel.AssignPolicy p = n.assignPolicy; + if (p.mode != null && !FlowDefModel.ASSIGN_MODE_SINGLE.equals(p.mode) + && !FlowDefModel.ASSIGN_MODE_MULTI.equals(p.mode)) { + problems.add(nodeLabel(n) + " assignPolicy.mode 取值非法:" + p.mode + "(支持 SINGLE/MULTI)"); + } + if (FlowDefModel.ASSIGN_MODE_SINGLE.equals(p.mode) + && ((p.min != null && p.min != 1) || (p.max != null && p.max != 1))) { + problems.add(nodeLabel(n) + " SINGLE 指派的 min/max 必须缺省或为 1(恰选 1 人)"); + } + if (p.min != null && p.min < 1) { + problems.add(nodeLabel(n) + " assignPolicy.min 必须 ≥1"); + } + if (p.min != null && p.max != null && p.min > p.max) { + problems.add(nodeLabel(n) + " assignPolicy.min 不得大于 max"); + } + } } private static void dfs(FlowDefModel model, String current, Set visited, diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java index 9a85190..db0076c 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowDefModel.java @@ -46,6 +46,10 @@ public class FlowDefModel { public static final String ASSIGNEE_DEPT_KEY = "DEPT_KEY"; public static final String ASSIGNEE_USERS = "USERS"; + /** 发起时指派选择模式:SINGLE=恰 1 人;MULTI=多选(min~max) */ + public static final String ASSIGN_MODE_SINGLE = "SINGLE"; + public static final String ASSIGN_MODE_MULTI = "MULTI"; + public String code; public String name; public String start; @@ -81,6 +85,8 @@ public class FlowDefModel { public String waitFor; /** 办理时限小时数(自然时间,任务 arrive_time + N 小时;可空) */ public Integer deadlineHours; + /** 发起时指派策略(2026-10-13 方案):存在即允许指派;缺省/缺失=不允许(向前兼容) */ + public AssignPolicy assignPolicy; } @JsonIgnoreProperties(ignoreUnknown = true) @@ -91,6 +97,16 @@ public class FlowDefModel { public List userIds; } + /** 发起时指派策略:mode 必填(容错 null=MULTI);min/max 可空(缺省 1/候选全量数);required 仅子应用 UI 用 */ + @JsonIgnoreProperties(ignoreUnknown = true) + public static class AssignPolicy { + /** SINGLE | MULTI */ + public String mode; + public Integer min; + public Integer max; + public Boolean required; + } + @JsonIgnoreProperties(ignoreUnknown = true) public static class Edge { public String from; diff --git a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java index 8360ec0..6c9bb7f 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java +++ b/src/main/java/com/yunhai/flowcenter/engine/FlowEngine.java @@ -209,10 +209,14 @@ public class FlowEngine { } } - /** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人 */ + /** 建任务(ALL=每人一条;ANY=每人一条、任一人办理即放行)+ 到达事件。operator=null 回退发起人。 + * 命中发起时指派快照则用指派子集替代全量候选(不变量 I1 收窄不改变 countersign 放行语义、I4 信任快照) */ private void createTasks(FlwInstance instance, FlowDefModel model, FlowDefModel.Node node, FlowOperator operator) { - List assignees = assigneeResolver.resolve(instance.getSchId(), node); + List designated = AssignDesignations.parse(instance.getAssignJson()).get(node.id); + List assignees = (designated != null && !designated.isEmpty()) + ? designated + : assigneeResolver.resolve(instance.getSchId(), node); LocalDateTime now = LocalDateTime.now(); LocalDateTime deadline = node.deadlineHours == null ? null : now.plusHours(node.deadlineHours); FlowOperator op = operator != null ? operator diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java index 72f1965..0758ab3 100644 --- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java +++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java @@ -51,6 +51,10 @@ public class FlwInstance extends BaseEntity { @Column(name = "form_data_json", columnDefinition = "LONGTEXT") private String formDataJson; + /** 发起时指派快照:{nodeId:[{userId,userName}]};NULL=无指派(全量候选现状行为) */ + @Column(name = "assign_json", columnDefinition = "LONGTEXT") + private String assignJson; + /** CMS 事项快照(原生模式,M1 不启用恒 null) */ @Column(name = "item_code", length = 64) private String itemCode; diff --git a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java index 058f5ca..4d32f81 100644 --- a/src/main/java/com/yunhai/flowcenter/model/OapiModels.java +++ b/src/main/java/com/yunhai/flowcenter/model/OapiModels.java @@ -40,6 +40,48 @@ public final class OapiModels { private String submitterName; /** 表单数据对象(业务方编辑页产出,无表单模式不传) */ private Map formData; + /** 发起时指派(可选,2026-10-13):[{nodeId, userIds}],校验失败业务码拒绝发起 */ + private List assigns; + } + + /** 发起时指派项(apply 可选字段) */ + @Data + public static class AssignItem { + private String nodeId; + private List userIds; + } + + /** 发起预览响应(GET /oapi/flow/apply/preview):只含开启指派的节点 */ + @Data + public static class ApplyPreviewVo { + private String flowGuid; + private List nodes; + } + + @Data + public static class PreviewNodeVo { + private String nodeId; + private String nodeName; + /** ANY|ALL(后端缺省补 ALL) */ + private String countersign; + private PreviewSelectionVo selection; + private List candidates; + } + + @Data + public static class PreviewSelectionVo { + /** SINGLE|MULTI */ + private String mode; + private Integer min; + private Integer max; + /** 仅子应用 UI 必选提示;引擎不消费 */ + private Boolean required; + } + + @Data + public static class PreviewUserVo { + private Integer userId; + private String userName; } /** 代发起响应(biz uk 幂等:重复代发起 HTTP 200 + duplicated:true 返回既有办件) */ diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java index 11499ae..5797308 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java @@ -60,6 +60,8 @@ public class FlowInstanceService { public final String applyAppKey; /** 调用方 app_code 快照(by-key 一次换得;UserBase 不可达为 null→消息回落发送方归属) */ public final String applyAppCode; + /** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */ + public final String assignJson; } /** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */ @@ -88,6 +90,7 @@ public class FlowInstanceService { instance.setBizUrl(cmd.bizUrl); instance.setBizSnapshot(cmd.bizSnapshot); instance.setFormDataJson(cmd.formDataJson); + instance.setAssignJson(cmd.assignJson); instance.setApplyAppKey(cmd.applyAppKey); instance.setApplyAppCode(cmd.applyAppCode); instance.setSubmitterId(cmd.submitterId); @@ -112,6 +115,7 @@ public class FlowInstanceService { instance.setBizUrl(cmd.bizUrl); instance.setBizSnapshot(cmd.bizSnapshot); instance.setFormDataJson(cmd.formDataJson); + instance.setAssignJson(cmd.assignJson); // 调用方身份快照跟随最新一次 apply 刷新(重启=新一轮代发起,调用方可能已变) instance.setApplyAppKey(cmd.applyAppKey); instance.setApplyAppCode(cmd.applyAppCode); diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java index aa858a1..d342f47 100644 --- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java +++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java @@ -7,6 +7,9 @@ import com.yunhai.flowcenter.entity.FlwCc; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwTask; import com.yunhai.flowcenter.entity.FlwInstance; +import com.yunhai.flowcenter.engine.AssigneeRef; +import com.yunhai.flowcenter.engine.AssigneeResolver; +import com.yunhai.flowcenter.engine.FlowDefModel; import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.model.OapiModels; @@ -77,6 +80,7 @@ public class OapiFlowService { private final FlowInstanceService instanceService; private final FlowTaskService taskService; private final FlowEngine flowEngine; + private final AssigneeResolver assigneeResolver; private final ObjectMapper objectMapper; private final OpenApiUserClient userClient; private final com.yunhai.platform.openapi.base.OpenApiAppClient appClient; @@ -116,6 +120,7 @@ public class OapiFlowService { schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode()) .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程不存在或未发布:" + body.getFlowGuid())); + String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId()); String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim(); FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( schId, def, @@ -123,7 +128,7 @@ public class OapiFlowService { body.getBizUrl(), asSnapshotString(body.getBizSnapshot()), body.getSubmitterId(), body.getSubmitterName(), asFormDataString(body.getFormData()), - cleanAppKey, resolveApplyAppCode(cleanAppKey)); + cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson); FlowInstanceService.ApplyOutcome outcome = instanceService.apply(cmd); OapiModels.ApplyResultVo vo = new OapiModels.ApplyResultVo(); vo.setInstanceId(outcome.getInstanceId()); @@ -155,6 +160,141 @@ public class OapiFlowService { } } + /** + * apply 时指派严格校验(方案 §5.2 五条,不变量 I7)+ 快照渲染。 + * assigns 空 → null(宽松回退 D3)。任何不过 → BizException(PARAM_ERROR) 拒绝发起。 + */ + private String renderAssignJson(Long schId, FlwDef def, List assigns, + Integer submitterId) { + if (assigns == null || assigns.isEmpty()) { + return null; + } + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + Map>> snapshot = new java.util.LinkedHashMap<>(); + Set seenNodeIds = new LinkedHashSet<>(); + for (OapiModels.AssignItem item : assigns) { + if (item.getNodeId() == null || seenNodeIds.contains(item.getNodeId())) { + throw new BizException(ErrorCode.PARAM_ERROR, + "指派 nodeId 缺失或重复:" + item.getNodeId()); + } + seenNodeIds.add(item.getNodeId()); + FlowDefModel.Node node = model.node(item.getNodeId()); + if (node == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "指派节点不存在:" + item.getNodeId()); + } + if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) { + throw new BizException(ErrorCode.PARAM_ERROR, + "节点未开启发起时指派:" + item.getNodeId()); + } + if (item.getUserIds() == null || item.getUserIds().isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "指派人员为空:" + item.getNodeId()); + } + Set uids = new LinkedHashSet<>(item.getUserIds()); + if (uids.contains(submitterId)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "不允许指派发起人本人:" + item.getNodeId()); + } + // 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝 + List candidates = assigneeResolver.resolve(schId, node); + Set candidateIds = new LinkedHashSet<>(); + for (AssigneeRef ref : candidates) { + candidateIds.add(ref.getUserId()); + } + for (Integer uid : uids) { + if (!candidateIds.contains(uid)) { + throw new BizException(ErrorCode.PARAM_ERROR, + "指派人员不在节点候选集内:node=" + item.getNodeId() + " userId=" + uid); + } + } + boolean single = FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode); + int n = uids.size(); + if (single && n != 1) { + throw new BizException(ErrorCode.PARAM_ERROR, + "单选节点须恰指派 1 人:" + item.getNodeId()); + } + if (!single) { + int min = node.assignPolicy.min == null ? 1 : node.assignPolicy.min; + int max = node.assignPolicy.max == null ? candidates.size() : node.assignPolicy.max; + if (n < min || n > max) { + throw new BizException(ErrorCode.PARAM_ERROR, + "指派人数越界[" + min + "~" + max + "]:" + item.getNodeId()); + } + } + List> rows = new ArrayList<>(); + for (Integer uid : uids) { + String userName = assigneeResolver.requireUserName(uid); + Map row = new HashMap<>(); + row.put("userId", uid); + row.put("userName", userName); + rows.add(row); + } + snapshot.put(item.getNodeId(), rows); + } + try { + return objectMapper.writeValueAsString(snapshot); + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "指派快照序列化失败:" + e.getMessage()); + } + } + + /** + * 发起预览(指派候选,方案 §5.1):只返回开启指派节点;candidates 剔除 submitter; + * 节点级解析异常降级空列表(I6)。 + */ + public OapiModels.ApplyPreviewVo applyPreview(Long schId, String flowGuid, Integer submitterId) { + if (schId == null || flowGuid == null || submitterId == null) { + throw new BizException(ErrorCode.PARAM_ERROR, "缺少必要参数:schId/flowGuid/submitterId"); + } + FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( + schId, flowGuid, FlwEnums.DefStatus.PUBLISHED.getCode()) + .orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, + "流程不存在或未发布:" + flowGuid)); + FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); + OapiModels.ApplyPreviewVo vo = new OapiModels.ApplyPreviewVo(); + vo.setFlowGuid(def.getFlowGuid()); + List nodes = new ArrayList<>(); + for (FlowDefModel.Node node : model.nodes) { + if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) { + continue; + } + List candidates; + try { + candidates = assigneeResolver.resolve(schId, node); + } catch (BizException e) { + log.warn("[flow-center] applyPreview 节点候选解析失败,降级空候选:flowGuid={} node={}:{}", + flowGuid, node.id, e.getMessage()); + candidates = new ArrayList<>(); + } + List users = new ArrayList<>(); + for (AssigneeRef ref : candidates) { + if (submitterId.equals(ref.getUserId())) { + continue; + } + OapiModels.PreviewUserVo u = new OapiModels.PreviewUserVo(); + u.setUserId(ref.getUserId()); + u.setUserName(ref.getUserName()); + users.add(u); + } + OapiModels.PreviewNodeVo nv = new OapiModels.PreviewNodeVo(); + nv.setNodeId(node.id); + nv.setNodeName(node.name); + nv.setCountersign(node.countersign == null ? "ALL" : node.countersign); + nv.setCandidates(users); + OapiModels.PreviewSelectionVo selection = new OapiModels.PreviewSelectionVo(); + selection.setMode(FlowDefModel.ASSIGN_MODE_SINGLE.equals(node.assignPolicy.mode) + ? FlowDefModel.ASSIGN_MODE_SINGLE : FlowDefModel.ASSIGN_MODE_MULTI); + selection.setMin(node.assignPolicy.min == null ? 1 : node.assignPolicy.min); + selection.setMax(node.assignPolicy.max == null ? users.size() : node.assignPolicy.max); + selection.setRequired(node.assignPolicy.required == null + ? Boolean.FALSE : node.assignPolicy.required); + nv.setSelection(selection); + nodes.add(nv); + } + vo.setNodes(nodes); + return vo; + } + /** 按业务键查办件(对账兜底;不存在返回 null——对账方按"漂移待清理"处理) */ public OapiModels.InstanceVo instanceByBiz(Long schId, String bizType, String bizId) { FlwInstance instance = instanceRepository.findBySchIdAndBizTypeAndBizId(schId, bizType, bizId) diff --git a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java index d23ff44..3b78325 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java +++ b/src/test/java/com/yunhai/flowcenter/engine/DefValidatorTest.java @@ -527,6 +527,54 @@ class DefValidatorTest { assertTrue(e.getMessage().contains("出口必须命名")); } + // ===== 发起时指派 assignPolicy(2026-10-13) ===== + + @Test + void assign_singleWithWrongCount_fails() { + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}," + + "\"assignPolicy\":{\"mode\":\"SINGLE\",\"min\":2}}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}"; + BizException e = assertThrows(BizException.class, + () -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")))); + assertTrue(e.getMessage().contains("SINGLE")); + } + + @Test + void assign_minGreaterThanMax_fails() { + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}," + + "\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":3,\"max\":2}}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}"; + BizException e = assertThrows(BizException.class, + () -> DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb")))); + assertTrue(e.getMessage().contains("min 不得大于 max")); + } + + @Test + void assign_validMulti_passes() { + String json = "{" + + "\"code\":\"c1\",\"name\":\"流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"ybb\"}," + + "\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":3,\"required\":true}}," + + "{\"id\":\"end\",\"type\":\"END\"}]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]}"; + DefValidator.validate(parse(json), new HashSet<>(Collections.singletonList("ybb"))); + // 无 assignPolicy(存量定义):向前兼容,照常通过 + String legacy = json.replace(",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":3,\"required\":true}", ""); + DefValidator.validate(parse(legacy), new HashSet<>(Collections.singletonList("ybb"))); + } + @Test void overviewSample_def() { // 概览 §5.1 缩样(并行+分支内串行):配齐 6 个 DEPT_KEY 映射应通过 diff --git a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java index fa1b435..dee0ff9 100644 --- a/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java +++ b/src/test/java/com/yunhai/flowcenter/engine/FlowEngineIT.java @@ -98,11 +98,17 @@ class FlowEngineIT extends FlowTestSupport { private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId, Integer submitterId, String submitterName) { + return apply(flowGuid, bizId, submitterId, submitterName, null); + } + + /** 发起时指派用例直灌快照入口:仍走 ApplyCommand 主链(契约校验在 OapiFlowContractIT 覆盖) */ + private FlowInstanceService.ApplyOutcome apply(String flowGuid, String bizId, + Integer submitterId, String submitterName, String assignJson) { FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc( SCH, flowGuid, 1).orElseThrow(IllegalStateException::new); FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand( SCH, def, "svc_item", bizId, "张三的测试办件", "http://biz/1", "{\"k\":\"v\"}", - submitterId, submitterName, null, "100001", "notice"); + submitterId, submitterName, null, "100001", "notice", assignJson); return instanceService.apply(cmd); } @@ -776,6 +782,158 @@ class FlowEngineIT extends FlowTestSupport { assertEquals(2, logs.get(1).getAttempt()); } + // ===== 发起时指派(2026-10-13):快照收窄/宽松回退/退回存活/R4 覆盖 ===== + + /** 三人 USERS 或签节点(USERS 直配不经科室映射,指派收窄用例共用) */ + private String usersAnyDef(String nodeName) { + return "{" + + "\"code\":\"t25\",\"name\":\"USERS或签测试流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"" + nodeName + "\"," + + "\"assignee\":{\"type\":\"USERS\",\"userIds\":[101,102,103]},\"countersign\":\"ANY\"}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + /** 或签 3 候选指派 1 人:只为被指派人建任务,该人办理即完成(收窄不改变 ANY 放行语义,I1) */ + @Test + void assign_singleNarrows_anySign() { + FlwDef def = saveAndPublishDef("t26", "指派单收窄", usersAnyDef("任一人审批")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-26", 201, "张三", + "{\"n1\":[{\"userId\":101,\"userName\":\"甲\"}]}"); + Long instanceId = outcome.getInstanceId(); + assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); + assertTrue(tasksOf(instanceId, 102).isEmpty(), "未被指派人不建任务"); + assertTrue(tasksOf(instanceId, 103).isEmpty(), "未被指派人不建任务"); + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + } + + /** 或签 3 候选指派 2 人:任一人办理即放行,另一条失效,办件完成 */ + @Test + void assign_twoNarrows_anySign_othersInvalidated() { + FlwDef def = saveAndPublishDef("t27", "指派双收窄", usersAnyDef("任一人审批")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-27", 201, "张三", + "{\"n1\":[{\"userId\":101,\"userName\":\"甲\"},{\"userId\":102,\"userName\":\"乙\"}]}"); + Long instanceId = outcome.getInstanceId(); + assertEquals(2, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "我先批", 101); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + assertEquals(FlwEnums.TaskStatus.INVALIDATED.getCode(), + allTasks(instanceId).stream().filter(t -> t.getAssigneeId().equals(102)) + .findFirst().get().getStatus()); + } + + /** 会签 3 候选指派 2 人:收窄=指派人全签(2 条待办),全部同意才完成 */ + @Test + void assign_allNarrows_countersignAll() { + mapDept("ky", 101, "甲"); + mapDept("ky", 102, "乙"); + mapDept("ky", 103, "丙"); + FlwDef def = saveAndPublishDef("t28", "指派会签", linearDef("ky", "联合审核")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-28", 201, "张三", + "{\"n1\":[{\"userId\":101,\"userName\":\"甲\"},{\"userId\":102,\"userName\":\"乙\"}]}"); + Long instanceId = outcome.getInstanceId(); + List pending = taskRepository.findByInstanceIdAndStatus(instanceId, 0); + assertEquals(2, pending.size()); + // 第一人同意:会签未清零仍在途 + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); + assertEquals(FlwEnums.InstanceStatus.APPROVING.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + // 第二人同意 → 完成 + taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + } + + /** assignJson 缺失 / 快照条目为空数组:均回退全量候选(宽松回退 D3) */ + @Test + void assign_missingOrEmpty_fallsBackToFull() { + FlwDef def = saveAndPublishDef("t29", "无指派回退", usersAnyDef("任一人审批")); + FlowInstanceService.ApplyOutcome a = apply(def.getFlowGuid(), "biz-29a", 201, "张三", null); + assertEquals(3, taskRepository.findByInstanceIdAndStatus(a.getInstanceId(), 0).size()); + FlowInstanceService.ApplyOutcome b = apply(def.getFlowGuid(), "biz-29b", 201, "张三", "{\"n1\":[]}"); + assertEquals(3, taskRepository.findByInstanceIdAndStatus(b.getInstanceId(), 0).size()); + } + + /** 脏 assignJson:log warn 后回退全量(引擎永不因缺指派拒绝或挂起) */ + @Test + void assign_dirtyJson_fallsBack() { + FlwDef def = saveAndPublishDef("t30", "脏快照回退", usersAnyDef("任一人审批")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-30", 201, "张三", "not-json"); + assertEquals(3, taskRepository.findByInstanceIdAndStatus(outcome.getInstanceId(), 0).size()); + } + + /** 退回重入:快照沿用,重入节点仍只发原指派人(I5) */ + @Test + void assign_survivesReturnReentry() { + mapDept("km", 103, "主"); + mapDept("km", 104, "副"); + mapDept("ka", 101, "甲"); + mapDept("kb", 102, "乙"); + FlwDef def = saveAndPublishDef("t31", "退回指派存活", mainlineParallelTailDef("km", "km")); + FlowInstanceService.ApplyOutcome outcome = apply(def.getFlowGuid(), "biz-31", 201, "张三", + "{\"n3\":[{\"userId\":103,\"userName\":\"主\"}]}"); + Long instanceId = outcome.getInstanceId(); + // 推进到 n3:n1 会签(主+副)→ 并行甲乙 → 出口复核只发给被指派的"主" + taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "ok", 103); + taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104); + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); + taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); + FlwTask tail = tasksOf(instanceId, 103).get(0); + assertEquals("n3", tail.getNodeId()); + assertTrue(tasksOf(instanceId, 104).isEmpty(), "n3 只给被指派人建任务"); + // 退回至入口 n1 重走 + taskService.returnBack(SCH, tail.getId(), null, "重新审一遍", 103); + assertEquals("n1", instanceRepository.findById(instanceId).get().getCurrentNodes()); + taskService.handle(SCH, tasksOf(instanceId, 103).get(0).getId(), true, "再入口", 103); + taskService.handle(SCH, tasksOf(instanceId, 104).get(0).getId(), true, "ok", 104); + taskService.handle(SCH, tasksOf(instanceId, 101).get(0).getId(), true, "ok", 101); + taskService.handle(SCH, tasksOf(instanceId, 102).get(0).getId(), true, "ok", 102); + // 重入 n3:快照沿用,仍只发原指派人 + FlwTask tailAgain = tasksOf(instanceId, 103).get(0); + assertEquals("n3", tailAgain.getNodeId()); + assertTrue(tasksOf(instanceId, 104).isEmpty()); + taskService.handle(SCH, tailAgain.getId(), true, "复核通过", 103); + assertEquals(FlwEnums.InstanceStatus.COMPLETED.getCode(), + instanceRepository.findById(instanceId).get().getStatus()); + } + + /** R4 重启:带新指派覆盖快照;不带 assigns(null=清空)回全量(覆盖语义 I5) */ + @Test + void assign_r4RestartOverwrites() { + mapDept("ky", 101, "甲"); + mapDept("ky", 102, "乙"); + mapDept("ky", 103, "丙"); + FlwDef def = saveAndPublishDef("t32", "R4指派覆盖", linearDef("ky", "联合审核")); + // 首轮:指派 101 → 单待办;拒绝到 30 + FlowInstanceService.ApplyOutcome first = apply(def.getFlowGuid(), "biz-32", 201, "张三", + "{\"n1\":[{\"userId\":101,\"userName\":\"甲\"}]}"); + assertEquals(1, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size()); + taskService.handle(SCH, tasksOf(first.getInstanceId(), 101).get(0).getId(), false, "不合格", 101); + assertEquals(FlwEnums.InstanceStatus.REJECTED.getCode(), + instanceRepository.findById(first.getInstanceId()).get().getStatus()); + // R4 重提带新指派 → 快照覆盖为 102 子集 + FlowInstanceService.ApplyOutcome second = apply(def.getFlowGuid(), "biz-32", 201, "张三", + "{\"n1\":[{\"userId\":102,\"userName\":\"乙\"}]}"); + assertTrue(second.isDuplicated()); + assertEquals(first.getInstanceId(), second.getInstanceId()); + List pending = taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0); + assertEquals(1, pending.size()); + assertEquals(102, pending.get(0).getAssigneeId()); + // 再拒后重提不带指派(置空)→ 全量 3 条 + taskService.handle(SCH, pending.get(0).getId(), false, "仍不合格", 102); + FlowInstanceService.ApplyOutcome third = apply(def.getFlowGuid(), "biz-32", 201, "张三", null); + assertTrue(third.isDuplicated()); + assertEquals(3, taskRepository.findByInstanceIdAndStatus(first.getInstanceId(), 0).size()); + assertNull(instanceRepository.findById(first.getInstanceId()).get().getAssignJson()); + } + private List branchRows(Long instanceId) { return branchRepository.findByInstanceId(instanceId); } diff --git a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java index 4ee2adb..2628057 100644 --- a/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java +++ b/src/test/java/com/yunhai/flowcenter/oapi/OapiFlowContractIT.java @@ -584,6 +584,131 @@ class OapiFlowContractIT extends FlowTestSupport { assertEquals(1, def.getVersion().intValue(), "引用自注册行的 SERVICE 定义应发布成功"); } + // ===== 发起时指派契约(2026-10-13):apply/preview + apply assigns 严格校验 ===== + + /** 指派契约用例定义:START → n1(可配 assignPolicy)→ n2(不开指派)→ END,两节点同科室 */ + private String assignDef(String deptTag, String n1Policy) { + return "{" + + "\"code\":\"t40\",\"name\":\"指派契约流程\",\"start\":\"start\"," + + "\"nodes\":[" + + "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"}," + + "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"首审\"," + + "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(deptTag) + "\"}," + + "\"countersign\":\"ANY\"" + n1Policy + "}," + + "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复审\"," + + "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(deptTag) + "\"}}," + + "{\"id\":\"end\",\"type\":\"END\"}" + + "]," + + "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"}," + + "{\"from\":\"n2\",\"to\":\"end\"}]," + + "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}"; + } + + /** 预览契约:只暴露开启指派节点;candidates 带姓名且剔除提交人;selection 缺省补齐;countersign 透传 */ + @Test + void applyPreview_exposesOnlyDesignatableNodes() { + mapDept("ybb", 101, "甲"); + mapDept("ybb", 201, "张三"); + FlwDef def = saveAndPublishDef("t41", "指派预览", + assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1}")); + ResponseEntity resp = client.get("/oapi/flow/apply/preview?schId=1&flowGuid=" + + def.getFlowGuid() + "&submitterId=201", JsonNode.class); + assertEquals(200, resp.getStatusCodeValue()); + assertEquals(0, resp.getBody().get("code").asInt()); + JsonNode data = unwrap(resp.getBody()); + assertEquals(def.getFlowGuid(), data.get("flowGuid").asText()); + JsonNode nodes = data.get("nodes"); + assertEquals(1, nodes.size(), "只暴露开启指派的节点(n2 未开不透出)"); + JsonNode n1 = nodes.get(0); + assertEquals("n1", n1.get("nodeId").asText()); + assertEquals("首审", n1.get("nodeName").asText()); + assertEquals("ANY", n1.get("countersign").asText(), "countersign 透传"); + JsonNode selection = n1.get("selection"); + assertEquals("MULTI", selection.get("mode").asText()); + assertEquals(1, selection.get("min").asInt()); + assertEquals(1, selection.get("max").asInt(), "max 缺省=剔除提交人后的候选数"); + assertFalse(selection.get("required").asBoolean(), "required 缺省补 false"); + JsonNode candidates = n1.get("candidates"); + assertEquals(1, candidates.size(), "候选剔除提交人本人"); + assertEquals(101, candidates.get(0).get("userId").asInt()); + assertEquals("甲", candidates.get(0).get("userName").asText()); + } + + /** 预览降级(I6):开启节点挂无映射科室 → 该节点 candidates=[],接口照常 200 */ + @Test + void applyPreview_deadDept_degradesToEmptyCandidates() { + mapDept("ybb", 101, "甲"); + FlwDef def = saveAndPublishDef("t41b", "指派预览降级", + assignDef("nobody", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}")); + ResponseEntity resp = client.get("/oapi/flow/apply/preview?schId=1&flowGuid=" + + def.getFlowGuid() + "&submitterId=201", JsonNode.class); + assertEquals(200, resp.getStatusCodeValue()); + assertEquals(0, resp.getBody().get("code").asInt()); + JsonNode n1 = unwrap(resp.getBody()).get("nodes").get(0); + assertEquals("n1", n1.get("nodeId").asText()); + assertEquals(0, n1.get("candidates").size(), "解析异常节点降级空候选"); + } + + /** 合法 assigns:200 + 快照落库(姓名走 requireUserName 严格口径)+ 只为被指派人建待办 */ + @Test + void apply_withAssigns_ok() throws Exception { + mapDept("ybb", 101, "甲"); + mapDept("ybb", 102, "乙"); + FlwDef def = saveAndPublishDef("t42", "指派发起", + assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}")); + String body = "{" + + "\"flowGuid\":\"" + def.getFlowGuid() + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"apply-as1\",\"bizTitle\":\"指派件\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"," + + "\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101]}]}"; + ResponseEntity resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); + assertEquals(0, resp.getBody().get("code").asInt()); + Long instanceId = unwrap(resp.getBody()).get("instanceId").asLong(); + JsonNode snap = objectMapper.readTree( + instanceRepository.findById(instanceId).get().getAssignJson()); + assertEquals(101, snap.get("n1").get(0).get("userId").asInt()); + assertEquals("用户101", snap.get("n1").get(0).get("userName").asText(), "姓名=UserBase 严格口径"); + assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size()); + assertEquals(101, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getAssigneeId()); + } + + /** 拒绝矩阵(I7/I3):六种非法 assigns 均业务码拒绝且办件未创建 */ + @Test + void apply_assigns_rejected_matrix() { + mapDept("ybb", 101, "甲"); + mapDept("ybb", 102, "乙"); + FlwDef def = saveAndPublishDef("t43", "指派拒绝矩阵", + assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":1}")); + FlwDef singleDef = saveAndPublishDef("t44", "单选拒绝", + assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"SINGLE\"}")); + + assertAssignRejected(def.getFlowGuid(), "as-x1", ",\"assigns\":[{\"nodeId\":\"n2\",\"userIds\":[101]}]}", + "未开启指派节点应拒绝"); + assertAssignRejected(def.getFlowGuid(), "as-x2", ",\"assigns\":[{\"nodeId\":\"n9\",\"userIds\":[101]}]}", + "nodeId 不存在应拒绝"); + assertAssignRejected(def.getFlowGuid(), "as-x3", ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[999]}]}", + "越候选集应拒绝"); + assertAssignRejected(def.getFlowGuid(), "as-x4", ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[201]}]}", + "指派发起人本人应拒绝"); + assertAssignRejected(singleDef.getFlowGuid(), "as-x5", + ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101,102]}]}", "SINGLE 传 2 人应拒绝"); + assertAssignRejected(def.getFlowGuid(), "as-x6", + ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101,102]}]}", "MULTI 超 max 应拒绝"); + } + + /** 断言一次带 assigns 的 apply 被业务码拒绝且办件未创建 */ + private void assertAssignRejected(String flowGuid, String bizId, String assignsSegment, String message) { + String body = "{" + + "\"flowGuid\":\"" + flowGuid + "\"," + + "\"bizType\":\"svc_item\",\"bizId\":\"" + bizId + "\",\"bizTitle\":\"矩阵\"," + + "\"submitterId\":201,\"submitterName\":\"张三\"" + assignsSegment; + ResponseEntity resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class); + assertEquals(200, resp.getStatusCodeValue()); + assertNotEquals(0, resp.getBody().get("code").asInt(), message); + assertFalse(instanceRepository.findBySchIdAndBizTypeAndBizId(1L, "svc_item", bizId).isPresent(), + message + "(办件未创建)"); + } + /** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */ private boolean awaitPayloadContains(List payloads, String needle) throws InterruptedException { for (int i = 0; i < 25; i++) {