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
@@ -12,6 +12,7 @@ import com.yunhai.flowcenter.engine.AssigneeResolver;
import com.yunhai.flowcenter.engine.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository;
@@ -25,6 +26,7 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -90,6 +92,25 @@ public class OapiFlowService {
private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider;
/**
* v2 发起的事务模板。<b>用 setter/字段注入而不是加进构造器</b>:本类构造被
* {@code OapiFlowApplyDefVersionTest}/{@code OapiFlowEmbedTaskUrlTest} 逐位钉住,
* 多一个参数就打不进来(同 svc-hall ServiceItemService 的 setter 先例)。
* 未装配(老单测直接 new)时 {@link #applyV2} 回落到"直接调用",事务语义由调用方的
* {@code @Transactional} 承担——那条路径只用于测试,不影响生产装配。
*/
private org.springframework.transaction.support.TransactionTemplate transactionTemplate;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setTransactionTemplate(org.springframework.transaction.support.TransactionTemplate template) {
this.transactionTemplate = template;
}
private FlowInstanceService.GuardedOutcome inTransaction(
org.springframework.transaction.support.TransactionCallback<FlowInstanceService.GuardedOutcome> action) {
return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
}
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
@@ -144,6 +165,100 @@ public class OapiFlowService {
return vo;
}
/**
* 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},v2 §8,TASK_2 C.3)。
*
* <p>与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:</p>
* <ol>
* <li><b>事务边界收窄到一次发起</b>:用 {@code TransactionTemplate} 而不是方法级
* {@code @Transactional}——唯一键冲突必须在<b>事务外</b>重新读既有实例
* (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall
* {@code AnswerService#executeSubmit} 同一形态;</li>
* <li><b>并发撞键后先核归属再返回</b>:读到的实例若属于另一应用/另一提交者/另一流程线,
* 一律拒绝,<b>不把别人的 bizType+bizId 当自己的结果</b>;</li>
* <li><b>回带稳定分支码</b>(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/
* RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。</li>
* </ol>
*
* <p>已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。</p>
*/
public FlowApplyRetryModels.ApplyResultV2 applyV2(Long schId, String appKey,
FlowApplyRetryModels.ApplyRequestV2 body) {
if (schId == null || body == null || body.getFlowGuid() == null || body.getBizType() == null
|| body.getBizId() == null || body.getSubmitterId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"缺少必要参数:schId/flowGuid/bizType/bizId/submitterId");
}
requireSchoolMember(schId, body.getSubmitterId());
FlwDef def = defRepository.findFirstBySchIdAndFlowGuidAndStatusOrderByVersionDesc(
schId, body.getFlowGuid(), FlwEnums.DefStatus.PUBLISHED.getCode())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR,
"流程不存在或未发布:" + body.getFlowGuid()));
if (body.getExpectedDefVersion() != null && !body.getExpectedDefVersion().equals(def.getVersion())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"流程已更新,请刷新后重新选择审批人(期望定义版本 " + body.getExpectedDefVersion()
+ ",当前已发布版本 " + def.getVersion() + ")");
}
String assignJson = renderAssignJson(schId, def, body.getAssigns(), body.getSubmitterId());
String cleanAppKey = appKey == null || appKey.trim().isEmpty() ? null : appKey.trim();
FlowInstanceService.ApplyCommand cmd = new FlowInstanceService.ApplyCommand(
schId, def,
body.getBizType(), body.getBizId(), body.getBizTitle(),
body.getBizUrl(), asSnapshotString(body.getBizSnapshot()),
body.getSubmitterId(), body.getSubmitterName(),
asFormDataString(body.getFormData()),
cleanAppKey, resolveApplyAppCode(cleanAppKey), assignJson);
FlowInstanceService.ApplyRetryPolicy policy = new FlowInstanceService.ApplyRetryPolicy(
body.getRequestToken(), body.getAttempt(), Boolean.TRUE.equals(body.getRetryOnly()));
FlowInstanceService.GuardedOutcome outcome;
try {
outcome = inTransaction(tx -> instanceService.applyGuarded(cmd, policy));
} catch (DataIntegrityViolationException concurrent) {
// 事务边界外重读:另一个并发请求已用同一业务键建好办件
return fromConcurrent(schId, cmd, policy);
}
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(outcome.instanceId);
vo.setDuplicated(outcome.duplicated);
vo.setBranch(outcome.branch);
vo.setApplyRound(outcome.applyRound);
return vo;
}
/**
* 并发撞键后的收口:重读既有实例 → <b>先核归属</b> → 同 token 记 {@code SAME_REQUEST},
* 否则按幂等命中回读。归属不符就拒绝,绝不返回别人的办件 id。
*/
private FlowApplyRetryModels.ApplyResultV2 fromConcurrent(Long schId,
FlowInstanceService.ApplyCommand cmd,
FlowInstanceService.ApplyRetryPolicy policy) {
FlwInstance existing = instanceRepository.findBySchIdAndBizTypeAndBizId(
schId, cmd.bizType, cmd.bizId).orElse(null);
if (existing == null) {
throw new BizException(ErrorCode.CUSTOM, "办件创建冲突且读不到既有实例,请稍后重试");
}
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 token = policy.requestToken == null ? null : policy.requestToken.trim();
FlowApplyRetryModels.ApplyResultV2 vo = new FlowApplyRetryModels.ApplyResultV2();
vo.setInstanceId(existing.getId());
vo.setDuplicated(true);
vo.setApplyRound(existing.getApplyRound());
vo.setBranch(token != null && token.equals(existing.getApplyRequestToken())
? FlowApplyRetryModels.Branch.SAME_REQUEST : FlowApplyRetryModels.Branch.CONCURRENT_CREATED);
log.warn("[flow-center] 并发建件命中既有实例:instance={} biz={}/{} branch={}",
existing.getId(), cmd.bizType, cmd.bizId, vo.getBranch());
return vo;
}
/**
* 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;