Files
flow-center-service/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
T
chenxing d72128672f
build / build-push (push) Failing after 55s
feat(fl2): 落地部门目录 / 退回重提(D14) / 授权管理后端(基线 origin/main)
在 b369527 之上增量移植 FL2.0 后端能力,不改既有线级停发/指派预览/流程图入口:

- 部门目录:FlowDepartmentController(/admin/department/{groups,options,resolve,members})
  + UserBaseDepartmentDirectory(先按会话校域解析引用,再读成员;目录不可用 fail-closed)
- 退回重提 D14:POST /oapi/flow/instance/{instanceId}/resubmit,仅原提交人 CAS 路径放行,
  OAPI 侧 submitterId 路径继续 fail-closed
- 授权管理:/api/flow/admin/usage-grant/{schoolList,summary}(校域取管理员会话,无 schId 入参)
- 来源主体:解析器派生 ub-app:{applicationInfo.id},不再读取 UserBase 的 sourcePrincipalId 字段
- 回调来源策略:SELF 身份强校验;NULL 来源不广播;复合幂等键 (event_id,reg_id)
- 测试夹具:许可/主体/开通桩按新契约修正,FlowEngineIT/FormalApplyCommandIT/
  CallbackDispatchIT/OapiFlowContractIT 共 117 例在真实 MySQL 8 通过

不含:FlSourceAuthorizationTest(其断言的两个 Repository 派生方法属另一实现线)
2026-10-30 10:15:12 +08:00

641 lines
35 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package com.yunhai.flowcenter.service;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.engine.FlowEvent;
import com.yunhai.flowcenter.entity.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.repository.FlwBranchRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
import com.yunhai.flowcenter.repository.FlwTaskRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例,
* 命令受理、坐标锁和轮次分配归 FormalApplyCommandService;下述 R4 复用仅描述旧入口。
*
* <p><b>R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿)</b>:biz uk 命中已终态办件
* (30 已拒绝 / 40 已撤回)时复用同一办件行"重启推进"——不新建、不报错:定义刷新为当前
* 已发布版本、提交快照全量覆盖、旧分支行清空、残留待办失效、status 回 10、提交时间刷新、
* version 自增(镜像方按 instanceVersion 单调推进到 10)。任务历史保留(1/2/3 状态行不动),
* 轨迹跨轮连续。status=10/20(在途/已完成)命中则纯幂等返回原办件(duplicated=true)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FlowInstanceService {
/** 旧 OAPI 入口的坐标槽位(正式命令走 NULL 槽位,两条分区互不复用)。 */
private static final Integer LEGACY_SLOT = 1;
private final FlwInstanceRepository instanceRepository;
private final FlwDefRepository defRepository;
private final FlwTaskRepository taskRepository;
private final FlwBranchRepository branchRepository;
private final FlowEngine flowEngine;
private final ApplicationEventPublisher eventPublisher;
@org.springframework.beans.factory.annotation.Autowired
private com.yunhai.flowcenter.repository.FlwCommandSourceRepository commandSources;
@org.springframework.beans.factory.annotation.Autowired
private FlowUsageGrantGuard creationGrants;
@javax.persistence.PersistenceContext
private javax.persistence.EntityManager entityManager;
/** 代发起申请体(oapi apply 与未来原生发起共用字段) */
@RequiredArgsConstructor
public static class ApplyCommand {
public final Long schId;
public final FlwDef def;
public final String bizType;
public final String bizId;
public final String bizTitle;
public final String bizUrl;
public final String bizSnapshot;
public final Integer submitterId;
public final String submitterName;
public final String formDataJson;
/** 调用方验签身份快照(yh.app.auth;本地/无签名场景为 null) */
public final String applyAppKey;
/** 调用方 app_code 快照(by-key 一次换得;UserBase 不可达为 null→消息回落发送方归属) */
public final String applyAppCode;
/** 发起时指派快照 JSON(oapi apply 校验后渲染;null=无指派) */
public final String assignJson;
/**
* 受理载荷指纹(2026-10-25 T04,SHA-256 hex;<b>非构造参数</b>——13 参构造被
* {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;null=存量调用方,
* 指纹核对缺省关)。
*/
public String payloadHash;
/**
* 可信来源主体(D11/F02,2026-10-29;同样<b>非构造参数</b>,理由同上)。
* 由服务端从验签 appKey 解析,绝不接收客户端自报值。业务坐标的幂等复用、重启与
* 按业务键回读都以它为分区维度;为空即拒(缺失来源不得静默补全)。
*/
public String sourcePrincipalId;
}
/** 代发起结果值对象:instanceId + duplicated(命中既有办件=true,含重启重提) */
@Transactional
public ApplyOutcome apply(ApplyCommand cmd) {
FlwInstance existing = requireSourceScopedLookup(cmd);
if (existing != null) {
requireSameOwner(existing, cmd);
int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()
|| status == FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) {
return new ApplyOutcome(existing.getId(), true);
}
// 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START)
restart(existing, cmd);
return new ApplyOutcome(existing.getId(), true);
}
requireLegacyCreationGrant(cmd);
FlwInstance instance = new FlwInstance();
instance.setSourcePrincipalId(cmd.sourcePrincipalId);
instance.setLegacySlot(LEGACY_SLOT);
instance.setSchId(cmd.schId);
instance.setDefId(cmd.def.getId());
instance.setDefCode(cmd.def.getCode());
instance.setDefVersion(cmd.def.getVersion());
instance.setBizType(cmd.bizType);
instance.setBizId(cmd.bizId);
instance.setBizTitle(cmd.bizTitle);
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);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
return new ApplyOutcome(instance.getId(), false);
}
private void restart(FlwInstance instance, ApplyCommand cmd) {
restart(instance, cmd, null, null);
}
/**
* R4 重启推进(语义不变:定义刷新、快照覆盖、旧分支清空、status 回 10、version 自增、轨迹跨轮连续)。
* 新增的两个参数只是<b>随行落库的请求标识与轮次</b>,不改任何节点算法(任务书 C.3 红线:
* 不为修复幂等而重写流程节点算法)。旧 apply 路径传 null ⇒ 行为与历史逐字一致。
*/
private void restart(FlwInstance instance, ApplyCommand cmd, String requestToken, Integer round) {
requireLegacyCreationGrant(cmd);
log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}",
instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId);
instance.setDefId(cmd.def.getId());
instance.setDefCode(cmd.def.getCode());
instance.setDefVersion(cmd.def.getVersion());
instance.setBizTitle(cmd.bizTitle);
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);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
// 新一轮才更新请求标识与轮次;同标识重投走不到这里(applyGuarded 已分支挡掉)
if (requestToken != null) {
instance.setApplyRequestToken(requestToken);
}
if (round != null) {
instance.setApplyRound(round);
}
if (cmd.payloadHash != null) {
// 新一轮命令重新取指纹(新轮=新命令,指纹跟随最新受理载荷)
instance.setApplyPayloadHash(cmd.payloadHash);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null);
instanceRepository.saveAndFlush(instance);
taskRepository.invalidatePendingByInstance(instance.getId());
branchRepository.deleteByInstance(instance.getId());
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_RESUBMIT, "重新提交",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
/**
* D14 发起人重提(2026-10-29):<b>同一实例、同一申报轮次</b>从第一级重新审批。
*
* <p>与"终态后重新申报"({@link #apply} 的 R4 重启,会刷新定义版本并留 重新提交 轨迹)
* 和"申请人撤回"完全区分:本方法只接受 {@code 50 退回待修改} 的办件,不改 defId/defVersion
* (D04/D05 在途实例固定发起时版本),不自增 applyRound,不删任何历史任务/意见/事件行。</p>
*
* <p>并发重提使用数据库行锁并刷新已加载实体;状态 10 还必须有最新 D14 提交留痕,
* 才可作为回执恢复。普通审批中与旧 R4 留痕都不构成 D14 重提成功证明。</p>
*
* @param formDataJson 业务应用可选回传的更新后表单快照(null=沿用原快照,FL 不做字段 Diff)
* @return 重提后的实例 id(幂等:已重提时同样返回原实例)
*/
@Transactional
public Long resubmitAfterReturn(Long schId, Long instanceId, Integer submitterId, String formDataJson) {
FlwInstance instance = instanceRepository.findFormalPreviousForUpdate(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在"));
// A controller may already have loaded this row into the request's persistence context.
// Refresh under the database lock so a waiting request cannot act on its stale status=50.
if (entityManager != null) entityManager.refresh(instance, javax.persistence.LockModeType.PESSIMISTIC_WRITE);
if (!instance.getSchId().equals(schId)) throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
if (instance.getSubmitterId() == null || !instance.getSubmitterId().equals(submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "仅发起人本人可重提");
}
if (instance.getStatus() == FlwEnums.InstanceStatus.APPROVING.getCode()
&& taskRepository.findFirstByInstanceIdAndStatusOrderByIdDesc(instanceId,
FlwEnums.TaskStatus.SUBMITTED.getCode())
.filter(t -> FlwEnums.TaskStatus.NODE_RESUBMIT.equals(t.getNodeId())
&& "修改后重新提交".equals(t.getNodeName())).isPresent()
&& (formDataJson == null || formDataJson.equals(instance.getFormDataJson()))) {
// 重复点击/响应丢失后的重试:已有一路重提成功,回原实例而不是再启一代任务
log.info("[flow-center] D14 重提幂等命中(已重提):instance={}", instanceId);
return instance.getId();
}
if (instance.getStatus() != FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "办件不处于退回待修改状态,不能重提");
}
FlwDef def = requireDef(instance.getDefId());
if (formDataJson != null && !formDataJson.trim().isEmpty()) {
instance.setFormDataJson(formDataJson);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setCurrentNodes("");
instance.setSubmitTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_RESUBMIT, "修改后重新提交",
FlwEnums.TaskStatus.SUBMITTED, instance.getSubmitterId(), instance.getSubmitterName());
// 从 START 重新推进=第一级审批节点重建一代新任务;旧任务行仍是 1/2/4/3,与新任务可区分
flowEngine.startAdvance(instance, def);
publishSubmitted(instance);
return instance.getId();
}
/**
* 撤回(概览 §5.3):仅当尚无任何任务被办理时允许;全部待办失效、分支失效、办件=40。
*/
@Transactional
public void withdraw(Long schId, Long instanceId) {
FlwInstance instance = requireInstance(schId, instanceId);
if (!FlwEnums.InstanceStatus.inFlightCodes().contains(instance.getStatus())) {
throw new BizException(ErrorCode.PARAM_ERROR, "仅在途办件可撤回(审批中或退回待修改)");
}
if (!taskRepository.findByInstanceIdAndStatusIn(instanceId,
FlwEnums.TaskStatus.handledCodes()).isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已有任务被办理,不能撤回");
}
instance.setStatus(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
instance.setFinishTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
taskRepository.invalidatePendingByInstance(instanceId);
branchRepository.invalidateRunningByInstance(instanceId);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_WITHDRAW, "撤回申请",
FlwEnums.TaskStatus.WITHDRAWN, instance.getSubmitterId(), instance.getSubmitterName());
Map<String, Object> data = new HashMap<>();
data.put("result", "WITHDRAWN");
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.WITHDRAWN, instanceId, null,
instance.getSubmitterId(), instance.getSubmitterName(), data));
}
/**
* 轨迹留痕事件行(2026-09-18):办件级动作(发起/重新提交、撤回)也落一条 flw_task 行,
* 让流程轨迹的时间线里这些动作本身可见(原实现只把待办批量置失效,撤回在轨迹上是空白)。
*
* <p>行 status=6/7 且 node_id 用保留值(submit/resubmit/withdraw,定义模型里不存在)——
* 不参与引擎任何判断:不进待办(非 0)、不进已办(handledCodes 不含 6/7)、不挡撤回守卫、
* 不进超期扫描与分支失效范围、示意图着色与退回目标解析也天然跳过。
*/
private void appendTraceEvent(FlwInstance instance, String nodeId, String nodeName,
FlwEnums.TaskStatus status, Integer operatorId, String operatorName) {
LocalDateTime now = LocalDateTime.now();
com.yunhai.flowcenter.entity.FlwTask row = new com.yunhai.flowcenter.entity.FlwTask();
row.setInstanceId(instance.getId());
row.setSchId(instance.getSchId());
row.setNodeId(nodeId);
row.setNodeName(nodeName);
// assignee_id 非空:发起/撤回的操作人恒为提交人;缺失(脏数据)时落 0,不产生僵尸待办
row.setAssigneeId(operatorId == null ? 0 : operatorId);
row.setAssigneeName(operatorName);
row.setStatus(status.getCode());
row.setArriveTime(now);
row.setHandleTime(now);
row.setUrgeCount(0);
taskRepository.save(row);
}
public FlwInstance requireInstance(Long schId, Long instanceId) {
FlwInstance instance = instanceRepository.findById(instanceId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "办件不存在:" + instanceId));
if (!instance.getSchId().equals(schId)) {
throw new BizException(ErrorCode.FORBIDDEN, "办件不属于该学校");
}
return instance;
}
public FlwDef requireDef(Long defId) {
return defRepository.findById(defId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "流程定义不存在:" + defId));
}
/**
* 催办(概览 §5.3):提交人/管理员对办件的全部待办任务各催一次(URGE 事件 → UserMsg 通道,
* msgNo=urge:{taskId}:{N} 幂等);次数上限取定义 settings.urge.maxTimes(默认 3;
* enabled=false → 0 = 不限次)。非提交人调用须是本校管理员(控制器已按 BoaUser 判定放行)。
*/
@Transactional
public void urgeInstance(Long schId, Long instanceId, Integer operatorId) {
FlwInstance instance = requireInstance(schId, instanceId);
FlowDefModel model = flowEngine.parseModel(requireDef(instance.getDefId()));
int maxTimes = urgeMaxTimes(model);
java.util.List<com.yunhai.flowcenter.entity.FlwTask> pendings =
taskRepository.findByInstanceIdAndStatus(instanceId,
com.yunhai.flowcenter.enums.FlwEnums.TaskStatus.PENDING.getCode());
if (pendings.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "无待办任务可催办");
}
for (com.yunhai.flowcenter.entity.FlwTask task : pendings) {
if (maxTimes > 0 && task.getUrgeCount() >= maxTimes) {
continue;
}
task.setUrgeCount(task.getUrgeCount() + 1);
taskRepository.save(task);
Map<String, Object> data = new HashMap<>();
data.put("taskId", task.getId());
data.put("urgeCount", task.getUrgeCount());
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.URGE,
instanceId, task.getId(), data));
}
}
private static int urgeMaxTimes(FlowDefModel model) {
if (model == null || model.settings == null || model.settings.urge == null) {
return 3;
}
FlowDefModel.Urge urge = model.settings.urge;
if (Boolean.FALSE.equals(urge.enabled)) {
return 0;
}
return urge.maxTimes == null || urge.maxTimes <= 0 ? 3 : urge.maxTimes;
}
private void publishSubmitted(FlwInstance instance) {
FlowDefModel model = flowEngine.parseModel(requireDef(instance.getDefId()));
Map<String, Object> data = new HashMap<>();
data.put("nodeSummary", FlowEngine.nodeSummary(model));
eventPublisher.publishEvent(FlowEvent.of(FlowEvent.Type.SUBMITTED, instance.getId(), null,
instance.getSubmitterId(), instance.getSubmitterName(), data));
}
/** 代发起结果值对象 */
@SuppressWarnings("unused")
public static class ApplyOutcome {
private final Long instanceId;
private final boolean duplicated;
public ApplyOutcome(Long instanceId, boolean duplicated) {
this.instanceId = instanceId;
this.duplicated = duplicated;
}
public Long getInstanceId() {
return instanceId;
}
public boolean isDuplicated() {
return duplicated;
}
}
// ==================== 请求标识与轮次守卫(v2 §8,TASK_2 C.3) ====================
/**
* 守卫参数。<b>刻意不做成 {@code ApplyCommand} 的新字段</b>:那个 13 参构造被基线集成测试
* {@code FlowEngineIT} 逐位钉住,加参数会让整棵测试树编译失败(同 svc-hall 的 setter 注入先例)。
* 旧 {@link #apply(ApplyCommand)} 路径行为逐字不变,只有 {@link #applyGuarded} 吃这个策略。
*/
@RequiredArgsConstructor
public static class ApplyRetryPolicy {
public final String requestToken;
public final Integer attempt;
public final boolean retryOnly;
/**
* 期望的前一轮次(2026-10-25 T04 服务端轮次收口,可选):显式申报"我基于第 N 轮重提"。
* 非空时:行轮次 ≠ N ⇒ STALE_ROUND(不重启);通过则服务端分配 round=N+1,
* <b>不信客户端 attempt</b>。null=既有客户端 attempt 语义(存量兼容)。
*/
public final Integer expectedPrevRound;
public ApplyRetryPolicy(String requestToken, Integer attempt, boolean retryOnly) {
this(requestToken, attempt, retryOnly, null);
}
}
/** 守卫化结果:除 instanceId/duplicated 外,把"命中的是哪一种情形"落成稳定分支码 */
@RequiredArgsConstructor
public static class GuardedOutcome {
public final Long instanceId;
public final boolean duplicated;
public final String branch;
public final Integer applyRound;
}
/**
* 带守卫的代发起(新 {@code /oapi/flow/apply/v2} 唯一入口)。
*
* <p>判定顺序(每一支都只回读现状,不改流程状态机一行算法):</p>
* <ol>
* <li><b>先核归属</b>:行上的调用应用/提交者/流程线与本次请求不符 ⇒ 拒绝,
* <b>不能把别的应用相同 bizType 键当自己的结果</b>返回(C.3)。
* 应用身份两侧都非空才判(本地无签名的历史行为 null,不因此把存量件判死);</li>
* <li>同 {@code requestToken} 命中 ⇒ {@code SAME_REQUEST}:这是同一次命令的重投,
* 即使行已终态也<b>不重启</b>——那正是"响应丢失后重发"与"用户重提"的分界;</li>
* <li>10/20 命中 ⇒ {@code IDEMPOTENT}(既有语义不变);</li>
* <li>30/40 命中:{@code retryOnly=true} ⇒ {@code REPLAY_ONLY} 不重启;
* 请求轮次 ≤ 行轮次 ⇒ {@code STALE_ROUND} 不重启;
* 否则才是新一轮授权重提 ⇒ {@code RESTARTED}(走既有 R4 重启推进);</li>
* <li>无行 ⇒ 新建 + {@code CREATED}。</li>
* </ol>
*/
@Transactional
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
FlwInstance existing = requireSourceScopedLookup(cmd);
if (existing != null) {
return settleExisting(existing, cmd, policy);
}
String token = policy == null ? null : trimToNull(policy.requestToken);
Integer round = policy == null || policy.attempt == null ? Integer.valueOf(1) : policy.attempt;
FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, LEGACY_SLOT);
return new GuardedOutcome(created.getId(), false, FlowApplyRetryModels.Branch.CREATED, round);
}
private GuardedOutcome settleExisting(FlwInstance existing, ApplyCommand cmd, ApplyRetryPolicy policy) {
requireSameOwner(existing, cmd);
String token = policy == null ? null : trimToNull(policy.requestToken);
// 行上轮次为 NULL(v2 之前建的存量件)按 1 处理:这样"没有更高轮次"就不能重启别人的旧件,
// 新一轮授权重提(attempt≥2)才走得动 R4——存量行的守卫缺省是**关**而不是开。
int currentRound = existing.getApplyRound() == null ? 1 : existing.getApplyRound();
if (token != null && token.equals(existing.getApplyRequestToken())) {
// T04(2026-10-25):同 token 必须同指纹——同 ID 不同载荷是两份命令撞同一 token,
// 按冲突拒绝,不伪装成幂等成功。存量行(指纹 NULL)或请求无指纹时跳过(守卫缺省关)。
if (existing.getApplyPayloadHash() != null && cmd.payloadHash != null
&& !existing.getApplyPayloadHash().equals(cmd.payloadHash)) {
log.warn("[flow-center] 同请求标识但载荷指纹不一致(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
}
log.info("[flow-center] 同请求标识重投命中,只回读现状不重启:instance={} biz={}/{} status={}",
existing.getId(), cmd.bizType, cmd.bizId, existing.getStatus());
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.SAME_REQUEST, existing.getApplyRound());
}
int status = existing.getStatus();
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()
|| status == FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) {
// T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2:
// "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的
// 存量调用方保持 IDEMPOTENT(守卫缺省关)。
if (token != null) {
log.warn("[flow-center] 在途/通过件命中且请求 token 不符(冲突拒绝):instance={} biz={}/{}",
existing.getId(), cmd.bizType, cmd.bizId);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.CONFLICT, existing.getApplyRound());
}
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.IDEMPOTENT, existing.getApplyRound());
}
if (policy != null && policy.retryOnly) {
// 恢复链的"查无才补发"落在已有行上:30/40 也绝不重启(C.2 点名)
log.warn("[flow-center] retryOnly 命中终态办件,拒绝重启:instance={} status={}",
existing.getId(), status);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.REPLAY_ONLY, existing.getApplyRound());
}
// T04 服务端轮次:显式 expectedPrevRound 时——行轮次必须恰为 N(否则陈旧),服务端只分配 N+1;
// 未显式时保持客户端 attempt 语义(存量兼容)。
Integer expectedPrev = policy == null ? null : policy.expectedPrevRound;
int incomingRound;
if (expectedPrev != null) {
if (expectedPrev != currentRound) {
log.warn("[flow-center] 重提前置轮次不符(不重启):instance={} 行轮次={} 期望前轮={}",
existing.getId(), currentRound, expectedPrev);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
incomingRound = currentRound + 1;
} else {
incomingRound = policy == null || policy.attempt == null ? 1 : policy.attempt;
if (incomingRound <= currentRound) {
log.warn("[flow-center] 陈旧轮次重投(不重启):instance={} 行轮次={} 请求轮次={}",
existing.getId(), currentRound, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.STALE_ROUND, existing.getApplyRound());
}
}
restart(existing, cmd, token, incomingRound);
return new GuardedOutcome(existing.getId(), true,
FlowApplyRetryModels.Branch.RESTARTED, incomingRound);
}
/** Formal command caller must hold the coordinate lock in the same transaction. */
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public FlwInstance createFormalInstance(ApplyCommand cmd, String token, Integer round, String source) {
return createInstance(cmd, token, round, source, null);
}
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) {
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
/**
* 建件。<b>来源主体与槽位都是入参</b>:旧入口传(解析出的来源, {@code LEGACY_SLOT}),
* 正式命令传(来源, null)——两条分区不互认。来源缺失直接拒,不落"归属不明"的行。
*/
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source,
Integer legacySlot) {
if (source == null || source.trim().isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能创建办件");
}
if (legacySlot != null) requireLegacyCreationGrant(cmd);
FlwInstance instance = new FlwInstance();
instance.setSourcePrincipalId(source);
instance.setLegacySlot(legacySlot);
instance.setSchId(cmd.schId);
instance.setDefId(cmd.def.getId());
instance.setDefCode(cmd.def.getCode());
instance.setDefVersion(cmd.def.getVersion());
instance.setBizType(cmd.bizType);
instance.setBizId(cmd.bizId);
instance.setBizTitle(cmd.bizTitle);
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.setApplyRequestToken(token);
instance.setApplyRound(round);
instance.setApplyPayloadHash(cmd.payloadHash);
instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName);
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instanceRepository.saveAndFlush(instance);
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
if (legacySlot != null) {
flowEngine.startAdvance(instance, cmd.def);
publishSubmitted(instance);
}
return instance;
}
/**
* 业务坐标的<b>来源分区</b>读(F02 收口):可信来源缺失即拒,命中行必然属于本来源。
*
* <p>D11 的业务对象身份=可信来源应用+学校+bizType+bizId,因此应用 A 已占用同名业务键时,
* 应用 B 命中的是"本来源无行"→ 走新建,各自得到独立实例;B 既拿不到 A 的 instanceId,
* 也不会被当成 duplicated 成功。同来源重投才回原实例。</p>
*/
private FlwInstance requireSourceScopedLookup(ApplyCommand cmd) {
if (cmd.sourcePrincipalId == null || cmd.sourcePrincipalId.trim().isEmpty()) {
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源应用,不能按业务坐标定位或新建办件");
}
lockLegacyAdmission(cmd);
Optional<FlwInstance> mine = instanceRepository
.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, LEGACY_SLOT);
return mine.orElse(null);
}
/** All accepting entry points share the formal source mutex, including first-insert races. */
private void lockLegacyAdmission(ApplyCommand cmd) {
if (commandSources == null) throw new BizException(ErrorCode.FORBIDDEN, "来源互斥门禁未装配");
String source = cmd.sourcePrincipalId;
Integer appId;
try { appId = Integer.valueOf(source.substring(FlowSourcePrincipalResolver.SOURCE_PREFIX.length())); }
catch (RuntimeException invalid) { throw new BizException(ErrorCode.FORBIDDEN, "来源主体非法"); }
if (!FlowSourcePrincipalResolver.sourcePrincipalIdOf(appId).equals(source))
throw new BizException(ErrorCode.FORBIDDEN, "来源主体非法");
com.yunhai.flowcenter.entity.FlwCommandSource mutex = commandSources.lock(source).orElse(null);
if (mutex == null) {
mutex = new com.yunhai.flowcenter.entity.FlwCommandSource();
mutex.setId(source); mutex.setAppId(appId); commandSources.saveAndFlush(mutex);
} else if (!appId.equals(mutex.getAppId())) {
throw new BizException(ErrorCode.FORBIDDEN, "来源主体绑定不一致");
}
if (!instanceRepository.formalIdsForUpdate(cmd.schId, source, cmd.bizType, cmd.bizId).isEmpty())
throw new BizException(ErrorCode.FORBIDDEN, "该业务坐标已使用正式命令,禁止旧入口另建实例");
}
/** Recheck in the locked creation/restart branch: an earlier receipt preview may be stale. */
private void requireLegacyCreationGrant(ApplyCommand cmd) {
if (creationGrants == null) throw new BizException(ErrorCode.FORBIDDEN, "创建使用许可门禁未装配");
Integer appId = Integer.valueOf(cmd.sourcePrincipalId.substring(FlowSourcePrincipalResolver.SOURCE_PREFIX.length()));
creationGrants.requireNewApplyGrant(cmd.schId, cmd.def.getFlowGuid(),
new FlowSourcePrincipalResolver.Principal(cmd.sourcePrincipalId, appId, cmd.applyAppKey, cmd.applyAppCode));
}
/**
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照,
* 不能因为"当年没记"就把存量件判成别人的。
*/
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
if (cmd.sourcePrincipalId != null && existing.getSourcePrincipalId() != null
&& !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件属于另一调用应用,不能当作本应用的幂等结果(bizType="
+ cmd.bizType + " bizId=" + cmd.bizId + ")");
}
if (existing.getSubmitterId() != null && cmd.submitterId != null
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
throw new BizException(ErrorCode.FORBIDDEN, "该业务键的办件提交者与本次请求不符");
}
String existingFlowGuid = defRepository.findById(existing.getDefId())
.map(def -> def.getFlowGuid()).orElse(null);
if (existingFlowGuid != null && cmd.def != null && cmd.def.getFlowGuid() != null
&& !existingFlowGuid.equals(cmd.def.getFlowGuid())) {
throw new BizException(ErrorCode.FORBIDDEN,
"该业务键的办件绑定的流程线不同(行=" + existingFlowGuid + " 请求=" + cmd.def.getFlowGuid() + ")");
}
}
private static String trimToNull(String raw) {
if (raw == null) {
return null;
}
String value = raw.trim();
return value.isEmpty() ? null : value;
}
}