t1~t2+精修+TASK_4: 定义绑定表单UUID/版本成对核验、发起恢复守卫、schema兼容边界草案
build / build-push (push) Successful in 1m10s
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:
@@ -45,6 +45,16 @@ public class FlowAdminController {
|
||||
|
||||
private final FlowAdminGuard adminGuard;
|
||||
private final FlowDefAdminService defAdminService;
|
||||
|
||||
/**
|
||||
* 表单中心目录客户端(v3/TASK_1)。
|
||||
*
|
||||
* <p><b>字段注入而不是加进构造参数</b>:本类的构造器已被 {@code FlowAdminInstanceHungTest} 与
|
||||
* {@code FlowDiagramServiceTest} 逐位钉住,而那两份测试不在本任务的可改清单里——多一个入参
|
||||
* 会让整个测试树编译不过。</p>
|
||||
*/
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
private com.yunhai.flowcenter.service.FormCenterCatalogClient formCenterCatalogClient;
|
||||
private final FlowMapAdminService mapAdminService;
|
||||
private final FlowCallbackAdminService callbackAdminService;
|
||||
private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
|
||||
@@ -103,7 +113,29 @@ public class FlowAdminController {
|
||||
return defAdminService.get(adminGuard.requireAdmin(user, null), id);
|
||||
}
|
||||
|
||||
/** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;changeLog=升版本改动说明,仅新版本行落库,>500 截断;formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版) */
|
||||
/**
|
||||
* 表单中心目录代理(v3/TASK_1):编辑器"关联表单"选择器的数据源,浏览器只打本应用同源接口,
|
||||
* appKey/secret 不在前端出现。校域由 {@link FlowAdminGuard#requireAdmin} 从 CAS 会话派生,
|
||||
* 前端没有可越界的 schId 入口;中心侧只回"已发布 ∩ 本应用可读"的表单,
|
||||
* 没授权的表压根不出现(不是"看得见但选不了")。
|
||||
*/
|
||||
@GetMapping("/form-center/forms")
|
||||
public com.yunhai.flowcenter.service.FormCenterCatalogClient.CatalogPage formCenterForms(
|
||||
BoaUser user,
|
||||
@RequestParam(value = "appCode", required = false) String appCode,
|
||||
@RequestParam(value = "keyword", required = false) String keyword,
|
||||
@RequestParam(value = "page", required = false) Integer page,
|
||||
@RequestParam(value = "pageSize", required = false) Integer pageSize) {
|
||||
return formCenterCatalogClient.listForms(adminGuard.requireAdmin(user, null),
|
||||
appCode, keyword, page, pageSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;
|
||||
* changeLog=升版本改动说明,仅新版本行落库,>500 截断;
|
||||
* formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版;
|
||||
* <b>formVersion=v3 确切版本(与 formUuid 成对,单传一个即拒;取消关联时同批清空)</b>。
|
||||
*/
|
||||
@PostMapping("/def/save")
|
||||
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
|
||||
Long schId = adminGuard.requireAdmin(user, null);
|
||||
@@ -112,7 +144,8 @@ public class FlowAdminController {
|
||||
(String) body.get("code"), (String) body.get("name"),
|
||||
(String) body.get("contentJson"), (String) body.get("baseGuid"),
|
||||
(String) body.get("appKey"), (String) body.get("changeLog"),
|
||||
(String) body.get("formUuid"));
|
||||
(String) body.get("formUuid"),
|
||||
body.get("formVersion") == null ? null : Integer.valueOf(String.valueOf(body.get("formVersion"))));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.yunhai.flowcenter.controller.oapi;
|
||||
|
||||
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
|
||||
import com.yunhai.flowcenter.model.FlowModels;
|
||||
import com.yunhai.flowcenter.model.OapiModels;
|
||||
import com.yunhai.flowcenter.service.FlowQueryService;
|
||||
@@ -68,6 +69,23 @@ public class OapiFlowController {
|
||||
return flowService.apply(schId, appKey, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* 带请求标识/轮次守卫的代发起(v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p>与 {@code /apply} 是<b>两条显式分支</b>而不是一个端点加 flag:旧端点行为逐字不变
|
||||
* (同 bizKey 命中 30/40 仍按 R4 重启推进),新端点声明"我带幂等守卫"——
|
||||
* 同 {@code requestToken} 重投只回读现状,{@code retryOnly} 命中终态也不重启,
|
||||
* 陈旧轮次拒绝重启。响应多带一个稳定分支码 {@code branch},消费方按码处置。
|
||||
* 请求标识与轮次由消费方的持久化出站命令携带(svc-hall {@code flow_apply_command})。</p>
|
||||
*/
|
||||
@PostMapping("/apply/v2")
|
||||
public FlowApplyRetryModels.ApplyResultV2 applyV2(@RequestParam("schId") Long schId,
|
||||
@RequestAttribute(value = "yh.app.auth",
|
||||
required = false) String appKey,
|
||||
@RequestBody FlowApplyRetryModels.ApplyRequestV2 body) {
|
||||
return flowService.applyV2(schId, appKey, body);
|
||||
}
|
||||
|
||||
/** 发起预览:返回开启发起时指派的节点与候选人(已剔除发起人本人),供子应用提交页选人 */
|
||||
@GetMapping("/apply/preview")
|
||||
public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId,
|
||||
|
||||
@@ -60,6 +60,20 @@ public class FlwDef extends BaseEntity {
|
||||
@Column(name = "form_uuid", length = 32)
|
||||
private String formUuid;
|
||||
|
||||
/**
|
||||
* 关联表单的<b>确切版本</b>(v3/TASK_1):与 {@link #formUuid} 成对,发布期按这组坐标取快照,
|
||||
* 中心返回的坐标与请求不符即阻断发布。
|
||||
*
|
||||
* <p>为什么必须钉版本而不是"发布时取当前发布版":同一条流程线的两次发布可能落到不同的表结构上,
|
||||
* 而表达式校验、办件回显都按嵌入的 formContract 判——版本不钉住,"这条流程当时用的哪张表"
|
||||
* 就成了只能猜的历史。存的是服务器侧核验过的版本号。</p>
|
||||
*
|
||||
* <p>null 只出现在本次改造之前已经关联了表单的存量行上(C1 第一刀没这列):
|
||||
* 那种行发布时仍走"当前发布版"的旧口径,改造后新保存/新关联一律成对写入。</p>
|
||||
*/
|
||||
@Column(name = "form_version")
|
||||
private Integer formVersion;
|
||||
|
||||
/** §5.1 流程定义 JSON */
|
||||
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
|
||||
private String contentJson;
|
||||
|
||||
@@ -129,6 +129,26 @@ public class FlwInstance extends BaseEntity {
|
||||
@Column(name = "version", nullable = false)
|
||||
private Integer version;
|
||||
|
||||
/**
|
||||
* 最近一次成功受理代发起的<b>请求标识</b>(消费方持久化命令里带的 token,v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p>它回答的是"这次 apply 是同一命令的重投,还是新一轮发起"——biz uk 只说得出
|
||||
* "这个业务键已经有办件了",说不出这句。没有它时,消费方在响应丢失后重发同业务键,
|
||||
* 命中 30/40 会被 R4 语义重启推进,等于<b>把网络重试当成用户重提</b>。
|
||||
* 同 token 一律只回读现状、不重启;换 token 才是新一轮。</p>
|
||||
*/
|
||||
@Column(name = "apply_request_token", length = 64)
|
||||
private String applyRequestToken;
|
||||
|
||||
/**
|
||||
* 当前发起轮次(1 起;随新一轮请求标识进位)。
|
||||
*
|
||||
* <p>轮次守卫是<b>单调</b>的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
|
||||
* 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。</p>
|
||||
*/
|
||||
@Column(name = "apply_round")
|
||||
private Integer applyRound;
|
||||
|
||||
/**
|
||||
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
|
||||
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.yunhai.flowcenter.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
/**
|
||||
* 代发起的请求标识与轮次守卫(v2 §8,TASK_2 C.3)。
|
||||
*
|
||||
* <p><b>为什么是独立 DTO 而不是往 {@code OapiModels.ApplyBody} 加字段</b>(任务书原文要求):
|
||||
* 旧 ApplyBody 是多个消费应用共用的线上契约,往里塞"重投标识/轮次"会让所有消费方
|
||||
* 都要重新理解一遍字段语义;更重要的是,请求标识的分支行为<b>只对新端点成立</b>——
|
||||
* 旧端点保持逐字不变,新端点显式声明"我带守卫"。两侧分支写清楚,比共用一个 DTO 加 if 诚实。</p>
|
||||
*
|
||||
* <p>继承旧 body 是为了让 {@code /oapi/flow/apply/v2} 的接收形状与旧端点完全一致
|
||||
* (消费方只多带三个字段),不是为了共享可变字段。</p>
|
||||
*/
|
||||
public final class FlowApplyRetryModels {
|
||||
|
||||
private FlowApplyRetryModels() {
|
||||
}
|
||||
|
||||
/** v2 请求体:旧 apply 全字段 + 请求标识/轮次/仅补发开关 */
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class ApplyRequestV2 extends OapiModels.ApplyBody {
|
||||
/**
|
||||
* 本次命令的请求标识(消费方持久化后携带,重投同值)。
|
||||
* 与行上已落的标识相同 ⇒ 同一命令的重投 ⇒ <b>只回读现状,绝不重启推进</b>。
|
||||
*/
|
||||
private String requestToken;
|
||||
/** 发起轮次(1 起,单调);低于行上 applyRound 的是陈旧轮次迟到重投 */
|
||||
private Integer attempt;
|
||||
/**
|
||||
* true=只补发"确实还没有办件"的情况:命中既有实例一律回读现状,
|
||||
* 即使处于 30/40 也<b>不</b>触发 R4 重启。恢复链在"查无才补发"之分支上用它,
|
||||
* 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
|
||||
*/
|
||||
private Boolean retryOnly;
|
||||
}
|
||||
|
||||
/** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
|
||||
@Data
|
||||
public static class ApplyResultV2 {
|
||||
private Long instanceId;
|
||||
private boolean duplicated;
|
||||
/**
|
||||
* 稳定分支码(消费方按码处置,不靠文案):
|
||||
* <ul>
|
||||
* <li>{@code CREATED}:新建办件并推进;</li>
|
||||
* <li>{@code IDEMPOTENT}:在途/已完成(10/20)命中,纯幂等返回;</li>
|
||||
* <li>{@code SAME_REQUEST}:同 requestToken 的重投命中,<b>未重启</b>;</li>
|
||||
* <li>{@code STALE_ROUND}:请求轮次陈旧,未重启;</li>
|
||||
* <li>{@code REPLAY_ONLY}:retryOnly 命中既有实例(含 30/40),未重启;</li>
|
||||
* <li>{@code RESTARTED}:新一轮授权重提,R4 重启推进。</li>
|
||||
* </ul>
|
||||
*/
|
||||
private String branch;
|
||||
/** 行上的当前轮次(消费方据此校正自己的命令表认知) */
|
||||
private Integer applyRound;
|
||||
}
|
||||
|
||||
/** 分支码常量(与 {@link ApplyResultV2#branch} 同值域) */
|
||||
public static final class Branch {
|
||||
public static final String CREATED = "CREATED";
|
||||
public static final String IDEMPOTENT = "IDEMPOTENT";
|
||||
public static final String SAME_REQUEST = "SAME_REQUEST";
|
||||
public static final String STALE_ROUND = "STALE_ROUND";
|
||||
public static final String REPLAY_ONLY = "REPLAY_ONLY";
|
||||
public static final String RESTARTED = "RESTARTED";
|
||||
public static final String CONCURRENT_CREATED = "CONCURRENT_CREATED";
|
||||
|
||||
private Branch() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,8 @@ public class FlowDefAdminService {
|
||||
def.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
|
||||
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey());
|
||||
def.setFormUuid(base.getFormUuid());
|
||||
def.setFormVersion(base.getFormVersion());
|
||||
} else if (id == null) {
|
||||
// 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用)
|
||||
def = new FlwDef();
|
||||
@@ -139,6 +141,8 @@ public class FlowDefAdminService {
|
||||
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
|
||||
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
|
||||
next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey());
|
||||
next.setFormUuid(def.getFormUuid());
|
||||
next.setFormVersion(def.getFormVersion());
|
||||
def = next;
|
||||
}
|
||||
}
|
||||
@@ -154,7 +158,7 @@ public class FlowDefAdminService {
|
||||
if (def.getId() == null) {
|
||||
def.setChangeLog(cleanLog);
|
||||
}
|
||||
def.setContentJson(contentJson);
|
||||
def.setContentJson(withoutFormContract(contentJson)); // Only publish may author a contract snapshot.
|
||||
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
|
||||
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
|
||||
def.setCode(parsed.code.trim());
|
||||
@@ -174,15 +178,32 @@ public class FlowDefAdminService {
|
||||
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
|
||||
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
|
||||
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
|
||||
* 非空=设置(不做格式强校验——寻址失败在发布期由快照拉取阻断,错误不进库但也不卡保存)。
|
||||
* 非空=设置,保存前按确切版本远端核验,失败不写入。
|
||||
* <br>v3/TASK_1 追加 {@code formVersion}:与 formUuid <b>成对</b>,规则是——
|
||||
* ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
|
||||
* ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
|
||||
* ③ formUuid 给空串=取消关联,<b>同时清空版本</b>(留着一个没人认领的版本号是下一轮误用的起点);
|
||||
* ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。
|
||||
*/
|
||||
@Transactional
|
||||
public FlwDef save(Long schId, Long id, String code, String name, String contentJson,
|
||||
String baseGuid, String appKey, String changeLog, String formUuid) {
|
||||
String baseGuid, String appKey, String changeLog, String formUuid, Integer formVersion) {
|
||||
boolean clearing = formUuid != null && formUuid.trim().isEmpty();
|
||||
boolean pairing = formUuid != null && !clearing && formVersion == null;
|
||||
boolean versionOnly = formUuid == null && formVersion != null;
|
||||
if (pairing || versionOnly) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
|
||||
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
|
||||
}
|
||||
if (formUuid != null && !clearing) {
|
||||
checkedSchema(schId, formUuid.trim(), formVersion); // Validate before the first save.
|
||||
}
|
||||
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
|
||||
if (formUuid != null) {
|
||||
String clean = formUuid.trim();
|
||||
saved.setFormUuid(clean.isEmpty() ? null : clean);
|
||||
saved.setFormVersion(clean.isEmpty() ? null : formVersion);
|
||||
}
|
||||
try {
|
||||
return defRepository.save(saved);
|
||||
@@ -202,15 +223,22 @@ public class FlowDefAdminService {
|
||||
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态");
|
||||
}
|
||||
if (def.getStatus() != FlwEnums.DefStatus.DRAFT.getCode()) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "停用版本请走启用;修改表单关联须保存为新版本草稿");
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
|
||||
Map<String, Object> snapshot;
|
||||
try {
|
||||
snapshot = formCenterSchemaClient.fetchSchema(schId, def.getFormUuid().trim());
|
||||
// 管理链始终使用确切版本;缺版本须重新选择,不回落当前发布版。
|
||||
snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion());
|
||||
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage());
|
||||
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
||||
"关联表单版本核验失败,发布已阻断(重试无意义,请重新选择表单与版本):" + e.getMessage());
|
||||
}
|
||||
List<String> problems = new ArrayList<>();
|
||||
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
|
||||
@@ -406,11 +434,37 @@ public class FlowDefAdminService {
|
||||
}
|
||||
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
|
||||
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
|
||||
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
|
||||
checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion());
|
||||
}
|
||||
def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||
def.setPublishTime(LocalDateTime.now());
|
||||
return defRepository.save(def);
|
||||
}
|
||||
|
||||
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version) {
|
||||
if (version == null || version < 1) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择");
|
||||
}
|
||||
try {
|
||||
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
|
||||
} catch (FormCenterSchemaClient.SchemaUnavailableException
|
||||
| FormCenterSchemaClient.SchemaContractViolationException e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单核验失败,操作已阻断:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private String withoutFormContract(String contentJson) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode)
|
||||
objectMapper.readTree(contentJson);
|
||||
root.remove("formContract");
|
||||
return objectMapper.writeValueAsString(root);
|
||||
} catch (Exception e) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "流程定义必须是有效的JSON对象");
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一流程线全部版本(降序) */
|
||||
public List<FlwDef> versions(Long schId, String flowGuid) {
|
||||
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -106,6 +107,15 @@ public class FlowInstanceService {
|
||||
}
|
||||
|
||||
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) {
|
||||
log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}",
|
||||
instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId);
|
||||
instance.setDefId(cmd.def.getId());
|
||||
@@ -121,6 +131,13 @@ public class FlowInstanceService {
|
||||
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);
|
||||
}
|
||||
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
|
||||
instance.setSubmitTime(LocalDateTime.now());
|
||||
instance.setFinishTime(null);
|
||||
@@ -267,4 +284,165 @@ public class FlowInstanceService {
|
||||
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;
|
||||
}
|
||||
|
||||
/** 守卫化结果:除 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 = 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())) {
|
||||
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()) {
|
||||
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());
|
||||
}
|
||||
int 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);
|
||||
}
|
||||
|
||||
/** 新建实例(与旧 apply 同一段代码形状:不复制第二份推进逻辑) */
|
||||
private void createInstance(ApplyCommand cmd, String token, Integer round) {
|
||||
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.setApplyRequestToken(token);
|
||||
instance.setApplyRound(round);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧 {@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.yunhai.flowcenter.service;
|
||||
|
||||
import com.yunhai.platform.apistd.BizException;
|
||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
||||
import com.yunhai.platform.utils.exception.ErrorCode;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 表单中心目录客户端(v3/TASK_1):{@code GET {form-center}/oapi/form/forms},
|
||||
* 给流程编辑器右侧"关联表单"选择器供数。调用形态对齐 {@link FormCenterSchemaClient}
|
||||
* (命名目标 {@code form-center},身份=本应用 appKey,签名由 starter 拦截器注入)。
|
||||
*
|
||||
* <p>能出现在这里的表单<b>已经过中心侧的 read 授权</b>:没授权给本应用的表压根不在这个目录里,
|
||||
* 所以编辑器不需要"选了才知道没权限"。反过来,目录里给了 {@code version}=中心当前发布版,
|
||||
* 保存时按 {@code formUuid + formVersion} 成对落库,发布再按这组确切坐标核验一次
|
||||
* ({@link FormCenterSchemaClient#fetchSchema(Long, String, Integer)})。</p>
|
||||
*
|
||||
* <p>空 data 按"目录不可用"拒而不是回一个空列表——空列表在页面上长得像"这所学校没有可用表单",
|
||||
* 会把"授权没配"这件事藏起来。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class FormCenterCatalogClient {
|
||||
|
||||
/** 服务目录 key(同 FormCenterSchemaClient,两级共用一个目标) */
|
||||
static final String TARGET = "form-center";
|
||||
static final String FORMS_PATH = "/oapi/form/forms";
|
||||
|
||||
private final YschOpenCall openCall;
|
||||
|
||||
public FormCenterCatalogClient(YschOpenCall openCall) {
|
||||
this.openCall = openCall;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param schId CAS 会话派生的管理域(由 {@code FlowAdminGuard} 给,不接受前端传值)
|
||||
* @return {@code {items,total}};{@code total}=中心过滤后可读的总条数
|
||||
*/
|
||||
public CatalogPage listForms(Long schId, String appCode, String keyword, Integer page, Integer pageSize) {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
if (notBlank(appCode)) {
|
||||
query.put("appCode", appCode.trim());
|
||||
}
|
||||
if (notBlank(keyword)) {
|
||||
query.put("keyword", keyword.trim());
|
||||
}
|
||||
if (page != null) {
|
||||
query.put("page", String.valueOf(page));
|
||||
}
|
||||
if (pageSize != null) {
|
||||
query.put("pageSize", String.valueOf(pageSize));
|
||||
}
|
||||
try {
|
||||
CatalogPage data = openCall.get(TARGET, FORMS_PATH, query, CatalogPage.class);
|
||||
if (data == null) {
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录返回空,请稍后重试");
|
||||
}
|
||||
return data;
|
||||
} catch (BizException e) {
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("[flow-center] 表单中心目录拉取失败 schId={} {}", schId, e.toString());
|
||||
throw new BizException(ErrorCode.PARAM_ERROR, "表单中心目录不可达(网络/授权未配置):" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean notBlank(String s) {
|
||||
return s != null && !s.trim().isEmpty();
|
||||
}
|
||||
|
||||
/** 目录页(形状=中心 GET /oapi/form/forms 的 data) */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CatalogPage {
|
||||
private List<CatalogItem> items;
|
||||
private long total;
|
||||
}
|
||||
|
||||
/** 目录行:选中后按 formUuid + version 成对保存 */
|
||||
@Getter
|
||||
@Setter
|
||||
public static class CatalogItem {
|
||||
private String formUuid;
|
||||
private String code;
|
||||
private String name;
|
||||
private String appCode;
|
||||
private Integer version;
|
||||
private List<String> audience;
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,19 @@ public class FormCenterSchemaClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快照<b>坐标与请求不符</b>(v3/TASK_1):请求 {@code uuid+v} 却拿回另一张表或另一个版本。
|
||||
*
|
||||
* <p>与 {@link SchemaUnavailableException} 分开,是因为重试对它没有意义——同一个请求重放同一个答案,
|
||||
* 真正要改的是关联(表单换校了、版本行被并了、中心某条分支回落到"当前发布版"了)。
|
||||
* 发布期一律阻断,且文案要点名"不符"而不是"拉取失败",否则运维会往网络方向查。</p>
|
||||
*/
|
||||
public static class SchemaContractViolationException extends RuntimeException {
|
||||
public SchemaContractViolationException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。
|
||||
* fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关);
|
||||
@@ -78,6 +91,60 @@ public class FormCenterSchemaClient {
|
||||
"关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
|
||||
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
|
||||
*
|
||||
* <p>刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为
|
||||
* ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。
|
||||
* 新管理链(def 带 {@code formVersion})只走本方法。</p>
|
||||
*/
|
||||
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
|
||||
if (version == null) {
|
||||
throw new IllegalArgumentException("按确切版本取快照必须给 version(缺版本=取当前发布版,那是旧口径)");
|
||||
}
|
||||
RuntimeException last = null;
|
||||
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
Map<String, String> query = new HashMap<>();
|
||||
query.put("schId", String.valueOf(schId));
|
||||
query.put("uuid", formUuid);
|
||||
query.put("version", String.valueOf(version));
|
||||
Map<String, Object> data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
|
||||
if (data == null) {
|
||||
throw new IllegalStateException("schema 返回空 data");
|
||||
}
|
||||
Map<String, Object> snapshot = toSnapshot(data);
|
||||
requireCoordinate(snapshot, formUuid, version);
|
||||
return snapshot;
|
||||
} catch (SchemaContractViolationException e) {
|
||||
throw e;
|
||||
} catch (RuntimeException e) {
|
||||
last = e;
|
||||
log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}",
|
||||
attempt, MAX_ATTEMPTS, formUuid, version, e.toString());
|
||||
} catch (Exception e) {
|
||||
last = new IllegalStateException(e.toString(), e);
|
||||
log.warn("[flow-center] form-center schema 精确版本拉取异常(第 {}/{} 次):uuid={} v={} {}",
|
||||
attempt, MAX_ATTEMPTS, formUuid, version, e.toString());
|
||||
}
|
||||
}
|
||||
throw new SchemaUnavailableException(
|
||||
"关联表单 v" + version + " 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
|
||||
}
|
||||
|
||||
/** 返回的 formUuid/formVersion 必须与请求一致(快照嵌进 def 版本行后,这就是当时的契约坐标) */
|
||||
private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) {
|
||||
Object gotUuid = snapshot.get("formUuid");
|
||||
Object gotVersion = snapshot.get("formVersion");
|
||||
if (gotUuid == null || gotVersion == null
|
||||
|| !String.valueOf(gotUuid).equals(formUuid)
|
||||
|| Integer.parseInt(String.valueOf(gotVersion)) != version) {
|
||||
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
|
||||
+ formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/** SchemaView(data) → 快照节(fields 扁平化 + audience 数组归一) */
|
||||
private Map<String, Object> toSnapshot(Map<String, Object> data) {
|
||||
Map<String, Object> snap = new LinkedHashMap<>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user