Enforce exact published form version contracts

Local review candidate only. Explicitly selected inherited prerequisites and prior repair patches; unrelated WIP and independent MQ fixes are excluded. No push or deployment authorized.
This commit is contained in:
chenxing
2026-10-23 10:51:47 +08:00
parent a10f2e054c
commit 7eeaea55f8
8 changed files with 344 additions and 85 deletions
+38
View File
@@ -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 验收。
@@ -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);
}
}
/**
@@ -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; }
}
@@ -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<String, Object> 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<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
def.getFormVersion(), "发布");
List<String> 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<String, Object> checkedSchema(Long schId, String uuid, Integer version) {
if (version == null || version < 1) {
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择");
private Map<String, Object> 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);
}
}
@@ -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 第一刀:发布期契约快照)。<br>
* GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid= ——按 form_uuid 拉当前已发布版 schema
* (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}:
* GET {目录解析 form-center}/oapi/form/schema?schId=&amp;uuid=&amp;version= ——只取明确发布版本。
* 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall:
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
* <b>只在发布期调用</b>(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
* <b>只在管理期调用</b>(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(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<String, Object> fetchSchema(Long schId, String formUuid) {
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);
Map<String, Object> 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);
}
}
throw new SchemaUnavailableException(
"关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
public static class SchemaAccessDeniedException extends RuntimeException {
public SchemaAccessDeniedException(Throwable cause) {
super("表单访问身份或授权被拒绝", cause);
}
}
/**
* 发布期快照的<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>
* fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。
*/
public Map<String, Object> 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<String, Object> 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 + ")");
}
@@ -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;
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
* 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;</li>
* 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本;</li>
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
* </ol>
*
* <p>注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
* 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。</p>
* <p>发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。</p>
*/
@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<String, Object> 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());
}
@@ -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());
}
@@ -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<String, Object> snap = client.fetchSchema(27L, "U1");
Map<String, Object> 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<String, Object> data = schemaData("U1", 1, null);
Map<String, Object> data = schemaData("U1", 3, null);
data.put("audience", "STUDENT");
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
Map<String, Object> snap = client.fetchSchema(27L, "U1");
Map<String, Object> 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<String, Object> 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));
}
}