t1~t2+精修+TASK_4: 定义绑定表单UUID/版本成对核验、发起恢复守卫、schema兼容边界草案
build / build-push (push) Successful in 1m10s

含10-22精修: 保存/发布/启用按确切uuid+version远端核验、升版继承两列、取消关联清空版本、缺版本不回落最新。TASK_4=docs/schema-extension-compatibility-next-tasks.md(纯文档)。
This commit is contained in:
chenxing
2026-10-22 10:03:28 +08:00
parent 60ff464226
commit 4b4d63e3ba
15 changed files with 1510 additions and 6 deletions
@@ -6,6 +6,7 @@ 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;
@@ -106,6 +107,15 @@ public class FlowInstanceService {
}
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());
@@ -121,6 +131,13 @@ public class FlowInstanceService {
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);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null);
@@ -267,4 +284,165 @@ public class FlowInstanceService {
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;
}
/** 守卫化结果:除 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())) {
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()) {
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());
}
int 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);
}
/** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */
private void createInstance(ApplyCommand cmd, String token, Integer round) {
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.setApplyRequestToken(token);
instance.setApplyRound(round);
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);
}
/**
* 旧 {@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;
}
}