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 复用仅描述旧入口。 * *
R4 驳回/撤回后重提语义(开工说明 §7#1,详设定稿):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;非构造参数——13 参构造被 * {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;null=存量调用方, * 指纹核对缺省关)。 */ public String payloadHash; /** * 可信来源主体(D11/F02,2026-10-29;同样非构造参数,理由同上)。 * 由服务端从验签 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 自增、轨迹跨轮连续)。 * 新增的两个参数只是随行落库的请求标识与轮次,不改任何节点算法(任务书 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):同一实例、同一申报轮次从第一级重新审批。 * *
与"终态后重新申报"({@link #apply} 的 R4 重启,会刷新定义版本并留 重新提交 轨迹) * 和"申请人撤回"完全区分:本方法只接受 {@code 50 退回待修改} 的办件,不改 defId/defVersion * (D04/D05 在途实例固定发起时版本),不自增 applyRound,不删任何历史任务/意见/事件行。
* *并发重提使用数据库行锁并刷新已加载实体;状态 10 还必须有最新 D14 提交留痕, * 才可作为回执恢复。普通审批中与旧 R4 留痕都不构成 D14 重提成功证明。
* * @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行 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 判定顺序(每一支都只回读现状,不改流程状态机一行算法): D11 的业务对象身份=可信来源应用+学校+bizType+bizId,因此应用 A 已占用同名业务键时,
* 应用 B 命中的是"本来源无行"→ 走新建,各自得到独立实例;B 既拿不到 A 的 instanceId,
* 也不会被当成 duplicated 成功。同来源重投才回原实例。
*
*/
@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);
}
/**
* 建件。来源主体与槽位都是入参:旧入口传(解析出的来源, {@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;
}
/**
* 业务坐标的来源分区读(F02 收口):可信来源缺失即拒,命中行必然属于本来源。
*
*