t1~t2+精修+TASK_4: 定义绑定表单UUID/版本成对核验、发起恢复守卫、schema兼容边界草案
build / build-push (push) Successful in 1m10s

含10-22精修: 保存/发布/启用按确切uuid+version远端核验、升版继承两列、取消关联清空版本、缺版本不回落最新。TASK_4=docs/schema-extension-compatibility-next-tasks.md(纯文档)。
This commit is contained in:
chenxing
2026-10-22 10:03:28 +08:00
parent 60ff464226
commit 4b4d63e3ba
15 changed files with 1510 additions and 6 deletions
@@ -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、不启动对账、不做前端视觉验收。
@@ -45,6 +45,16 @@ public class FlowAdminController {
private final FlowAdminGuard adminGuard; private final FlowAdminGuard adminGuard;
private final FlowDefAdminService defAdminService; 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 FlowMapAdminService mapAdminService;
private final FlowCallbackAdminService callbackAdminService; private final FlowCallbackAdminService callbackAdminService;
private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService; private final com.yunhai.flowcenter.service.FlowRouterAdminService routerAdminService;
@@ -103,7 +113,29 @@ public class FlowAdminController {
return defAdminService.get(adminGuard.requireAdmin(user, null), id); 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") @PostMapping("/def/save")
public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) { public FlwDef defSave(BoaUser user, @RequestBody Map<String, Object> body) {
Long schId = adminGuard.requireAdmin(user, null); Long schId = adminGuard.requireAdmin(user, null);
@@ -112,7 +144,8 @@ public class FlowAdminController {
(String) body.get("code"), (String) body.get("name"), (String) body.get("code"), (String) body.get("name"),
(String) body.get("contentJson"), (String) body.get("baseGuid"), (String) body.get("contentJson"), (String) body.get("baseGuid"),
(String) body.get("appKey"), (String) body.get("changeLog"), (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; package com.yunhai.flowcenter.controller.oapi;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.FlowModels; import com.yunhai.flowcenter.model.FlowModels;
import com.yunhai.flowcenter.model.OapiModels; import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.service.FlowQueryService; import com.yunhai.flowcenter.service.FlowQueryService;
@@ -68,6 +69,23 @@ public class OapiFlowController {
return flowService.apply(schId, appKey, body); 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") @GetMapping("/apply/preview")
public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId, public OapiModels.ApplyPreviewVo applyPreview(@RequestParam("schId") Long schId,
@@ -60,6 +60,20 @@ public class FlwDef extends BaseEntity {
@Column(name = "form_uuid", length = 32) @Column(name = "form_uuid", length = 32)
private String formUuid; 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 */ /** §5.1 流程定义 JSON */
@Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT") @Column(name = "content_json", nullable = false, columnDefinition = "LONGTEXT")
private String contentJson; private String contentJson;
@@ -129,6 +129,26 @@ public class FlwInstance extends BaseEntity {
@Column(name = "version", nullable = false) @Column(name = "version", nullable = false)
private Integer version; 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 的路由判断行无出口)。 * 台账展示位(F11-4):该办件是否「路由挂起」(审批中且最新 attempt 的路由判断行无出口)。
* 非持久列——由管理端办件分页(FlowAdminController.instancePage)按页查询后填充, * 非持久列——由管理端办件分页(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.setVersion(defRepository.maxVersionByGuid(schId, baseGuid.trim()) + 1);
def.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); def.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey()); def.setAppKey(cleanAppKey != null ? cleanAppKey : base.getAppKey());
def.setFormUuid(base.getFormUuid());
def.setFormVersion(base.getFormVersion());
} else if (id == null) { } else if (id == null) {
// 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用) // 新建:永远新开一条流程线,版本从 1 起(不再做同编码草稿复用)
def = new FlwDef(); def = new FlwDef();
@@ -139,6 +141,8 @@ public class FlowDefAdminService {
next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1); next.setVersion(defRepository.maxVersionByGuid(schId, def.getFlowGuid()) + 1);
next.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); next.setStatus(FlwEnums.DefStatus.DRAFT.getCode());
next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey()); next.setAppKey(cleanAppKey != null ? cleanAppKey : def.getAppKey());
next.setFormUuid(def.getFormUuid());
next.setFormVersion(def.getFormVersion());
def = next; def = next;
} }
} }
@@ -154,7 +158,7 @@ public class FlowDefAdminService {
if (def.getId() == null) { if (def.getId() == null) {
def.setChangeLog(cleanLog); def.setChangeLog(cleanLog);
} }
def.setContentJson(contentJson); def.setContentJson(withoutFormContract(contentJson)); // Only publish may author a contract snapshot.
// 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主) // 定义 JSON 里的 code/name 与行字段一致性校验(给绑定回显用行字段,JSON 内为主)
if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) { if (parsed.code != null && !parsed.code.trim().isEmpty() && !parsed.code.trim().equals(def.getCode())) {
def.setCode(parsed.code.trim()); def.setCode(parsed.code.trim());
@@ -174,15 +178,32 @@ public class FlowDefAdminService {
* 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br> * 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。<br>
* <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行); * <b>发布后不可改,改=升版本</b>由既有升版分派天然保证(非 DRAFT 直改必走升版新行);
* 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库); * 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库);
* 非空=设置(不做格式强校验——寻址失败在发布期由快照拉取阻断,错误不进库但也不卡保存)。 * 非空=设置,保存前按确切版本远端核验,失败不写入。
* <br>v3/TASK_1 追加 {@code formVersion}:与 formUuid <b>成对</b>,规则是——
* ① 两个都给了值=设关联(版本按服务端收到的整数存,之后发布按这组确切坐标核验);
* ② 只给一个=拒(半个坐标既存不下也没法核验,比"存了再说"便宜);
* ③ formUuid 给空串=取消关联,<b>同时清空版本</b>(留着一个没人认领的版本号是下一轮误用的起点);
* ④ formUuid 给 null=这条指令不存在(两个坐标都不动,兼容旧调用面)。
*/ */
@Transactional @Transactional
public FlwDef save(Long schId, Long id, String code, String name, String contentJson, 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); FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
if (formUuid != null) { if (formUuid != null) {
String clean = formUuid.trim(); String clean = formUuid.trim();
saved.setFormUuid(clean.isEmpty() ? null : clean); saved.setFormUuid(clean.isEmpty() ? null : clean);
saved.setFormVersion(clean.isEmpty() ? null : formVersion);
} }
try { try {
return defRepository.save(saved); return defRepository.save(saved);
@@ -202,15 +223,22 @@ public class FlowDefAdminService {
if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) { if (def.getStatus() == FlwEnums.DefStatus.PUBLISHED.getCode()) {
throw new BizException(ErrorCode.PARAM_ERROR, "已是发布状态"); 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()); FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys());
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) { if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
Map<String, Object> snapshot; Map<String, Object> snapshot;
try { try {
snapshot = formCenterSchemaClient.fetchSchema(schId, def.getFormUuid().trim()); // 管理链始终使用确切版本;缺版本须重新选择,不回落当前发布版。
snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion());
} catch (FormCenterSchemaClient.SchemaUnavailableException e) { } catch (FormCenterSchemaClient.SchemaUnavailableException e) {
throw new BizException(ErrorCode.PARAM_ERROR, throw new BizException(ErrorCode.PARAM_ERROR,
"关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage()); "关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage());
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
throw new BizException(ErrorCode.PARAM_ERROR,
"关联表单版本核验失败,发布已阻断(重试无意义,请重新选择表单与版本):" + e.getMessage());
} }
List<String> problems = new ArrayList<>(); List<String> problems = new ArrayList<>();
validateExpressionKeysAgainstSnapshot(model, snapshot, problems); validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
@@ -406,11 +434,37 @@ public class FlowDefAdminService {
} }
FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson()); FlowDefModel model = FlowDefModel.parse(objectMapper, def.getContentJson());
DefValidator.validate(model, enabledDeptKeys(schId), enabledRouterKeys()); 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.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
def.setPublishTime(LocalDateTime.now()); def.setPublishTime(LocalDateTime.now());
return defRepository.save(def); 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) { public List<FlwDef> versions(Long schId, String flowGuid) {
return defRepository.findBySchIdAndFlowGuidOrderByVersionDesc(schId, 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.FlwDef;
import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.entity.FlwInstance;
import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.repository.FlwBranchRepository; import com.yunhai.flowcenter.repository.FlwBranchRepository;
import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwDefRepository;
import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository;
@@ -106,6 +107,15 @@ public class FlowInstanceService {
} }
private void restart(FlwInstance instance, ApplyCommand cmd) { 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={}/{}", log.info("[flow-center] R4 重启推进:instance={} 原 status={} biz={}/{}",
instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId); instance.getId(), instance.getStatus(), cmd.bizType, cmd.bizId);
instance.setDefId(cmd.def.getId()); instance.setDefId(cmd.def.getId());
@@ -121,6 +131,13 @@ public class FlowInstanceService {
instance.setApplyAppCode(cmd.applyAppCode); instance.setApplyAppCode(cmd.applyAppCode);
instance.setSubmitterId(cmd.submitterId); instance.setSubmitterId(cmd.submitterId);
instance.setSubmitterName(cmd.submitterName); instance.setSubmitterName(cmd.submitterName);
// 新一轮才更新请求标识与轮次;同标识重投走不到这里(applyGuarded 已分支挡掉)
if (requestToken != null) {
instance.setApplyRequestToken(requestToken);
}
if (round != null) {
instance.setApplyRound(round);
}
instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode()); instance.setStatus(FlwEnums.InstanceStatus.APPROVING.getCode());
instance.setSubmitTime(LocalDateTime.now()); instance.setSubmitTime(LocalDateTime.now());
instance.setFinishTime(null); instance.setFinishTime(null);
@@ -267,4 +284,165 @@ public class FlowInstanceService {
return duplicated; 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(数组)}。 * 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。
* fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关); * fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关);
@@ -78,6 +91,60 @@ public class FormCenterSchemaClient {
"关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last); "关联表单 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 数组归一) */ /** SchemaView(data) → 快照节(fields 扁平化 + audience 数组归一) */
private Map<String, Object> toSnapshot(Map<String, Object> data) { private Map<String, Object> toSnapshot(Map<String, Object> data) {
Map<String, Object> snap = new LinkedHashMap<>(); 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.FlowDefModel;
import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.engine.FlowEngine;
import com.yunhai.flowcenter.enums.FlwEnums; import com.yunhai.flowcenter.enums.FlwEnums;
import com.yunhai.flowcenter.model.FlowApplyRetryModels;
import com.yunhai.flowcenter.model.OapiModels; import com.yunhai.flowcenter.model.OapiModels;
import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; import com.yunhai.flowcenter.repository.FlwCallbackRegRepository;
import com.yunhai.flowcenter.repository.FlwCcRepository; 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 com.yunhai.platform.openapi.base.OpenApiUserClient;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; 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> private final org.springframework.beans.factory.ObjectProvider<com.yunhai.platform.appauth.LocalAppIdentityProvider>
appIdentityProvider; 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) { public List<OapiModels.FlowSummaryVo> listFlows(Long schId) {
List<OapiModels.FlowSummaryVo> out = new ArrayList<>(); List<OapiModels.FlowSummaryVo> out = new ArrayList<>();
@@ -144,6 +165,100 @@ public class OapiFlowService {
return vo; 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 做这一次)。 * 调用方 app_key → app_code 一次转换(域间边界:验签域→路由域,只在 apply 做这一次)。
* 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply; * 查无/不可达降级 null——消息 jumpApp 空、归属回落发送方(现状行为),不阻断 apply;
@@ -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 轮"处理,
-- 于是"请求轮次 ≤ 行轮次 ⇒ 陈旧轮次不重启"对存量件天然成立——存量行的守卫缺省是
-- <b>关</b>而不是开:想让老件走新一轮,必须由消费方显式带 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 会打开存量重启窗口)
@@ -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= 取快照,
-- 中心返回的坐标与请求不符即<b>阻断发布</b>;已发布快照不改写,改关联=走新版本(既有升版分派)。
-- 可空口径:存量行(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`;
@@ -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)。
*
* <p>要证的正是任务书点名的那一类事故:<b>消费方响应丢失后按同业务键补发,绝不能被中心
* 当成"用户又重提了一次"</b>。旧形状下 biz uk 命中 30/40 就走 R4 重启推进,把已拒绝/已撤回
* 的办件重新跑起来;新分支用 {@code requestToken}/{@code attempt}/{@code retryOnly}
* 把"同一次命令的重投"与"新一轮授权重提"分成两个事实。</p>
*
* <p>同时钉住三条边界:</p>
* <ul>
* <li>并发唯一键冲突在<b>事务边界外</b>重读,且先核调用应用/提交者/流程线——
* 别的应用相同 bizType 键<b>不作为自己的结果</b>返回;</li>
* <li>存量行({@code apply_round} 为 NULL)按第 1 轮处理,<b>守卫缺省是关</b>:
* 没带更高轮次就重启不了别人的老件;</li>
* <li>既有 R4 重提语义与流程节点算法一行未改({@code RESTARTED} 分支才走 restart)。</li>
* </ul>
*/
@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.<FlwInstance>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<FlwInstance> 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.<FlwInstance>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;
}
}
@@ -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)。
*
* <p>三组判据:</p>
* <ol>
* <li><b>成对</b>:{@code formUuid} 与 {@code formVersion} 同传或都不传;只传一个即拒;
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
* 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;</li>
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
* </ol>
*
* <p>注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
* 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。</p>
*/
@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<String, Object> snapshot(String uuid, int version) {
Map<String, Object> 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<String, Object> field(String key, String type) {
Map<String, Object> 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<String, Object> 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<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
when(openCall.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class))).thenReturn(data);
Map<String, Object> snap = client.fetchSchema(SCH, "FU1", 3);
assertEquals("FU1", snap.get("formUuid"));
assertEquals(3, snap.get("formVersion"));
verify(openCall).get(eq("form-center"), eq("/oapi/form/schema"), query.capture(), eq(Map.class));
assertEquals("3", query.getValue().get("version"));
assertEquals("FU1", query.getValue().get("uuid"));
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
data.put("version", 4);
assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class,
() -> client.fetchSchema(SCH, "FU1", 3));
// 坐标不符不重试:两次调用=成功一次 + 不符一次;若按可重试口径处理会变成三次
verify(openCall, org.mockito.Mockito.times(2))
.get(eq("form-center"), eq("/oapi/form/schema"), any(), eq(Map.class));
}
@Test
@DisplayName("精确版本形态不给 version:直接拒(缺版本=旧口径,不许混着调)")
void schemaClientRequiresExplicitVersion() {
FormCenterSchemaClient client = new FormCenterSchemaClient(mock(YschOpenCall.class), new ObjectMapper());
assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(SCH, "FU1", null));
}
@Test
@DisplayName("目录客户端:命名目标 form-center,query 带 CAS 校域与分页;空 data 报不可用")
void catalogClientQueryShapeAndEmptyData() {
YschOpenCall openCall = mock(YschOpenCall.class);
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
FormCenterCatalogClient.CatalogPage page = new FormCenterCatalogClient.CatalogPage();
FormCenterCatalogClient.CatalogItem item = new FormCenterCatalogClient.CatalogItem();
item.setFormUuid("FU1");
item.setVersion(3);
item.setName("请假单");
page.setItems(Arrays.asList(item));
page.setTotal(1);
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(page);
FormCenterCatalogClient.CatalogPage got = client.listForms(SCH, "svc-hall", "请假", 2, 20);
assertEquals(1, got.getItems().size());
assertEquals("FU1", got.getItems().get(0).getFormUuid());
verify(openCall).get(eq("form-center"), eq("/oapi/form/forms"), query.capture(),
eq(FormCenterCatalogClient.CatalogPage.class));
assertEquals(String.valueOf(SCH), query.getValue().get("schId"));
assertEquals("svc-hall", query.getValue().get("appCode"));
assertEquals("请假", query.getValue().get("keyword"));
assertEquals("2", query.getValue().get("page"));
assertEquals("20", query.getValue().get("pageSize"));
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
eq(FormCenterCatalogClient.CatalogPage.class))).thenReturn(null);
BizException e = assertThrows(BizException.class,
() -> client.listForms(SCH, null, null, null, null));
assertTrue(e.getMessage().contains("空"), e.getMessage());
}
@Test
@DisplayName("目录不可达(网络/未授权)转可诊断 BizException,不静默给空列表")
void catalogUnreachableIsSurfaced() {
YschOpenCall openCall = mock(YschOpenCall.class);
when(openCall.get(eq("form-center"), eq("/oapi/form/forms"), any(),
eq(FormCenterCatalogClient.CatalogPage.class))).thenThrow(new IllegalStateException("connect refused"));
FormCenterCatalogClient client = new FormCenterCatalogClient(openCall);
BizException e = assertThrows(BizException.class, () -> client.listForms(SCH, null, null, 1, 20));
assertTrue(e.getMessage().contains("不可达"), e.getMessage());
}
}