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
@@ -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)