build / build-push (push) Failing after 55s
在 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 派生方法属另一实现线)
641 lines
35 KiB
Java
641 lines
35 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;
|
||
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;
|
||
}
|
||
}
|