diff --git a/docs/schema-extension-compatibility-next-tasks.md b/docs/schema-extension-compatibility-next-tasks.md
new file mode 100644
index 0000000..5fac708
--- /dev/null
+++ b/docs/schema-extension-compatibility-next-tasks.md
@@ -0,0 +1,118 @@
+# FL公共schema扩展兼容边界与下一批任务草案(TASK_4)
+
+日期:2026-10-22。状态:文档检查完成;没有实施显隐/矩阵/量表,也没有放宽FC白名单或修改路由算法。伴随契约:[FC草案](../../../form-center/service/docs/schema-extension-draft.md)。本相对链接跨目录时应以工作区根目录的 `form-center/service/docs/schema-extension-draft.md` 为准。
+
+## 1. HEAD与当前锚点
+
+FL后端HEAD=`2f54f37ab34ca71410ec854f1adcc1da7c4dbe5f`;FC后端HEAD=`b2437a20742bfbfe38a564134904f3e0d7d931a3`;FC前端HEAD=`2e406a88b1d9e09b2ca54a8cf68a3726174f49c0`。均有任务实施后的未提交工作区内容,不能写成已合入。
+
+FL / `src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java` / `FormCenterSchemaClient#toSnapshot(Map)`:下段已与当前HEAD的git show重新对照,当前工作区仍保留这段搬运规则:
+
+```java
+field.put("key", f.path("key").asText(null));
+field.put("type", f.path("type").asText(null));
+JsonNode required = f.get("required");
+field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false));
+```
+
+同方法对数组options搬运value(缺value时回落label)为enum;顶层快照为formUuid/formVersion/audience/fields。它没有完整保留field.valueSchema、visible_when、pattern、min/max、precision或复合值声明;更没有执行这些规则。`type`字符串被带入不能证明引擎支持复杂值。
+
+FL / `service/FlowDefAdminService#save/#publish/#enable`:当前工作区在保存绑定前核验确切版本,升版继承formUuid/formVersion,取消关联清空坐标;发布以 `fetchSchema(schId,uuid,version)` 核对返回坐标后嵌入contentJson.formContract。此管理行为属于Task1及本轮代码修复,**不能声称存在于上述HEAD**。已发布/停用版本的快照不能原地重写;修改关联走新草稿。旧两参fetchSchema仍是内部方法,不作为新管理链的无版本入口。
+
+FC / `common/FormSchemaValidator#validateField`:
+
+```java
+rejectUnknownProps(field, ALLOWED_PROPS, "字段属性(" + key + ")");
+```
+
+FC / `common/FormValueValidator#validate`先处理required再分派类型;当前没有显隐计算。FC / `common/FormValueSchemaBuilder#withValueSchemas`只生成读侧机读描述;前端 `types/formSchema.ts` / `ALLOWED_FIELD_PROPS`及 `components/fields/index.ts#fieldWidget`限定当前控件。根未知属性未统一拒绝,不构成安全规则兼容机制。
+
+## 2. 当前边界与未来兼容矩阵
+
+| FC值/候选类型 | 当前schema/快照事实 | 未来FL路由要求(未实施) |
+|---|---|---|
+| number | JSON number;FC校验边界/precision;FL快照只保留type等少量字段 | 显式数值域及空值语义;不能从缺失边界推定任意数值合法 |
+| select/radio | 稳定value字符串;FL有enum | 用value比较,不能按label;value缺失不能静默用展示文案给新契约兜底 |
+| multiselect/checkbox | string[];快照有type及enum,但无完整array约束 | 禁止把整个数组当标量eq/gt操作数;若需contains等运算,必须另立版本化运算符及集合语义 |
+| upload/image | [{id,fileName,key}];机读形状未完整进入FL快照 | 禁止对象/数组转字符串或比较附件URL作为路由;文件访问权限仍在FC |
+| display metadata | 当前无通用跨端透传保证 | 获批后可在专用命名空间保留,不能执行或改变审批判据 |
+| visible_when | FC字段属性白名单拒绝,FL无执行器 | 必需规则不受支持则拒绝发布/发起;不得只在编辑器隐藏后继续按旧快照执行 |
+| matrix/scale | 未冻结/未放行 | 拒绝复合值标量路由,或只消费显式版本化投影;两种策略实施前定稿 |
+
+当前表达式校验与运行仍按现有引擎行为;上表右列是下一批要求,不冒充已存在的“复杂值安全闸”。不得为了通过候选schema而先修改PHASE1_TYPES、ALLOWED_PROPS或表达式白名单。
+
+## 3. 能力协商与投影候选
+
+下一版formContract应明确schemaContractVersion、requiredCapabilities以及字段完整值形状;具体字段名尚为候选。unsupported required capability必须阻断发布,不能丢掉字段后继续成功。已发布流程继续使用其原快照,不被FC新能力升级暗中改变。
+
+若选择投影,应明确:
+
+- 稳定命名空间及命名冲突规则(候选如`projection.satisfaction_score`,当前表达式是否支持点号必须先核实,不能直接采用)。
+- 投影算法版本、输入表单版本、rowKey/枚举值域及顺序,禁止label参与算法。
+- 全部可见、部分空值、整题隐藏、不适用、非法复合结构的输出分别定义;hidden不能自动映射为0,null不能自动映射为空字符串。
+- 谁计算并校验投影、在哪个可信边界附带它、如何进入revision/hash;不能信任浏览器额外上报的分数。
+- 对象键顺序与数组顺序遵循已有规范化;如改变快照成员或哈希规则必须升协议,不原地改fc.canon.v1。
+
+| 场景 | 候选输出/行为 |
+|---|---|
+| 已知row、有效值、题目可见 | 按已冻结投影算法产出标量/枚举 |
+| 题目隐藏 | 输出显式不适用标记或拒绝该路由;不得假装0分,具体方案待定 |
+| 必答row缺失 | FC拒绝答卷,不向FL构造“补零”结果 |
+| 重复row/未知row/非法枚举 | 拒绝复合值,错误可定位,不执行路由 |
+| 旧schema无该题/旧流程无该能力 | 保持旧快照行为;需要新能力则升流程版本 |
+
+## 4. 下一批任务书草案(均未授权实施)
+
+这些文件已分别属于Task1/2或历史功能,实施前必须重新分配写权限;不能在Task3分支顺手追加。以下为成对功能包,**不是可同时执行的互不相交白名单**。正式拆分时要核对真实路径、确定独占文件集和按序依赖。
+
+### E1:公共扩展版本及静态规则校验
+
+背景:先建立能力协商及拒绝边界,避免静默丢规则。
+
+改动候选:FC `common/FormSchemaValidator#validateSchema/#validateField/#validateOptions`、`common/FormValueSchemaBuilder#withValueSchemas`;FC前端 `types/formSchema.ts`、设计器序列化路径;拟新增独立规则AST/能力清单类(名称待实施时确定)。必须同时处理根/group/field/options各层保留与拒绝规则,不能只放宽字段白名单。
+
+行为:先校验契约版本与必需能力,再核对引用/类型/循环/资源预算;未知安全规则拒绝,允许的display metadata仅保留不执行。valueSchema仍由服务端派生。
+
+依赖/约束:重新分配Task1 schema/目录契约相关文件;不改AppRegistry或部门模型,不引入eval。
+
+验收计划:未知根/字段/选项、循环依赖、类型不符、超预算、保留metadata但不执行、旧schema保持原行为。此处仅列计划,本轮不写、不跑测试。
+
+### E2:可见性规则的全写入通道与普通题型回归
+
+背景:显隐不是纯前端效果,必须覆盖自填、匿名、hosted、本人/管理员编辑和导入。
+
+改动候选:FC `service/AnswerService#doSubmit`、`service/AnswerReadService#editBySubmitter`、`service/AnswerAdminService#edit`、`service/AnswerImportService`、公共FormValueValidator;FC前端 `views/Fill.vue`、字段渲染/校验及编辑入口。具体编辑组件路径在正式任务前用rg重新定位,不虚构已存在文件名。
+
+行为:取钉版schema,执行同一规则服务,先visible再required;隐藏值A/B策略必须先获定稿。导入预检和执行使用同一版本;本人/匿名身份边界和hosted本人边界保持;索引/outbox基于实际存储结果更新。
+
+依赖/约束:E1完成且隐藏值策略定稿;Answer/投递/Fill属于Task2,需重新分配;OPEN-T19b原有代填不是此任务取消对象。
+
+验收计划:number=0、false、select value、空数组、多选顺序、upload归属;切换上游使下游隐藏;旧答卷按原版编辑;匿名与hosted隔离;导入同规则、原子回滚与hash一致。
+
+### E3:统计导出与历史解释
+
+改动候选:FC `service/AnswerStatsService`、`AnswerExportService`、`AnswerIndexService`和已有统计/导出前端(路径须正式定位);矩阵渲染与行列编辑组件仅在题型获批后新增。
+
+行为:区分N/E/V/A四类分母;逐答卷版本解释字段/规则,导出不适用与空值不同;历史读取不修改答卷。不跨版本同key暗合并,不把多选计次当人数。
+
+依赖/约束:E2后才有稳定写入语义;不得用当前schema重解释历史数据。矩阵object/array结构、rowKey、算法上限先定稿。
+
+验收计划:版本间增删题、隐藏/可见未答、V=0、多选计数、矩阵每行/整题分母、字典label变动、历史附件及业务扩展保留。
+
+### E4:FC—FL成对快照、复杂值拒绝或投影
+
+改动候选:FC `service/OapiFormService`/机读schema契约;FL `service/FormCenterSchemaClient#toSnapshot`、`FlowDefAdminService#publish`、`FlowInstanceService`输入校验及`engine/FlowDefModel`/相关表达式验证入口;FL前端定义编辑器及表达式提示。调用端与服务端在一个任务中交付,不硬拆端点和调用。
+
+行为:先识别required capabilities,再保存完整契约快照;不支持的安全能力拒绝发布。complex值不走标量比较,或仅使用显式受信投影。新绑定保存/发布均核验确切formUuid/version;旧快照只读,改动走新流程版本。
+
+依赖/约束:E1能力格式稳定,E2/E3值与统计口径稳定;Task1版本绑定、Task2流程发起文件需重新分配;不改部门节点策略,不把矩阵处理塞进回调恢复。
+
+验收计划:array/object标量路由拒绝、投影边界与null、返回坐标不符、撤权、跨版本不可变快照、普通表单仍可发布/发起。资源失败必须有稳定码,不把“网络不可达”误当“不支持能力”。
+
+## 5. 本轮交付检查
+
+- 当前白名单、11类控件、根/group/field/options边界、FC验证与FL快照搬运已逐项对照源码。
+- 样例均明确标注候选;隐藏值A/B未被私自定稿,matrix/scale未被写成已实现。
+- 数值/单选/多选/附件及所有写入、编辑、导入、统计、导出的版本来源详见FC伴随草案。
+- L1状态:**文档检查完成,功能测试未执行**。按用户本轮指令只做编译/跳过测试打包;构建结果记录在根目录docs/tasks/evidence/refinement-20261022,不构成候选功能验收。
+- 后续具体测试与生产联调方案另出;本轮不连接真实库/MQ、不执行DDL、不启动对账、不做前端视觉验收。
diff --git a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
index 0b8dd13..7da759d 100644
--- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
+++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java
@@ -45,6 +45,16 @@ public class FlowAdminController {
private final FlowAdminGuard adminGuard;
private final FlowDefAdminService defAdminService;
+
+ /**
+ * 表单中心目录客户端(v3/TASK_1)。
+ *
+ *
字段注入而不是加进构造参数:本类的构造器已被 {@code FlowAdminInstanceHungTest} 与
+ * {@code FlowDiagramServiceTest} 逐位钉住,而那两份测试不在本任务的可改清单里——多一个入参
+ * 会让整个测试树编译不过。
+ */
+ @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=保持、空串=取消关联、发布后不可改改=升版;
+ * formVersion=v3 确切版本(与 formUuid 成对,单传一个即拒;取消关联时同批清空)。
+ */
@PostMapping("/def/save")
public FlwDef defSave(BoaUser user, @RequestBody Map 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"))));
}
/**
diff --git a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
index e0f0fb5..a9f54be 100644
--- a/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
+++ b/src/main/java/com/yunhai/flowcenter/controller/oapi/OapiFlowController.java
@@ -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)。
+ *
+ * 与 {@code /apply} 是两条显式分支而不是一个端点加 flag:旧端点行为逐字不变
+ * (同 bizKey 命中 30/40 仍按 R4 重启推进),新端点声明"我带幂等守卫"——
+ * 同 {@code requestToken} 重投只回读现状,{@code retryOnly} 命中终态也不重启,
+ * 陈旧轮次拒绝重启。响应多带一个稳定分支码 {@code branch},消费方按码处置。
+ * 请求标识与轮次由消费方的持久化出站命令携带(svc-hall {@code flow_apply_command})。
+ */
+ @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,
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
index 09e60ef..edc7724 100644
--- a/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwDef.java
@@ -60,6 +60,20 @@ public class FlwDef extends BaseEntity {
@Column(name = "form_uuid", length = 32)
private String formUuid;
+ /**
+ * 关联表单的确切版本(v3/TASK_1):与 {@link #formUuid} 成对,发布期按这组坐标取快照,
+ * 中心返回的坐标与请求不符即阻断发布。
+ *
+ * 为什么必须钉版本而不是"发布时取当前发布版":同一条流程线的两次发布可能落到不同的表结构上,
+ * 而表达式校验、办件回显都按嵌入的 formContract 判——版本不钉住,"这条流程当时用的哪张表"
+ * 就成了只能猜的历史。存的是服务器侧核验过的版本号。
+ *
+ * null 只出现在本次改造之前已经关联了表单的存量行上(C1 第一刀没这列):
+ * 那种行发布时仍走"当前发布版"的旧口径,改造后新保存/新关联一律成对写入。
+ */
+ @Column(name = "form_version")
+ private Integer formVersion;
+
/** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
private String contentJson;
diff --git a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
index 5d5452d..42be69b 100644
--- a/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
+++ b/src/main/java/com/yunhai/flowcenter/entity/FlwInstance.java
@@ -129,6 +129,26 @@ public class FlwInstance extends BaseEntity {
@Column(name = "version", nullable = false)
private Integer version;
+ /**
+ * 最近一次成功受理代发起的请求标识(消费方持久化命令里带的 token,v2 §8,TASK_2 C.3)。
+ *
+ * 它回答的是"这次 apply 是同一命令的重投,还是新一轮发起"——biz uk 只说得出
+ * "这个业务键已经有办件了",说不出这句。没有它时,消费方在响应丢失后重发同业务键,
+ * 命中 30/40 会被 R4 语义重启推进,等于把网络重试当成用户重提。
+ * 同 token 一律只回读现状、不重启;换 token 才是新一轮。
+ */
+ @Column(name = "apply_request_token", length = 64)
+ private String applyRequestToken;
+
+ /**
+ * 当前发起轮次(1 起;随新一轮请求标识进位)。
+ *
+ * 轮次守卫是单调的:请求带来的 attempt 低于行上的 applyRound 就是陈旧轮次的迟到重投,
+ * 只回读现状。两侧(消费方命令行 + 本行)都持久化,崩溃恢复后仍能判出"哪一轮才是当前轮"。
+ */
+ @Column(name = "apply_round")
+ private Integer applyRound;
+
/**
* 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充,
diff --git a/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
new file mode 100644
index 0000000..946caf3
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/model/FlowApplyRetryModels.java
@@ -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)。
+ *
+ * 为什么是独立 DTO 而不是往 {@code OapiModels.ApplyBody} 加字段(任务书原文要求):
+ * 旧 ApplyBody 是多个消费应用共用的线上契约,往里塞"重投标识/轮次"会让所有消费方
+ * 都要重新理解一遍字段语义;更重要的是,请求标识的分支行为只对新端点成立——
+ * 旧端点保持逐字不变,新端点显式声明"我带守卫"。两侧分支写清楚,比共用一个 DTO 加 if 诚实。
+ *
+ * 继承旧 body 是为了让 {@code /oapi/flow/apply/v2} 的接收形状与旧端点完全一致
+ * (消费方只多带三个字段),不是为了共享可变字段。
+ */
+public final class FlowApplyRetryModels {
+
+ private FlowApplyRetryModels() {
+ }
+
+ /** v2 请求体:旧 apply 全字段 + 请求标识/轮次/仅补发开关 */
+ @Getter
+ @Setter
+ @JsonIgnoreProperties(ignoreUnknown = true)
+ public static class ApplyRequestV2 extends OapiModels.ApplyBody {
+ /**
+ * 本次命令的请求标识(消费方持久化后携带,重投同值)。
+ * 与行上已落的标识相同 ⇒ 同一命令的重投 ⇒ 只回读现状,绝不重启推进。
+ */
+ private String requestToken;
+ /** 发起轮次(1 起,单调);低于行上 applyRound 的是陈旧轮次迟到重投 */
+ private Integer attempt;
+ /**
+ * true=只补发"确实还没有办件"的情况:命中既有实例一律回读现状,
+ * 即使处于 30/40 也不触发 R4 重启。恢复链在"查无才补发"之分支上用它,
+ * 这样"网络重投"与"用户授权重提"在中心侧也是两个不同的事实。
+ */
+ private Boolean retryOnly;
+ }
+
+ /** v2 响应:在旧 {instanceId,duplicated} 之上把"中心判出来的是哪一种命中"说清楚 */
+ @Data
+ public static class ApplyResultV2 {
+ private Long instanceId;
+ private boolean duplicated;
+ /**
+ * 稳定分支码(消费方按码处置,不靠文案):
+ *
+ * - {@code CREATED}:新建办件并推进;
+ * - {@code IDEMPOTENT}:在途/已完成(10/20)命中,纯幂等返回;
+ * - {@code SAME_REQUEST}:同 requestToken 的重投命中,未重启;
+ * - {@code STALE_ROUND}:请求轮次陈旧,未重启;
+ * - {@code REPLAY_ONLY}:retryOnly 命中既有实例(含 30/40),未重启;
+ * - {@code RESTARTED}:新一轮授权重提,R4 重启推进。
+ *
+ */
+ 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() {
+ }
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
index 4d01424..001e9d1 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java
@@ -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 位)。
* 发布后不可改,改=升版本由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
- * 非空=设置(不做格式强校验——寻址失败在发布期由快照拉取阻断,错误不进库但也不卡保存)。
+ * 非空=设置,保存前按确切版本远端核验,失败不写入。
+ *
v3/TASK_1 追加 {@code formVersion}:与 formUuid 成对,规则是——
+ * ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
+ * ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
+ * ③ formUuid 给空串=取消关联,同时清空版本(留着一个没人认领的版本号是下一轮误用的起点);
+ * ④ 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 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 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 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 versions(Long schId, String flowGuid) {
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, flowGuid);
diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
index 5797308..8f8cdcc 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FlowInstanceService.java
@@ -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 自增、轨迹跨轮连续)。
+ * 新增的两个参数只是随行落库的请求标识与轮次,不改任何节点算法(任务书 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) ====================
+
+ /**
+ * 守卫参数。刻意不做成 {@code ApplyCommand} 的新字段:那个 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} 唯一入口)。
+ *
+ * 判定顺序(每一支都只回读现状,不改流程状态机一行算法):
+ *
+ * - 先核归属:行上的调用应用/提交者/流程线与本次请求不符 ⇒ 拒绝,
+ * 不能把别的应用相同 bizType 键当自己的结果返回(C.3)。
+ * 应用身份两侧都非空才判(本地无签名的历史行为 null,不因此把存量件判死);
+ * - 同 {@code requestToken} 命中 ⇒ {@code SAME_REQUEST}:这是同一次命令的重投,
+ * 即使行已终态也不重启——那正是"响应丢失后重发"与"用户重提"的分界;
+ * - 10/20 命中 ⇒ {@code IDEMPOTENT}(既有语义不变);
+ * - 30/40 命中:{@code retryOnly=true} ⇒ {@code REPLAY_ONLY} 不重启;
+ * 请求轮次 ≤ 行轮次 ⇒ {@code STALE_ROUND} 不重启;
+ * 否则才是新一轮授权重提 ⇒ {@code RESTARTED}(走既有 R4 重启推进);
+ * - 无行 ⇒ 新建 + {@code CREATED}。
+ *
+ */
+ @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;
+ }
}
diff --git a/src/main/java/com/yunhai/flowcenter/service/FormCenterCatalogClient.java b/src/main/java/com/yunhai/flowcenter/service/FormCenterCatalogClient.java
new file mode 100644
index 0000000..9362802
--- /dev/null
+++ b/src/main/java/com/yunhai/flowcenter/service/FormCenterCatalogClient.java
@@ -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 拦截器注入)。
+ *
+ * 能出现在这里的表单已经过中心侧的 read 授权:没授权给本应用的表压根不在这个目录里,
+ * 所以编辑器不需要"选了才知道没权限"。反过来,目录里给了 {@code version}=中心当前发布版,
+ * 保存时按 {@code formUuid + formVersion} 成对落库,发布再按这组确切坐标核验一次
+ * ({@link FormCenterSchemaClient#fetchSchema(Long, String, Integer)})。
+ *
+ * 空 data 按"目录不可用"拒而不是回一个空列表——空列表在页面上长得像"这所学校没有可用表单",
+ * 会把"授权没配"这件事藏起来。
+ */
+@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 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 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 audience;
+ }
+}
diff --git a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
index 9bae793..2eacddd 100644
--- a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
+++ b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java
@@ -47,6 +47,19 @@ public class FormCenterSchemaClient {
}
}
+ /**
+ * 快照坐标与请求不符(v3/TASK_1):请求 {@code uuid+v} 却拿回另一张表或另一个版本。
+ *
+ * 与 {@link SchemaUnavailableException} 分开,是因为重试对它没有意义——同一个请求重放同一个答案,
+ * 真正要改的是关联(表单换校了、版本行被并了、中心某条分支回落到"当前发布版"了)。
+ * 发布期一律阻断,且文案要点名"不符"而不是"拉取失败",否则运维会往网络方向查。
+ */
+ 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);
}
+ /**
+ * 发布期快照的确切版本形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
+ * 并把返回坐标与请求坐标逐件核对——不符即 {@link SchemaContractViolationException}(不重试)。
+ *
+ * 刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为
+ * ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。
+ * 新管理链(def 带 {@code formVersion})只走本方法。
+ */
+ public Map 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 query = new HashMap<>();
+ query.put("schId", String.valueOf(schId));
+ query.put("uuid", formUuid);
+ query.put("version", String.valueOf(version));
+ Map data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class);
+ if (data == null) {
+ throw new IllegalStateException("schema 返回空 data");
+ }
+ Map 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 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 toSnapshot(Map data) {
Map snap = new LinkedHashMap<>();
diff --git a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
index 0693b14..d399120 100644
--- a/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
+++ b/src/main/java/com/yunhai/flowcenter/service/OapiFlowService.java
@@ -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
appIdentityProvider;
+ /**
+ * v2 发起的事务模板。用 setter/字段注入而不是加进构造器:本类构造被
+ * {@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 action) {
+ return transactionTemplate == null ? action.doInTransaction(null) : transactionTemplate.execute(action);
+ }
+
/** 已发布流程列表(每条流程线取最高已发布版本) */
public List listFlows(Long schId) {
List out = new ArrayList<>();
@@ -144,6 +165,100 @@ public class OapiFlowService {
return vo;
}
+ /**
+ * 带请求标识/轮次守卫的代发起({@code POST /oapi/flow/apply/v2},v2 §8,TASK_2 C.3)。
+ *
+ * 与 {@link #apply} 的三点不同,都是"幂等修复"要求而不是新功能:
+ *
+ * - 事务边界收窄到一次发起:用 {@code TransactionTemplate} 而不是方法级
+ * {@code @Transactional}——唯一键冲突必须在事务外重新读既有实例
+ * (撞键后当前事务已被标记回滚,事务内再读不可靠),这与 svc-hall
+ * {@code AnswerService#executeSubmit} 同一形态;
+ * - 并发撞键后先核归属再返回:读到的实例若属于另一应用/另一提交者/另一流程线,
+ * 一律拒绝,不把别人的 bizType+bizId 当自己的结果;
+ * - 回带稳定分支码(CREATED/IDEMPOTENT/SAME_REQUEST/STALE_ROUND/REPLAY_ONLY/
+ * RESTARTED/CONCURRENT_CREATED),消费方按码处置,不再猜"duplicated 是哪一种重复"。
+ *
+ *
+ * 已发布定义校验、提交人本校校验与旧路径同一条,不放宽;流程节点算法一行未改。
+ */
+ 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;
+ }
+
+ /**
+ * 并发撞键后的收口:重读既有实例 → 先核归属 → 同 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;
diff --git a/src/main/resources/sql/add_flw_apply_request_identity.sql b/src/main/resources/sql/add_flw_apply_request_identity.sql
new file mode 100644
index 0000000..e11ba69
--- /dev/null
+++ b/src/main/resources/sql/add_flw_apply_request_identity.sql
@@ -0,0 +1,30 @@
+-- ============================================================
+-- 流程中心 · 代发起请求标识与轮次 DDL(MySQL 5.7 兼容复核版,TASK_2 C.3)
+-- 范围:《answer-delivery-v2.md》§8 = flw_instance 加两列 apply_request_token / apply_round
+-- 依据:任务书 TASK_2 C.2/C.3——"补充 FL 请求标识/当前轮次守卫时,SH 发送与 FL 校验同任务完成"
+-- 用途:生产 ddl-auto=none 时的升级脚本;开发期(本机 bsch_flowcenter,ddl-auto=update)由
+-- Hibernate 自动追加,本脚本同时是"实表应当长什么样"的评审权威。
+-- 这两列解决什么:biz uk(sch_id,biz_type,biz_id) 只说得出"这个业务键已经有办件了",
+-- 说不出"这次 apply 是同一次命令的重投,还是新一轮授权重提"。缺这句时,消费方
+-- 在响应丢失后按同业务键补发,命中 30/40 会被 R4 语义重启推进——等于把网络重试
+-- 当成用户重提,把已被拒绝/已撤回的办件又跑起来(任务书 C.2 点名禁止)。
+-- 存量口径(关键):**不回填、留 NULL**。apply_round 为 NULL 的行在新守卫里按"第 1 轮"处理,
+-- 于是"请求轮次 ≤ 行轮次 ⇒ 陈旧轮次不重启"对存量件天然成立——存量行的守卫缺省是
+-- 关而不是开:想让老件走新一轮,必须由消费方显式带 attempt≥2(用户授权重提)。
+-- 回填成 0 会反过来让任何 attempt=1 的重投都能重启存量件,那是最容易埋雷的写法。
+-- 索引:不加 token 索引——守卫判据是"读到的那一行自己的列比对",没有按 token 反查的路径;
+-- 建索引只会给写入加成本,换不到任何读收益(评审时别问"为什么不索引")。
+-- 非幂等:执行前先 `SHOW COLUMNS FROM flw_instance LIKE 'apply_request_token'` 确认列不存在。
+-- ============================================================
+
+ALTER TABLE `flw_instance`
+ ADD COLUMN `apply_request_token` VARCHAR(64) DEFAULT NULL
+ COMMENT '最近一次受理代发起的请求标识(消费方持久化命令携带);同值重投=同一命令,只回读现状不重启推进',
+ ADD COLUMN `apply_round` INT DEFAULT NULL
+ COMMENT '当前发起轮次(1 起、单调);NULL=本列上线前的存量行,守卫按第 1 轮处理(缺省不放开重启)';
+
+-- 自验(执行后逐条核对):
+-- SHOW COLUMNS FROM flw_instance LIKE 'apply%'; -- 应只有 apply_app_key/apply_app_code/
+-- -- apply_request_token/apply_round 四列
+-- SELECT COUNT(*) FROM flw_instance WHERE apply_round IS NOT NULL; -- 刚执行完必须 0(不回填)
+-- SELECT COUNT(*) FROM flw_instance WHERE apply_round = 0; -- 必须 0(0 会打开存量重启窗口)
diff --git a/src/main/resources/sql/add_flw_def_form_version.sql b/src/main/resources/sql/add_flw_def_form_version.sql
new file mode 100644
index 0000000..00c3d94
--- /dev/null
+++ b/src/main/resources/sql/add_flw_def_form_version.sql
@@ -0,0 +1,12 @@
+-- flw_def 增加 form_version(与 form_uuid 成对的"确切版本"坐标)
+-- 契约:form-center/service/docs/consumer-access-v3.md §4;依据 VERIFICATION V07 + 原骨架 T05 的确切版本读取部分
+-- 业务:发布期按 (form_uuid, form_version) 调 GET /oapi/form/schema?uuid=&version= 取快照,
+-- 中心返回的坐标与请求不符即阻断发布;已发布快照不改写,改关联=走新版本(既有升版分派)。
+-- 可空口径:存量行(C1 第一刀只有关联、没有版本)留 NULL,发布时仍按"当前发布版"旧口径取快照,
+-- 行为逐字不变;本次改造后新保存/新关联一律成对写入。NULL 不代表"任意版本",只代表"这条行是本列之前存的"。
+-- 取消关联=两列同时清空(单传一个在保存链路即拒,见 FlowDefAdminService#save)。
+-- 本地/测试 ddl-auto=update 会自动加列;生产执行本脚本。
+-- MySQL 5.7:无 ADD COLUMN IF NOT EXISTS(重复执行报 Duplicate column name,需在外部判重)。
+
+ALTER TABLE `flw_def`
+ ADD COLUMN `form_version` INT NULL COMMENT '关联表单的确切版本(与 form_uuid 成对;发布期按此核验快照坐标)' AFTER `form_uuid`;
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
new file mode 100644
index 0000000..2dc0793
--- /dev/null
+++ b/src/test/java/com/yunhai/flowcenter/service/FlApplyRecoveryTest.java
@@ -0,0 +1,334 @@
+package com.yunhai.flowcenter.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.flowcenter.engine.FlowDefModel;
+import com.yunhai.flowcenter.engine.FlowEngine;
+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 org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.dao.DataIntegrityViolationException;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 代发起幂等守卫与恢复分支的单测(answer-delivery-v2 §8,TASK_2 C.2/C.3)。
+ *
+ * 要证的正是任务书点名的那一类事故:消费方响应丢失后按同业务键补发,绝不能被中心
+ * 当成"用户又重提了一次"。旧形状下 biz uk 命中 30/40 就走 R4 重启推进,把已拒绝/已撤回
+ * 的办件重新跑起来;新分支用 {@code requestToken}/{@code attempt}/{@code retryOnly}
+ * 把"同一次命令的重投"与"新一轮授权重提"分成两个事实。
+ *
+ * 同时钉住三条边界:
+ *
+ * - 并发唯一键冲突在事务边界外重读,且先核调用应用/提交者/流程线——
+ * 别的应用相同 bizType 键不作为自己的结果返回;
+ * - 存量行({@code apply_round} 为 NULL)按第 1 轮处理,守卫缺省是关:
+ * 没带更高轮次就重启不了别人的老件;
+ * - 既有 R4 重提语义与流程节点算法一行未改({@code RESTARTED} 分支才走 restart)。
+ *
+ */
+@DisplayName("代发起请求标识与轮次守卫 - FL 侧规则单测(TASK_2)")
+class FlApplyRecoveryTest {
+
+ private static final long SCH = 1L;
+ private static final String BIZ_TYPE = "svc-hall";
+ private static final String BIZ_ID = "42";
+ private static final String FLOW_GUID = "fg-1";
+ private static final String TOKEN_A = "tok-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ private static final String TOKEN_B = "tok-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ private FlwInstanceRepository instanceRepository;
+ private FlwDefRepository defRepository;
+ private FlwTaskRepository taskRepository;
+ private FlwBranchRepository branchRepository;
+ private FlowEngine flowEngine;
+ private ApplicationEventPublisher eventPublisher;
+ private FlowInstanceService service;
+
+ @BeforeEach
+ void setUp() {
+ instanceRepository = mock(FlwInstanceRepository.class);
+ defRepository = mock(FlwDefRepository.class);
+ taskRepository = mock(FlwTaskRepository.class);
+ branchRepository = mock(FlwBranchRepository.class);
+ flowEngine = mock(FlowEngine.class);
+ eventPublisher = mock(ApplicationEventPublisher.class);
+ service = new FlowInstanceService(instanceRepository, defRepository, taskRepository,
+ branchRepository, flowEngine, eventPublisher);
+
+ FlwDef def = def(1L, FLOW_GUID, 3);
+ when(defRepository.findById(anyLong())).thenReturn(Optional.of(def));
+ FlowDefModel model = FlowDefModel.parse(mapper, "{\"nodes\":[],\"edges\":[]}");
+ when(flowEngine.parseModel(any())).thenReturn(model);
+ when(instanceRepository.saveAndFlush(any())).thenAnswer(call -> {
+ FlwInstance row = call.getArgument(0);
+ row.setId(900L);
+ return row;
+ });
+ }
+
+ // ==================== 同一次命令的重投 ====================
+
+ @Test
+ @DisplayName("同 requestToken 命中被拒(30)的办件:只回读现状,绝不重启推进")
+ void sameTokenOnRejectedInstanceDoesNotRestart() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ FlowInstanceService.GuardedOutcome outcome =
+ service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
+
+ assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
+ assertThat(outcome.duplicated).isTrue();
+ assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
+ verify(flowEngine, never()).startAdvance(any(), any());
+ verify(branchRepository, never()).deleteByInstance(anyLong());
+ }
+
+ @Test
+ @DisplayName("同 requestToken 命中在途(10):同一条判定优先于幂等分支,回 SAME_REQUEST")
+ void sameTokenOnApprovingInstanceIsSameRequest() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
+ verify(flowEngine, never()).startAdvance(any(), any());
+ }
+
+ @Test
+ @DisplayName("retryOnly 命中终态(40):恢复链补发也不重启(C.2 点名不把 30/40 当网络重试)")
+ void retryOnlyNeverRestartsTerminal() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.WITHDRAWN.getCode(), TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ FlowInstanceService.GuardedOutcome outcome =
+ service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, true));
+
+ assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.REPLAY_ONLY);
+ assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.WITHDRAWN.getCode());
+ verify(flowEngine, never()).startAdvance(any(), any());
+ }
+
+ @Test
+ @DisplayName("10/20 命中不同 token:仍是纯幂等返回(既有语义逐字不变)")
+ void approvingOrCompletedStayIdempotent() {
+ FlwInstance approving = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(approving));
+ assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
+
+ FlwInstance completed = instance(900L, FlwEnums.InstanceStatus.COMPLETED.getCode(), TOKEN_A, 1, "100018");
+ assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.IDEMPOTENT);
+ verify(flowEngine, never()).startAdvance(any(), any());
+ }
+
+ // ==================== 轮次守卫 ====================
+
+ @Test
+ @DisplayName("更高轮次 + 新 token 命中 30:才是新一轮授权重提,走既有 R4 重启并推进")
+ void higherRoundRestartsPerExistingR4Semantics() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ FlowInstanceService.GuardedOutcome outcome =
+ service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false));
+
+ assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
+ assertThat(outcome.applyRound).isEqualTo(2);
+ assertThat(existing.getApplyRequestToken()).isEqualTo(TOKEN_B);
+ assertThat(existing.getApplyRound()).isEqualTo(2);
+ assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
+ verify(flowEngine).startAdvance(any(), any());
+ verify(branchRepository).deleteByInstance(900L);
+ verify(taskRepository).invalidatePendingByInstance(900L);
+ }
+
+ @Test
+ @DisplayName("轮次不高于行上轮次:陈旧轮次迟到重投只回读,不重启")
+ void staleRoundIsRejectedForRestart() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), TOKEN_A, 2, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
+ assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
+ verify(flowEngine, never()).startAdvance(any(), any());
+ }
+
+ @Test
+ @DisplayName("存量行(apply_round 为 NULL)按第 1 轮处理:attempt=1 重投重启不了它")
+ void legacyRowWithoutRoundDefaultsToGuarded() {
+ FlwInstance legacy = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(legacy));
+
+ assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.STALE_ROUND);
+ assertThat(legacy.getStatus()).isEqualTo(FlwEnums.InstanceStatus.REJECTED.getCode());
+
+ assertThat(service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 2, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.RESTARTED);
+ }
+
+ // ==================== 归属复核 ====================
+
+ @Test
+ @DisplayName("别的应用同 bizType+bizId:拒绝,不把别人的办件 id 当自己的结果")
+ void otherApplicationSameBizKeyIsRefused() {
+ FlwInstance foreign = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100020");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(foreign));
+
+ assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
+ .isInstanceOf(BizException.class).hasMessageContaining("另一调用应用");
+ }
+
+ @Test
+ @DisplayName("提交者不符 / 流程线不同:同样拒绝(守卫不是可协商的提示)")
+ void submitterAndFlowLineMustMatch() {
+ FlwInstance otherSubmitter = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
+ TOKEN_A, 1, "100018");
+ otherSubmitter.setSubmitterId(2002);
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(otherSubmitter));
+ assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
+ .isInstanceOf(BizException.class).hasMessageContaining("提交者");
+
+ FlwInstance otherFlow = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(),
+ TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(otherFlow));
+ when(defRepository.findById(anyLong())).thenReturn(Optional.of(def(2L, "fg-other", 1)));
+ assertThatThrownBy(() -> service.applyGuarded(cmd(TOKEN_B), policy(TOKEN_B, 1, false)))
+ .isInstanceOf(BizException.class).hasMessageContaining("流程线");
+ }
+
+ // ==================== 新建与并发 ====================
+
+ @Test
+ @DisplayName("无既有实例:新建并落 token/轮次,走 START 推进(守卫不影响首发路径)")
+ void createsWhenAbsentAndStampsIdentity() {
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.empty());
+
+ FlowInstanceService.GuardedOutcome outcome =
+ service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
+
+ assertThat(outcome.branch).isEqualTo(FlowApplyRetryModels.Branch.CREATED);
+ assertThat(outcome.duplicated).isFalse();
+ ArgumentCaptor saved = ArgumentCaptor.forClass(FlwInstance.class);
+ verify(instanceRepository).saveAndFlush(saved.capture());
+ assertThat(saved.getValue().getApplyRequestToken()).isEqualTo(TOKEN_A);
+ assertThat(saved.getValue().getApplyRound()).isEqualTo(1);
+ assertThat(saved.getValue().getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
+ verify(flowEngine).startAdvance(any(), any());
+ }
+
+ @Test
+ @DisplayName("并发唯一键撞键:applyV2 在事务外重读既有行并核归属,返回稳定分支码而不是 500")
+ void concurrentUkCollisionIsResolvedOutsideTransaction() {
+ // 撞键路径由 OapiFlowService 在事务边界外收口;这里证服务层两步读到的结果自洽
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.APPROVING.getCode(), TOKEN_A, 1, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.empty())
+ .thenReturn(Optional.of(existing));
+ assertThat(service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false)).branch)
+ .isEqualTo(FlowApplyRetryModels.Branch.CREATED);
+
+ // 第二次同 token:读到了并发对手刚建的行 → SAME_REQUEST(不重启、不重复建)
+ FlowInstanceService.GuardedOutcome second =
+ service.applyGuarded(cmd(TOKEN_A), policy(TOKEN_A, 1, false));
+ assertThat(second.branch).isEqualTo(FlowApplyRetryModels.Branch.SAME_REQUEST);
+ assertThat(second.instanceId).isEqualTo(900L);
+ }
+
+ @Test
+ @DisplayName("旧 apply(cmd) 路径逐字不变:不写 token/轮次,30/40 仍按 R4 重启(存量兼容)")
+ void legacyApplyPathIsUnchanged() {
+ FlwInstance existing = instance(900L, FlwEnums.InstanceStatus.REJECTED.getCode(), null, null, "100018");
+ when(instanceRepository.findBySchIdAndBizTypeAndBizId(SCH, BIZ_TYPE, BIZ_ID))
+ .thenReturn(Optional.of(existing));
+
+ FlowInstanceService.ApplyOutcome outcome = service.apply(cmd(null));
+
+ assertThat(outcome.isDuplicated()).isTrue();
+ assertThat(existing.getStatus()).isEqualTo(FlwEnums.InstanceStatus.APPROVING.getCode());
+ assertThat(existing.getApplyRequestToken()).isNull();
+ assertThat(existing.getApplyRound()).isNull();
+ }
+
+ // ==================== 夹具 ====================
+
+ /** 提交者恒 1001、调用应用恒 100018;不符的一侧都在被测行上构造 */
+ private FlowInstanceService.ApplyCommand cmd(String token) {
+ return new FlowInstanceService.ApplyCommand(SCH, def(1L, FLOW_GUID, 3), BIZ_TYPE, BIZ_ID,
+ "张三的测试事项", "/svc-hall/apply-detail?id=42", "{}", 1001,
+ "张三", "{}", "100018", "svc-hall", null);
+ }
+
+ private FlowInstanceService.ApplyRetryPolicy policy(String token, Integer attempt, boolean retryOnly) {
+ return new FlowInstanceService.ApplyRetryPolicy(token, attempt, retryOnly);
+ }
+
+ private FlwDef def(Long id, String flowGuid, int version) {
+ FlwDef def = new FlwDef();
+ def.setId(id);
+ def.setSchId(SCH);
+ def.setFlowGuid(flowGuid);
+ def.setCode("leave");
+ def.setName("请假");
+ def.setVersion(version);
+ def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
+ def.setContentJson("{\"nodes\":[],\"edges\":[]}");
+ return def;
+ }
+
+ private FlwInstance instance(Long id, int status, String token, Integer round, String appKey) {
+ FlwInstance instance = new FlwInstance();
+ instance.setId(id);
+ instance.setSchId(SCH);
+ instance.setDefId(1L);
+ instance.setDefCode("leave");
+ instance.setDefVersion(3);
+ instance.setBizType(BIZ_TYPE);
+ instance.setBizId(BIZ_ID);
+ instance.setStatus(status);
+ instance.setApplyAppKey(appKey);
+ instance.setApplyRequestToken(token);
+ instance.setApplyRound(round);
+ instance.setSubmitterId(1001);
+ instance.setSubmitterName("张三");
+ instance.setVersion(0);
+ return instance;
+ }
+}
diff --git a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
new file mode 100644
index 0000000..1279736
--- /dev/null
+++ b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java
@@ -0,0 +1,335 @@
+package com.yunhai.flowcenter.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yunhai.flowcenter.entity.FlwDef;
+import com.yunhai.flowcenter.engine.FlowEngine;
+import com.yunhai.flowcenter.enums.FlwEnums;
+import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
+import com.yunhai.flowcenter.repository.FlwDefRepository;
+import com.yunhai.flowcenter.repository.FlwInstanceRepository;
+import com.yunhai.flowcenter.repository.FlwRouterRepository;
+import com.yunhai.platform.apistd.BizException;
+import com.yunhai.platform.openapi.core.YschOpenCall;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 流程定义的表单中心绑定单测(v3/TASK_1)。
+ *
+ * 三组判据:
+ *
+ * - 成对:{@code formUuid} 与 {@code formVersion} 同传或都不传;只传一个即拒;
+ * 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);
+ * - 发布取确切版本:带版本的关联走 {@code schema?uuid=&version=};
+ * 中心返回的坐标与请求不符即阻断发布,而不是把错的快照嵌进 def 版本行;
+ * 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;
+ * - 目录客户端的调用形态:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
+ * 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。
+ *
+ *
+ * 注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
+ * 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。
+ */
+@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
+class FlFormBindingTest {
+
+ private static final Long SCH = 27L;
+ private static final String SIMPLE_JSON = "{\"nodes\":[],\"edges\":[]}";
+
+ /** 含 EXPRESSION 判断的合法定义:days/grade 是表单字段,schId 是内置变量(与发布契约测试同形) */
+ private static final String FLOW_JSON = "{\"start\":\"s\",\"nodes\":["
+ + "{\"id\":\"s\",\"type\":\"START\"},"
+ + "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"},"
+ + "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}},"
+ + "{\"id\":\"e\",\"type\":\"END\"}],"
+ + "\"edges\":["
+ + "{\"from\":\"s\",\"to\":\"c\"},"
+ + "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"},"
+ + "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"},"
+ + "{\"from\":\"a\",\"to\":\"e\"}]}";
+
+ private FlwDefRepository defRepository;
+ private com.yunhai.flowcenter.repository.FlwAppRepository appRepository;
+ private FormCenterSchemaClient schemaClient;
+ private FlowDefAdminService service;
+
+ @BeforeEach
+ void setUp() {
+ defRepository = mock(FlwDefRepository.class);
+ appRepository = mock(com.yunhai.flowcenter.repository.FlwAppRepository.class);
+ schemaClient = mock(FormCenterSchemaClient.class);
+ service = new FlowDefAdminService(defRepository,
+ mock(FlwRouterRepository.class),
+ mock(FlwCallbackRegRepository.class),
+ appRepository,
+ mock(FlwInstanceRepository.class),
+ mock(FlowDeptAdminService.class),
+ mock(FlowEngine.class),
+ new ObjectMapper(),
+ schemaClient);
+ when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
+ // 归属应用白名单:一律放行(拦截语义由既有专门用例覆盖,本类只管表单坐标)
+ when(appRepository.isEnabledApp(any(), any())).thenReturn(true);
+ }
+
+ private static FlwDef draft(String formUuid, Integer formVersion) {
+ FlwDef d = new FlwDef();
+ d.setId(9L);
+ d.setSchId(SCH);
+ d.setFlowGuid("G");
+ d.setCode("work-leave");
+ d.setVersion(1);
+ d.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
+ d.setAppKey("notice");
+ d.setContentJson(FLOW_JSON);
+ d.setFormUuid(formUuid);
+ d.setFormVersion(formVersion);
+ return d;
+ }
+
+ /** 快照(formUuid=FU1 / v3),字段够表达式键查用 */
+ private static Map snapshot(String uuid, int version) {
+ Map snap = new LinkedHashMap<>();
+ snap.put("formUuid", uuid);
+ snap.put("formVersion", version);
+ snap.put("audience", Arrays.asList("STUDENT"));
+ snap.put("fields", Arrays.asList(field("days", "number"), field("grade", "select")));
+ return snap;
+ }
+
+ private static Map field(String key, String type) {
+ Map f = new LinkedHashMap<>();
+ f.put("key", key);
+ f.put("type", type);
+ f.put("required", Boolean.FALSE);
+ return f;
+ }
+
+ // ==================== ① 成对规则 ====================
+
+ @Test
+ @DisplayName("成对提交:uuid 与 version 一起落行")
+ void pairIsSavedTogether() {
+ when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
+
+ FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
+ null, null, null, "FU1", 3);
+
+ assertEquals("FU1", saved.getFormUuid());
+ assertEquals(Integer.valueOf(3), saved.getFormVersion());
+ }
+
+ @Test
+ @DisplayName("只传 uuid 或只传 version:拒,且不落库")
+ void halfCoordinateIsRejected() {
+ when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
+
+ BizException uuidOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
+ "work-leave", "请假", SIMPLE_JSON, null, null, null, "FU1", null));
+ assertTrue(uuidOnly.getMessage().contains("必须成对提交"), uuidOnly.getMessage());
+
+ BizException versionOnly = assertThrows(BizException.class, () -> service.save(SCH, 9L,
+ "work-leave", "请假", SIMPLE_JSON, null, null, null, null, 4));
+ assertTrue(versionOnly.getMessage().contains("必须成对提交"), versionOnly.getMessage());
+
+ // 半个坐标在保存分派之前就被拦下:这一行一次都没写库
+ verify(defRepository, never()).save(any());
+ }
+
+ @Test
+ @DisplayName("取消关联:空串 uuid 同时清空两列(哪怕请求里还带着版本号)")
+ void unlinkClearsBothColumns() {
+ FlwDef existing = draft("FU1", 3);
+ when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
+
+ FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
+ null, null, null, " ", 7);
+
+ assertSame(existing, saved);
+ assertNull(saved.getFormUuid());
+ assertNull(saved.getFormVersion());
+ }
+
+ @Test
+ @DisplayName("都不传=保持原关联不变(兼容旧调用面,缺省语义不能变成取消)")
+ void nullMeansKeep() {
+ FlwDef existing = draft("FU1", 3);
+ when(defRepository.findById(9L)).thenReturn(Optional.of(existing));
+
+ FlwDef saved = service.save(SCH, 9L, "work-leave", "改名", SIMPLE_JSON,
+ null, null, null, null, null);
+
+ assertEquals("FU1", saved.getFormUuid());
+ assertEquals(Integer.valueOf(3), saved.getFormVersion());
+ }
+
+ // ==================== ② 发布期确切版本 ====================
+
+ @Test
+ @DisplayName("带确切版本发布:按 uuid+version 取快照,并把版本坐标嵌进 def 行")
+ void publishUsesExactVersion() {
+ FlwDef def = draft("FU1", 3);
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3));
+
+ FlwDef published = service.publish(SCH, 9L);
+
+ verify(schemaClient).fetchSchema(SCH, "FU1", 3);
+ verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
+ assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
+ assertTrue(published.getContentJson().contains("FU1"));
+ }
+
+ @Test
+ @DisplayName("坐标不符:发布阻断(错误文案点名核验失败,不是拉取失败)")
+ void publishBlockedOnCoordinateMismatch() {
+ FlwDef def = draft("FU1", 3);
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ when(schemaClient.fetchSchema(SCH, "FU1", 3))
+ .thenThrow(new FormCenterSchemaClient.SchemaContractViolationException(
+ "表单中心返回的坐标与请求不符(请求 FU1 v3,返回 FU1 v4)"));
+
+ BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
+
+ assertTrue(e.getMessage().contains("核验失败"), e.getMessage());
+ assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
+ assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
+ }
+
+ @Test
+ @DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径")
+ void legacyRowWithoutVersionKeepsOldBehaviour() {
+ FlwDef def = draft("FU1", null);
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+ when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot("FU1", 9));
+
+ service.publish(SCH, 9L);
+
+ verify(schemaClient).fetchSchema(SCH, "FU1");
+ verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
+ }
+
+ @Test
+ @DisplayName("未关联表单的流程:一次都不碰表单中心")
+ void noBindingNoCall() {
+ FlwDef def = draft(null, null);
+ when(defRepository.findById(9L)).thenReturn(Optional.of(def));
+
+ service.publish(SCH, 9L);
+
+ verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
+ verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
+ }
+
+ // ==================== ③ 客户端形态 ====================
+
+ @Test
+ @DisplayName("精确版本请求:query 带 version;返回坐标不符即 SchemaContractViolationException")
+ void schemaClientSendsVersionAndChecksCoordinate() {
+ YschOpenCall openCall = mock(YschOpenCall.class);
+ FormCenterSchemaClient client = new FormCenterSchemaClient(openCall, new ObjectMapper());
+ Map data = new LinkedHashMap<>();
+ data.put("formUuid", "FU1");
+ data.put("version", 3);
+ data.put("code", "leave-form");
+ data.put("appCode", "svc-hall");
+ data.put("audience", Collections.singletonList("STUDENT"));
+ ArgumentCaptor