在 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 派生方法属另一实现线)
This commit is contained in:
@@ -22,6 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 引擎实例生命周期与迁移前代发起。正式命令通过 create/startFormalInstance 建立独立轮次实例,
|
||||
@@ -38,12 +39,21 @@ import java.util.Map;
|
||||
@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
|
||||
@@ -67,48 +77,66 @@ public class FlowInstanceService {
|
||||
|
||||
/**
|
||||
* 受理载荷指纹(2026-10-25 T04,SHA-256 hex;<b>非构造参数</b>——13 参构造被
|
||||
* {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;
|
||||
* null=存量调用方,指纹核对缺省关)。
|
||||
* {@code FlowEngineIT} 逐位钉住,由 {@code OapiFlowService} 在构建后赋值;null=存量调用方,
|
||||
* 指纹核对缺省关)。
|
||||
*/
|
||||
public String payloadHash;
|
||||
/** Assigned only by authenticated server-side source resolution; not a request DTO field. */
|
||||
|
||||
/**
|
||||
* 可信来源主体(D11/F02,2026-10-29;同样<b>非构造参数</b>,理由同上)。
|
||||
* 由服务端从验签 appKey 解析,绝不接收客户端自报值。业务坐标的幂等复用、重启与
|
||||
* 按业务键回读都以它为分区维度;为空即拒(缺失来源不得静默补全)。
|
||||
*/
|
||||
public String sourcePrincipalId;
|
||||
}
|
||||
|
||||
/** 代发起结果:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||||
/** 代发起结果值对象:instanceId + duplicated(命中既有办件=true,含重启重提) */
|
||||
@Transactional
|
||||
public ApplyOutcome apply(ApplyCommand cmd) {
|
||||
FlwInstance existing = findOwned(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.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);
|
||||
}
|
||||
FlwInstance created = createInstance(cmd, java.util.UUID.randomUUID().toString(), 1, cmd.sourcePrincipalId, 1, true);
|
||||
return new ApplyOutcome(created.getId(), false);
|
||||
}
|
||||
|
||||
private FlwInstance findOwned(ApplyCommand cmd) {
|
||||
if (cmd.schId == null || cmd.schId <= 0 || cmd.sourcePrincipalId == null
|
||||
|| !cmd.sourcePrincipalId.matches("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}")) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "缺少可信来源或有效申报学校");
|
||||
}
|
||||
FlwInstance existing = instanceRepository.findBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdAndLegacySlot(
|
||||
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId, 1).orElse(null);
|
||||
if (existing == null && instanceRepository.findFirstBySchIdAndSourcePrincipalIdAndBizTypeAndBizIdOrderByApplyRoundDesc(
|
||||
cmd.schId, cmd.sourcePrincipalId, cmd.bizType, cmd.bizId).isPresent()) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN, "该业务对象已有正式命令实例,请使用原命令通道");
|
||||
}
|
||||
return existing;
|
||||
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, java.util.UUID.randomUUID().toString(), instance.getApplyRound());
|
||||
restart(instance, cmd, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,6 +145,7 @@ public class FlowInstanceService {
|
||||
* 不为修复幂等而重写流程节点算法)。旧 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());
|
||||
@@ -155,14 +184,67 @@ public class FlowInstanceService {
|
||||
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 (instance.getStatus() != FlwEnums.InstanceStatus.APPROVING.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅审批中的办件可撤回");
|
||||
if (!FlwEnums.InstanceStatus.inFlightCodes().contains(instance.getStatus())) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "仅在途办件可撤回(审批中或退回待修改)");
|
||||
}
|
||||
if (!taskRepository.findByInstanceIdAndStatusIn(instanceId,
|
||||
FlwEnums.TaskStatus.handledCodes()).isEmpty()) {
|
||||
@@ -342,13 +424,13 @@ public class FlowInstanceService {
|
||||
*/
|
||||
@Transactional
|
||||
public GuardedOutcome applyGuarded(ApplyCommand cmd, ApplyRetryPolicy policy) {
|
||||
FlwInstance existing = findOwned(cmd);
|
||||
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, 1, true);
|
||||
FlwInstance created = createInstance(cmd, token, round, cmd.sourcePrincipalId, LEGACY_SLOT);
|
||||
return new GuardedOutcome(created.getId(), false, FlowApplyRetryModels.Branch.CREATED, round);
|
||||
}
|
||||
|
||||
@@ -375,7 +457,8 @@ public class FlowInstanceService {
|
||||
}
|
||||
int status = existing.getStatus();
|
||||
if (status == FlwEnums.InstanceStatus.APPROVING.getCode()
|
||||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()) {
|
||||
|| status == FlwEnums.InstanceStatus.COMPLETED.getCode()
|
||||
|| status == FlwEnums.InstanceStatus.RETURNED_TO_SUBMITTER.getCode()) {
|
||||
// T04:带 token 的请求撞上在途/通过件且 token 不符=另一份命令(审计 P0-2:
|
||||
// "10/20 即使异命令也回 IDEMPOTENT"是把他人命令的成功当自己的)。无 token 的
|
||||
// 存量调用方保持 IDEMPOTENT(守卫缺省关)。
|
||||
@@ -424,7 +507,7 @@ public class FlowInstanceService {
|
||||
/** 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);
|
||||
return createInstance(cmd, token, round, source, null);
|
||||
}
|
||||
|
||||
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY)
|
||||
@@ -433,12 +516,16 @@ public class FlowInstanceService {
|
||||
publishSubmitted(instance);
|
||||
}
|
||||
|
||||
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source) {
|
||||
return createInstance(cmd, token, round, source, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建件。<b>来源主体与槽位都是入参</b>:旧入口传(解析出的来源, {@code LEGACY_SLOT}),
|
||||
* 正式命令传(来源, null)——两条分区不互认。来源缺失直接拒,不落"归属不明"的行。
|
||||
*/
|
||||
private FlwInstance createInstance(ApplyCommand cmd, String token, Integer round, String source,
|
||||
Integer legacySlot, boolean start) {
|
||||
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);
|
||||
@@ -465,21 +552,70 @@ public class FlowInstanceService {
|
||||
instanceRepository.saveAndFlush(instance);
|
||||
appendTraceEvent(instance, FlwEnums.TaskStatus.NODE_SUBMIT, "发起申请",
|
||||
FlwEnums.TaskStatus.SUBMITTED, cmd.submitterId, cmd.submitterName);
|
||||
if (start) {
|
||||
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 键当自己的结果")。来源必须存在且匹配,不用 appKey 快照替代来源 UUID。
|
||||
* 相同 bizType 键当自己的结果")。两侧都非空才判——历史行没有 token/appKey 快照,
|
||||
* 不能因为"当年没记"就把存量件判成别人的。
|
||||
*/
|
||||
private void requireSameOwner(FlwInstance existing, ApplyCommand cmd) {
|
||||
if (cmd.sourcePrincipalId == null || !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
|
||||
if (cmd.sourcePrincipalId != null && existing.getSourcePrincipalId() != null
|
||||
&& !cmd.sourcePrincipalId.equals(existing.getSourcePrincipalId())) {
|
||||
throw new BizException(ErrorCode.FORBIDDEN,
|
||||
"该业务键的办件属于另一调用应用(bizType=" + cmd.bizType + " bizId=" + cmd.bizId + ")");
|
||||
"该业务键的办件属于另一调用应用,不能当作本应用的幂等结果(bizType="
|
||||
+ cmd.bizType + " bizId=" + cmd.bizId + ")");
|
||||
}
|
||||
if (existing.getSubmitterId() != null && cmd.submitterId != null
|
||||
&& !existing.getSubmitterId().equals(cmd.submitterId)) {
|
||||
|
||||
Reference in New Issue
Block a user