diff --git a/docs/form-binding-contract.md b/docs/form-binding-contract.md new file mode 100644 index 0000000..3f49846 --- /dev/null +++ b/docs/form-binding-contract.md @@ -0,0 +1,38 @@ +# 流程定义精确表单版本契约(2026-10-23,R2 / R1) + +流程关联表单时必须同时绑定 `formUuid` 与正整数 `formVersion`。保存显式关联、发布、启用统一调用 +`GET /oapi/form/schema?schId=&uuid=&version=`,核对返回 UUID 和版本;不回落到当前版本。 +未关联表单的独立流程(Mode C)仍可使用;孤立版本号不是未关联流程,必须拒绝。 + +UUID-only 存量定义不能发布或启用,必须重新选择明确版本。当前开发阶段不做存量迁移。 +本轮不修改任何数据库;部署前核查 `def68` 的现有绑定即可。 + +FC 精确 schema 只返回 `publish_time != null` 的版本行。草稿不能作为发布契约。 +已发布历史版本仍可按坐标读取,不要求它是头表当前版本;停用/软删的历史读取与原授权边界保持。 +FC 将不存在、不可读、未发布版本统一为 `PARAM_ERROR(2)`,FL 不通过文案猜测资源存在性。 + +## 稳定错误码与恢复动作 + +API 信封 `code` 使用下列数值;`msg` 仅用于展示,消费方不得解析文案判断重试。 + +| code / 名称 | 含义 | 恢复动作 | +|---|---|---| +| 46101 `VERSION_REQUIRED` | 已指定 UUID,未指定版本 | 重新绑定;发布/启用均阻断,不调用 FC | +| 46102 `INVALID_COORDINATE` | 半个坐标、版本非正整数/非整数/溢出 | 修正请求;不盲目重试 | +| 46103 `SCHEMA_UNAVAILABLE` | 网络、空响应、服务端故障等拉取失败 | 可重试;客户端每次最多请求 2 次 | +| 46104 `SCHEMA_CONTRACT_VIOLATION` | 返回 UUID/版本缺失、不合法或不匹配 | 阻断;检查契约或重新选择坐标,不盲目重试 | +| 46105 `RESOURCE_UNAVAILABLE` | FC 业务码 2 或 HTTP 404,指定发布资源不存在或不可读 | 检查资源/发布版本/读授权,不盲目重试 | +| 46106 `ACCESS_DENIED` | SDK 身份/授权业务码 3、4 | 修复身份/授权后重试,不自动重放 | + +坐标核验失败、资源不可用与身份拒绝均不在客户端重试循环内重复请求。 +`SchemaUnavailableException` 与 `SchemaContractViolationException` 在公共边界分别翻译成错误码, +保留 cause;不存在被公共包装吞掉后无法命中的分类 catch。 +启用只核验绑定,不覆盖此前发布的 `formContract` 快照;运行期继续使用流程版本里的原快照。 + +## 验证与边界 + +`FlFormBindingTest` 覆盖发布/启用缺版本、非法版本、孤立版本、四类失败码和不落库、启用保留快照。 +`FlowDefAdminServicePublishContractTest` 全部关联夹具带 v3,原表达式键查、类型桶查、域查红牌继续验证。 +`FormCenterSchemaClientTest` 覆盖不合法返回版本、UUID 不符、按业务码分类及重试次数。 +FC `OapiSchemaPublishedVersionTest` 验证真实服务层的草稿阻断和历史已发布版可读;其中仓储是桩, +不是数据库或 160 验收。 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 7da759d..8b9f2ff 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -6,6 +6,7 @@ import com.yunhai.flowcenter.entity.FlwCallbackReg; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.entity.FlwInstance; import com.yunhai.flowcenter.model.FlowModels; +import com.yunhai.flowcenter.enums.FormBindingErrorCode; import com.yunhai.flowcenter.repository.FlwInstanceRepository; import com.yunhai.flowcenter.service.FlowAdminGuard; import com.yunhai.flowcenter.service.FlowCallbackAdminService; @@ -145,7 +146,16 @@ public class FlowAdminController { (String) body.get("contentJson"), (String) body.get("baseGuid"), (String) body.get("appKey"), (String) body.get("changeLog"), (String) body.get("formUuid"), - body.get("formVersion") == null ? null : Integer.valueOf(String.valueOf(body.get("formVersion")))); + parseFormVersion(body.get("formVersion"))); + } + + private static Integer parseFormVersion(Object value) { + if (value == null) return null; + try { + return Integer.valueOf(String.valueOf(value)); + } catch (NumberFormatException e) { + throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "formVersion 必须是正整数", e); + } } /** diff --git a/src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java b/src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java new file mode 100644 index 0000000..45d7b9d --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/enums/FormBindingErrorCode.java @@ -0,0 +1,27 @@ +package com.yunhai.flowcenter.enums; + +import com.yunhai.platform.utils.exception.IErrorCode; + +/** 管理面表单绑定契约:消费方按数值码判断恢复动作,不解析 msg。 */ +public enum FormBindingErrorCode implements IErrorCode { + VERSION_REQUIRED(46101, "关联表单缺少明确版本"), + INVALID_COORDINATE(46102, "关联表单坐标或版本不合法"), + SCHEMA_UNAVAILABLE(46103, "关联表单 schema 拉取失败,可重试"), + SCHEMA_CONTRACT_VIOLATION(46104, "关联表单 schema 坐标核验失败"), + RESOURCE_UNAVAILABLE(46105, "关联表单或指定发布版本不存在或不可读"), + ACCESS_DENIED(46106, "关联表单访问身份或授权被拒绝"); + + private final int code; + private final String msg; + + FormBindingErrorCode(int code, String msg) { + this.code = code; + this.msg = msg; + } + + @Override + public int getCode() { return code; } + + @Override + public String getMsg() { return msg; } +} diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 001e9d1..f5826d7 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -7,6 +7,7 @@ import com.yunhai.flowcenter.engine.FlowEngine; import com.yunhai.flowcenter.entity.FlwAssigneeMap; import com.yunhai.flowcenter.entity.FlwDef; import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.enums.FormBindingErrorCode; import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository; import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.platform.apistd.BizException; @@ -192,12 +193,13 @@ public class FlowDefAdminService { boolean pairing = formUuid != null && !clearing && formVersion == null; boolean versionOnly = formUuid == null && formVersion != null; if (pairing || versionOnly) { - throw new BizException(ErrorCode.PARAM_ERROR, + throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED + : FormBindingErrorCode.INVALID_COORDINATE, "关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid=" + (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion); } if (formUuid != null && !clearing) { - checkedSchema(schId, formUuid.trim(), formVersion); // Validate before the first save. + checkedSchema(schId, formUuid.trim(), formVersion, "保存"); // Validate before the first save. } FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog); if (formUuid != null) { @@ -229,17 +231,8 @@ public class FlowDefAdminService { 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 = 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()); - } + Map snapshot = checkedSchema(schId, def.getFormUuid().trim(), + def.getFormVersion(), "发布"); List problems = new ArrayList<>(); validateExpressionKeysAgainstSnapshot(model, snapshot, problems); validateExpressionTypesAndDomains(model, snapshot, problems); @@ -248,6 +241,8 @@ public class FlowDefAdminService { "发布校验失败(表达式三查):\n" + String.join("\n", problems)); } def.setContentJson(embedFormContract(def.getContentJson(), snapshot)); + } else if (def.getFormVersion() != null) { + throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid"); } def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); @@ -435,22 +430,38 @@ 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()); + checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion(), "启用"); + } else if (def.getFormVersion() != null) { + throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, "关联表单版本必须同时绑定 formUuid"); } 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, "关联表单必须选择确切的正整数版本,请重新选择"); + private Map checkedSchema(Long schId, String uuid, Integer version, String operation) { + if (version == null) { + throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本," + + operation + "已阻断,请重新选择表单与版本"); + } + if (version < 1 || uuid == null || uuid.trim().isEmpty()) { + throw new BizException(FormBindingErrorCode.INVALID_COORDINATE, + "关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择"); } try { return formCenterSchemaClient.fetchSchema(schId, uuid, version); - } catch (FormCenterSchemaClient.SchemaUnavailableException - | FormCenterSchemaClient.SchemaContractViolationException e) { - throw new BizException(ErrorCode.PARAM_ERROR, "关联表单核验失败,操作已阻断:" + e.getMessage()); + } catch (FormCenterSchemaClient.SchemaUnavailableException e) { + throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE, + "关联表单快照拉取失败," + operation + "已阻断(可重试)", e); + } catch (FormCenterSchemaClient.SchemaContractViolationException e) { + throw new BizException(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, + "关联表单版本核验失败," + operation + "已阻断(请重新选择表单与版本)", e); + } catch (FormCenterSchemaClient.SchemaResourceUnavailableException e) { + throw new BizException(FormBindingErrorCode.RESOURCE_UNAVAILABLE, + "关联表单或指定发布版本不存在或不可读," + operation + "已阻断", e); + } catch (FormCenterSchemaClient.SchemaAccessDeniedException e) { + throw new BizException(FormBindingErrorCode.ACCESS_DENIED, + "关联表单访问身份或授权被拒绝," + operation + "已阻断,请检查授权", e); } } diff --git a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java index faa1f5f..dbb2fb6 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java +++ b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java @@ -3,6 +3,9 @@ package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.openapi.core.OpenApiException; +import com.yunhai.platform.utils.exception.DException; +import com.yunhai.platform.utils.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -14,10 +17,10 @@ import java.util.Map; /** * form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。
- * GET {目录解析 form-center}/oapi/form/schema?schId=&uuid= ——按 form_uuid 拉当前已发布版 schema - * (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}: + * GET {目录解析 form-center}/oapi/form/schema?schId=&uuid=&version= ——只取明确发布版本。 + * 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall: * 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。
- * 只在发布期调用(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
+ * 只在管理期调用(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
* audience 解析:契约 v2 C5/G3 起是字符串数组(breaking,T13 切换)——本客户端按数组读取, * 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。 */ @@ -60,48 +63,28 @@ public class FormCenterSchemaClient { } } - /** - * 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。 - * fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关); - * select/radio 类字段的 options 值域收进 enum(供域查③,本轮仅透传)。 - */ - public Map fetchSchema(Long schId, String formUuid) { - 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); - Map data = openCall.get(TARGET, SCHEMA_PATH, query, Map.class); - if (data == null) { - throw new IllegalStateException("schema 返回空 data"); - } - return toSnapshot(data); - } catch (RuntimeException e) { - last = e; - log.warn("[flow-center] form-center schema 快照拉取失败(第 {}/{} 次):uuid={} {}", - attempt, MAX_ATTEMPTS, formUuid, e.toString()); - } catch (Exception e) { - last = new IllegalStateException(e.toString(), e); - log.warn("[flow-center] form-center schema 快照拉取异常(第 {}/{} 次):uuid={} {}", - attempt, MAX_ATTEMPTS, formUuid, e.toString()); - } + /** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */ + public static class SchemaResourceUnavailableException extends RuntimeException { + public SchemaResourceUnavailableException(Throwable cause) { + super("表单或指定发布版本不存在或不可读", cause); + } + } + + public static class SchemaAccessDeniedException extends RuntimeException { + public SchemaAccessDeniedException(Throwable cause) { + super("表单访问身份或授权被拒绝", cause); } - throw new SchemaUnavailableException( - "关联表单 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})只走本方法。

+ * fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。 */ public Map fetchSchema(Long schId, String formUuid, Integer version) { - if (version == null) { - throw new IllegalArgumentException("按确切版本取快照必须给 version(缺版本=取当前发布版,那是旧口径)"); + if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) { + throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version"); } RuntimeException last = null; for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { @@ -119,6 +102,20 @@ public class FormCenterSchemaClient { return snapshot; } catch (SchemaContractViolationException e) { throw e; + } catch (DException e) { + int code = e.getCode() == null ? -1 : e.getCode().getCode(); + if (code == ErrorCode.PARAM_ERROR.getCode()) { + throw new SchemaResourceUnavailableException(e); + } + if (code == ErrorCode.FORBIDDEN.getCode() || code == ErrorCode.UNAUTHORIZED.getCode()) { + throw new SchemaAccessDeniedException(e); + } + last = e; + } catch (OpenApiException e) { + if (e.getHttpStatus() == 404) { + throw new SchemaResourceUnavailableException(e); + } + last = e; } catch (RuntimeException e) { last = e; log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}", @@ -137,9 +134,15 @@ public class FormCenterSchemaClient { 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 + Integer parsedVersion; + try { + parsedVersion = gotVersion == null ? null : Integer.valueOf(String.valueOf(gotVersion)); + } catch (NumberFormatException e) { + throw new SchemaContractViolationException("表单中心返回的版本不是合法整数"); + } + if (gotUuid == null || parsedVersion == null || parsedVersion < 1 || !String.valueOf(gotUuid).equals(formUuid) - || Integer.parseInt(String.valueOf(gotVersion)) != version) { + || !parsedVersion.equals(version)) { throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 " + formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")"); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java index 1279736..ba696e3 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlFormBindingTest.java @@ -4,6 +4,9 @@ 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.enums.FormBindingErrorCode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; import com.yunhai.flowcenter.repository.FlwDefRepository; import com.yunhai.flowcenter.repository.FlwInstanceRepository; @@ -45,13 +48,12 @@ import static org.mockito.Mockito.when; * 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点); *
  • 发布取确切版本:带版本的关联走 {@code schema?uuid=&version=}; * 中心返回的坐标与请求不符即阻断发布,而不是把错的快照嵌进 def 版本行; - * 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;
  • + * 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本; *
  • 目录客户端的调用形态:命名目标 {@code form-center} + 查询里带 schId/page/pageSize, * 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。
  • * * - *

    注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是 - * 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。

    + *

    发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。

    */ @DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)") class FlFormBindingTest { @@ -134,6 +136,7 @@ class FlFormBindingTest { @DisplayName("成对提交:uuid 与 version 一起落行") void pairIsSavedTogether() { when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null))); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3)); FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON, null, null, null, "FU1", 3); @@ -198,7 +201,6 @@ class FlFormBindingTest { 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")); } @@ -214,22 +216,112 @@ class FlFormBindingTest { BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertEquals(FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION.getCode(), e.getErrorCode().getCode()); assertTrue(e.getMessage().contains("核验失败"), e.getMessage()); assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage()); assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus()); } - @Test - @DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径") - void legacyRowWithoutVersionKeepsOldBehaviour() { + @ParameterizedTest + @ValueSource(booleans = {false, true}) + @DisplayName("缺版本:发布和启用均阻断,不回落当前版本") + void missingVersionBlocksPublishAndEnable(boolean enable) { FlwDef def = draft("FU1", null); + if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); when(defRepository.findById(9L)).thenReturn(Optional.of(def)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot("FU1", 9)); - service.publish(SCH, 9L); + BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); - verify(schemaClient).fetchSchema(SCH, "FU1"); + assertEquals(FormBindingErrorCode.VERSION_REQUIRED.getCode(), ex.getErrorCode().getCode()); + assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus()); verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); + verify(defRepository, never()).save(any()); + } + + private FlwDef changeStatus(boolean enable) { + return enable ? service.enable(SCH, 9L) : service.publish(SCH, 9L); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void invalidVersionBlocksPublishAndEnable(boolean enable) { + FlwDef def = draft("FU1", 0); + if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); + BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); + assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode()); + verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); + verify(defRepository, never()).save(any()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void orphanVersionBlocksPublishAndEnable(boolean enable) { + FlwDef def = draft(null, 3); + if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); + BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); + assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode()); + verify(defRepository, never()).save(any()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void schemaFailuresHaveDistinctStableCodesAndDoNotWrite(boolean enable) { + FlwDef def = draft("FU1", 3); + if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); + RuntimeException[] failures = { + new FormCenterSchemaClient.SchemaUnavailableException("timeout", null), + new FormCenterSchemaClient.SchemaContractViolationException("wrong version"), + new FormCenterSchemaClient.SchemaResourceUnavailableException(null), + new FormCenterSchemaClient.SchemaAccessDeniedException(null) + }; + FormBindingErrorCode[] codes = {FormBindingErrorCode.SCHEMA_UNAVAILABLE, + FormBindingErrorCode.SCHEMA_CONTRACT_VIOLATION, FormBindingErrorCode.RESOURCE_UNAVAILABLE, + FormBindingErrorCode.ACCESS_DENIED}; + for (int i = 0; i < failures.length; i++) { + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenThrow(failures[i]); + BizException ex = assertThrows(BizException.class, () -> changeStatus(enable)); + assertEquals(codes[i].getCode(), ex.getErrorCode().getCode()); + assertSame(failures[i], ex.getCause()); + assertEquals(enable ? FlwEnums.DefStatus.DISABLED.getCode() : FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus()); + assertEquals(FLOW_JSON, def.getContentJson()); + org.mockito.Mockito.reset(schemaClient); + } + verify(defRepository, never()).save(any()); + } + + @Test + void enableUsesExactVersionAndPreservesPublishedSnapshot() { + FlwDef def = draft("FU1", 3); + def.setStatus(FlwEnums.DefStatus.DISABLED.getCode()); + def.setContentJson(FLOW_JSON.substring(0, FLOW_JSON.length() - 1) + + ",\"formContract\":{\"formUuid\":\"FU1\",\"formVersion\":3,\"fields\":[]}}"); + String frozenJson = def.getContentJson(); + when(defRepository.findById(9L)).thenReturn(Optional.of(def)); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot("FU1", 3)); + FlwDef enabled = service.enable(SCH, 9L); + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), enabled.getStatus()); + assertEquals(frozenJson, enabled.getContentJson()); + verify(schemaClient).fetchSchema(SCH, "FU1", 3); + } + + @ParameterizedTest + @ValueSource(strings = {"oops", "3.5", "3.0", "2147483648", "", "true"}) + void malformedVersionAtHttpBoundaryHasStableCode(String value) { + FlowAdminGuard guard = mock(FlowAdminGuard.class); + when(guard.requireAdmin(any(), any())).thenReturn(SCH); + com.yunhai.flowcenter.controller.api.FlowAdminController controller = + new com.yunhai.flowcenter.controller.api.FlowAdminController(guard, service, + null, null, null, null, null, null, new ObjectMapper(), null, null, null); + Map body = new LinkedHashMap<>(); + body.put("formUuid", "FU1"); + body.put("formVersion", value); + BizException ex = assertThrows(BizException.class, () -> controller.defSave(null, body)); + assertEquals(FormBindingErrorCode.INVALID_COORDINATE.getCode(), ex.getErrorCode().getCode()); + verify(defRepository, never()).save(any()); + org.mockito.Mockito.verifyNoInteractions(schemaClient); } @Test @@ -240,7 +332,6 @@ class FlFormBindingTest { service.publish(SCH, 9L); - verify(schemaClient, never()).fetchSchema(anyLong(), anyString()); verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java index 18f9c38..ee0ce89 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java @@ -85,6 +85,7 @@ class FlowDefAdminServicePublishContractTest { d.setAppKey("notice"); d.setContentJson(JSON); d.setFormUuid(formUuid); + d.setFormVersion(formUuid == null ? null : 3); return d; } @@ -115,7 +116,7 @@ class FlowDefAdminServicePublishContractTest { @Test void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception { when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1"))); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); FlwDef published = service.publish(SCH, 9L); @@ -134,7 +135,7 @@ class FlowDefAdminServicePublishContractTest { // 出口条件改成引用快照外变量 d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'")); when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage()); @@ -147,7 +148,7 @@ class FlowDefAdminServicePublishContractTest { FlwDef d = draft("FU1"); d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3")); when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"), @@ -159,7 +160,7 @@ class FlowDefAdminServicePublishContractTest { FlwDef d = draft("FU1"); d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'")); when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"), @@ -171,7 +172,7 @@ class FlowDefAdminServicePublishContractTest { FlwDef d = draft("FU1"); d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'")); when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"), @@ -184,7 +185,7 @@ class FlowDefAdminServicePublishContractTest { FlwDef d = draft("FU1"); d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5")); when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus()); } @@ -195,7 +196,7 @@ class FlowDefAdminServicePublishContractTest { FlwDef d = draft("FU1"); d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'")); when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d)); - when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + when(schemaClient.fetchSchema(SCH, "FU1", 3)).thenReturn(snapshot()); assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus()); } @@ -208,16 +209,18 @@ class FlowDefAdminServicePublishContractTest { assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus()); assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动"); - verify(schemaClient, never()).fetchSchema(anyLong(), anyString()); + verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any()); } @Test void schemaUnavailableBlocksPublishAsRetryableBizError() { when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1"))); - when(schemaClient.fetchSchema(SCH, "FU1")) + when(schemaClient.fetchSchema(SCH, "FU1", 3)) .thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null)); BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertEquals(com.yunhai.flowcenter.enums.FormBindingErrorCode.SCHEMA_UNAVAILABLE.getCode(), + ex.getErrorCode().getCode()); assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage()); verify(defRepository, never()).save(any()); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java index 472668f..73aad4e 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java @@ -2,6 +2,12 @@ package com.yunhai.flowcenter.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.yunhai.platform.openapi.core.YschOpenCall; +import com.yunhai.platform.openapi.core.OpenApiException; +import com.yunhai.platform.openapi.core.OpenApiErrorCode; +import com.yunhai.platform.utils.exception.DException; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -72,7 +78,7 @@ class FormCenterSchemaClientTest { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) .thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER"))); - Map snap = client.fetchSchema(27L, "U1"); + Map snap = client.fetchSchema(27L, "U1", 3); assertEquals("U1", snap.get("formUuid")); assertEquals(3, snap.get("formVersion")); @@ -88,11 +94,11 @@ class FormCenterSchemaClientTest { @Test void legacySingleStringAudienceIsWrappedAsArray() { - Map data = schemaData("U1", 1, null); + Map data = schemaData("U1", 3, null); data.put("audience", "STUDENT"); when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); - Map snap = client.fetchSchema(27L, "U1"); + Map snap = client.fetchSchema(27L, "U1", 3); assertEquals(Arrays.asList("STUDENT"), snap.get("audience"), "C5 切换窗口的旧单值串按一元素数组容错"); @@ -104,7 +110,7 @@ class FormCenterSchemaClientTest { .thenThrow(new RuntimeException("connect refused")); assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, - () -> client.fetchSchema(27L, "U1")); + () -> client.fetchSchema(27L, "U1", 3)); verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) .get(anyString(), anyString(), anyMap(), eq(Map.class)); } @@ -115,7 +121,77 @@ class FormCenterSchemaClientTest { when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null); assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, - () -> client.fetchSchema(27L, "U1")); - assertTrue(true); + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) + .get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"0", "-1", "4", "3.5", "wrong", "2147483648"}) + void malformedOrWrongReturnedVersionIsContractFailureWithoutRetry(String returnedVersion) { + Map data = schemaData("U1", 3, Arrays.asList("STUDENT")); + data.put("version", returnedVersion); + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); + assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class, + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", "U2"}) + void missingOrWrongReturnedUuidIsContractFailureWithoutRetry(String returnedUuid) { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenReturn(schemaData(returnedUuid, 3, Arrays.asList("STUDENT"))); + assertThrows(FormCenterSchemaClient.SchemaContractViolationException.class, + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @ParameterizedTest + @ValueSource(ints = {0, -1}) + void illegalRequestedVersionMakesNoRemoteCall(int version) { + assertThrows(IllegalArgumentException.class, () -> client.fetchSchema(27L, "U1", version)); + org.mockito.Mockito.verifyNoInteractions(openCall); + } + + @Test + void resourceRejectionUsesEnvelopeCodeAndDoesNotRetry() { + // SDK 实际携带 OpenApiErrorCode,不会转换成本地 ErrorCode 枚举;文案刻意无关键词。 + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new DException(new OpenApiErrorCode(2, "opaque"))); + assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class, + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @ParameterizedTest + @ValueSource(ints = {3, 4}) + void authRejectionDoesNotBlindlyRetry(int code) { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new DException(new OpenApiErrorCode(code, "opaque"))); + assertThrows(FormCenterSchemaClient.SchemaAccessDeniedException.class, + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @Test + void http404DoesNotBlindlyRetry() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new OpenApiException("opaque", 404, "")); + assertThrows(FormCenterSchemaClient.SchemaResourceUnavailableException.class, + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(1)).get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @Test + void internalRemoteFailureRemainsRetryable() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new DException(new OpenApiErrorCode(6, "opaque"))); + assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, + () -> client.fetchSchema(27L, "U1", 3)); + verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) + .get(anyString(), anyString(), anyMap(), eq(Map.class)); } }