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 复用仅描述旧入口。 * *
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 {
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;非构造参数——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 自增、轨迹跨轮连续)。
* 新增的两个参数只是随行落库的请求标识与轮次,不改任何节点算法(任务书 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 行 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 判定顺序(每一支都只回读现状,不改流程状态机一行算法):
*
*/
@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;
}
}