feat: 发起时指派——assignPolicy 配置/assign_json 快照/引擎收窄/preview+apply 契约
build / build-push (push) Successful in 2m8s
build / build-push (push) Successful in 2m8s
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user