按《FL最终代码审计与上线阻断清单》194 项已审计工作树形成候选,基座 = origin/main 11e0778。
- 源码/测试/资源:沿用已审计工作树内容(96 项相对远端为新增或更新),含未跟踪实现
CallbackFactStore、CallbackSourcePolicy、VariableContract、FormalApplyCommandService、
FlowActorContextGate 等;C01 续验夹具修正一并入内。
- CI:保留远端 11e0778 的门禁强化(CI_SOURCE_SHA 核对、容器回收验证、空 schema 反向门禁、
main ref 才推镜像);ci/flow-test-schema.sql 按当前实体重新生成(14→20 表,补 6 新表与
6 新列,回调日志复合唯一键 uk_flw_log_event_reg),workflow 表数断言同步 14→20。
- 依据:docs/tasks/evidence/FL-final-audit-20261027(194 项哈希/版本核对)。
524 lines
27 KiB
Java
524 lines
27 KiB
Java
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;
|
||
|
||
/**
|
||
* 引擎实例生命周期与迁移前代发起。正式命令通过 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 {
|
||
|
||
private final FlwInstanceRepository instanceRepository;
|
||
private final FlwDefRepository defRepository;
|
||
private final FlwTaskRepository taskRepository;
|
||
private final FlwBranchRepository branchRepository;
|
||
private final FlowEngine flowEngine;
|
||
private final ApplicationEventPublisher eventPublisher;
|
||
|
||
/** 代发起申请体(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;
|
||
}
|
||
|
||
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||
@Transactional
|
||
public ApplyOutcome apply(ApplyCommand cmd) {
|
||
FlwInstance existing = instanceRepository
|
||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
||
if (existing != null) {
|
||
int status = existing.getStatus();
|
||
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||
return new ApplyOutcome(existing.getId(), true);
|
||
}
|
||
// 30/40:R4 重启推进(复用办件行,定义/快照/时间刷新,重新走 START)
|
||
restart(existing, cmd);
|
||
return new ApplyOutcome(existing.getId(), true);
|
||
}
|
||
FlwInstance instance = new FlwInstance();
|
||
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) {
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 撤回(概览 §5.3):仅当尚无任何任务被办理时允许;全部待办失效、分支失效、办件=40。
|
||
*/
|
||
@Transactional
|
||
public void withdraw(Long schId, Long instanceId) {
|
||
FlwInstance instance = requireInstance(schId, instanceId);
|
||
if (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||
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 = instanceRepository
|
||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
||
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;
|
||
createInstance(cmd, token, round);
|
||
return new GuardedOutcome(instanceOf(cmd), 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()) {
|
||
// 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);
|
||
}
|
||
|
||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||
public void startFormalInstance(FlwInstance instance, ApplyCommand cmd) {
|
||
flowEngine.startAdvance(instance, cmd.def);
|
||
publishSubmitted(instance);
|
||
}
|
||
|
||
private void createInstance(ApplyCommand cmd, String token, Integer round) {
|
||
createInstance(cmd, token, round, null);
|
||
}
|
||
|
||
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
|
||
FlwInstance instance = new FlwInstance();
|
||
instance.setSourcePrincipalId(source);
|
||
instance.setLegacySlot(source == null ? 1 : null);
|
||
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 (source == null) {
|
||
flowEngine.startAdvance(instance, cmd.def);
|
||
publishSubmitted(instance);
|
||
}
|
||
return instance;
|
||
}
|
||
|
||
/**
|
||
* 旧 {@link #apply(ApplyCommand)} 的建件段复用(行为逐字不变,只是把 new 出来的那段抽出来)。
|
||
* 这里不复用 createInstance 是因为旧路径不写 token/round(保持存量行 NULL,不伪装"有守卫")。
|
||
*/
|
||
private Long instanceOf(ApplyCommand cmd) {
|
||
FlwInstance created = instanceRepository
|
||
.findBySchIdAndBizTypeAndBizId(cmd.schId, cmd.bizType, cmd.bizId).orElse(null);
|
||
return created == null ? null : created.getId();
|
||
}
|
||
|
||
/**
|
||
* 归属复核:调用应用 / 提交者 / 流程线三者任一明确不符都拒(C.3"不能把别的应用
|
||
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照,
|
||
* 不能因为"当年没记"就把存量件判成别人的。
|
||
*/
|
||
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
|
||
if (existing.getApplyAppKey() != null && cmd.applyAppKey != null
|
||
&& !existing.getApplyAppKey().equals(cmd.applyAppKey)) {
|
||
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;
|
||
}
|
||
}
|