feat: 发起时指派——assignPolicy 配置/assign_json 快照/引擎收窄/preview+apply 契约
build / build-push (push) Successful in 2m8s

This commit is contained in:
chenxing
2026-10-13 12:05:11 +08:00
parent c02bdc5eca
commit fd4e207ce0
13 changed files with 607 additions and 6 deletions
@@ -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,
@@ -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<String, java.util.List<AssigneeRef>> parse(String assignJson) {
if (assignJson == null || assignJson.trim().isEmpty()) {
return java.util.Collections.emptyMap();
}
try {
java.util.Map<String, java.util.List<AssigneeRef>> result = MAPPER.readValue(assignJson,
new com.fasterxml.jackson.core.type.TypeReference<java.util.Map<String, java.util.List<AssigneeRef>>>() {});
return result == null ? java.util.Collections.emptyMap() : result;
} catch (Exception e) {
log.warn("[flow-center] assign_json 解析失败,按无指派回退全量候选:{}", e.getMessage());
return java.util.Collections.emptyMap();
}
}
}
@@ -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;
@@ -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<String> visited,
@@ -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<Integer> 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;
@@ -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<AssigneeRef> assignees = assigneeResolver.resolve(instance.getSchId(), node);
List<AssigneeRef> designated = AssignDesignations.parse(instance.getAssignJson()).get(node.id);
List<AssigneeRef> 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
@@ -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;
@@ -40,6 +40,48 @@ public final class OapiModels {
private String submitterName;
/** 表单数据对象(业务方编辑页产出,无表单模式不传) */
private Map<String, Object> formData;
/** 发起时指派(可选,2026-10-13):[{nodeId, userIds}],校验失败业务码拒绝发起 */
private List<AssignItem> assigns;
}
/** 发起时指派项(apply 可选字段) */
@Data
public static class AssignItem {
private String nodeId;
private List<Integer> userIds;
}
/** 发起预览响应(GET /oapi/flow/apply/preview):只含开启指派的节点 */
@Data
public static class ApplyPreviewVo {
private String flowGuid;
private List<PreviewNodeVo> nodes;
}
@Data
public static class PreviewNodeVo {
private String nodeId;
private String nodeName;
/** ANY|ALL(后端缺省补 ALL) */
private String countersign;
private PreviewSelectionVo selection;
private List<PreviewUserVo> 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 返回既有办件) */
@@ -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);
@@ -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<OapiModels.AssignItem> assigns,
Integer submitterId) {
if (assigns == null || assigns.isEmpty()) {
return null;
}
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
Map<String, List<Map<String, Object>>> snapshot = new java.util.LinkedHashMap<>();
Set<String> 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<Integer> uids = new LinkedHashSet<>(item.getUserIds());
if (uids.contains(submitterId)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"不允许指派发起人本人:" + item.getNodeId());
}
// 候选集取 apply 时点全量(不剔除发起人,存量口径);越集=契约破坏,拒绝
List<AssigneeRef> candidates = assigneeResolver.resolve(schId, node);
Set<Integer> 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<Map<String, Object>> rows = new ArrayList<>();
for (Integer uid : uids) {
String userName = assigneeResolver.requireUserName(uid);
Map<String, Object> 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<OapiModels.PreviewNodeVo> nodes = new ArrayList<>();
for (FlowDefModel.Node node : model.nodes) {
if (!FlowDefModel.TYPE_APPROVAL.equals(node.type) || node.assignPolicy == null) {
continue;
}
List<AssigneeRef> 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<OapiModels.PreviewUserVo> 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)
@@ -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 映射应通过
@@ -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<FlwTask> 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<FlwTask> 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<FlwBranch> branchRows(Long instanceId) {
return branchRepository.findByInstanceId(instanceId);
}
@@ -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<JsonNode> 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<JsonNode> 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<JsonNode> 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<JsonNode> 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<String> payloads, String needle) throws InterruptedException {
for (int i = 0; i < 25; i++) {