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:
@@ -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.FlwDef;
|
||||||
import com.yunhai.flowcenter.entity.FlwInstance;
|
import com.yunhai.flowcenter.entity.FlwInstance;
|
||||||
import com.yunhai.flowcenter.model.FlowModels;
|
import com.yunhai.flowcenter.model.FlowModels;
|
||||||
|
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
|
||||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||||
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
import com.yunhai.flowcenter.service.FlowAdminGuard;
|
||||||
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
|
import com.yunhai.flowcenter.service.FlowCallbackAdminService;
|
||||||
@@ -145,7 +146,16 @@ public class FlowAdminController {
|
|||||||
(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"))));
|
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.FlwAssigneeMap;
|
||||||
import com.yunhai.flowcenter.entity.FlwDef;
|
import com.yunhai.flowcenter.entity.FlwDef;
|
||||||
import com.yunhai.flowcenter.enums.FlwEnums;
|
import com.yunhai.flowcenter.enums.FlwEnums;
|
||||||
|
import com.yunhai.flowcenter.enums.FormBindingErrorCode;
|
||||||
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
import com.yunhai.flowcenter.repository.FlwAssigneeMapRepository;
|
||||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||||
import com.yunhai.platform.apistd.BizException;
|
import com.yunhai.platform.apistd.BizException;
|
||||||
@@ -192,12 +193,13 @@ public class FlowDefAdminService {
|
|||||||
boolean pairing = formUuid != null && !clearing && formVersion == null;
|
boolean pairing = formUuid != null && !clearing && formVersion == null;
|
||||||
boolean versionOnly = formUuid == null && formVersion != null;
|
boolean versionOnly = formUuid == null && formVersion != null;
|
||||||
if (pairing || versionOnly) {
|
if (pairing || versionOnly) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
throw new BizException(formVersion == null ? FormBindingErrorCode.VERSION_REQUIRED
|
||||||
|
: FormBindingErrorCode.INVALID_COORDINATE,
|
||||||
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
|
"关联表单必须成对提交 formUuid 与 formVersion(只传一个拒):当前 formUuid="
|
||||||
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
|
+ (formUuid == null ? "未传" : formUuid.trim()) + ",formVersion=" + formVersion);
|
||||||
}
|
}
|
||||||
if (formUuid != null && !clearing) {
|
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);
|
FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog);
|
||||||
if (formUuid != null) {
|
if (formUuid != null) {
|
||||||
@@ -229,17 +231,8 @@ 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()) {
|
if (def.getFormUuid() != null && !def.getFormUuid().trim().isEmpty()) {
|
||||||
Map<String, Object> snapshot;
|
Map<String, Object> snapshot = checkedSchema(schId, def.getFormUuid().trim(),
|
||||||
try {
|
def.getFormVersion(), "发布");
|
||||||
// 管理链始终使用确切版本;缺版本须重新选择,不回落当前发布版。
|
|
||||||
snapshot = checkedSchema(schId, def.getFormUuid().trim(), def.getFormVersion());
|
|
||||||
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
|
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
|
||||||
"关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage());
|
|
||||||
} catch (FormCenterSchemaClient.SchemaContractViolationException e) {
|
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR,
|
|
||||||
"关联表单版本核验失败,发布已阻断(重试无意义,请重新选择表单与版本):" + e.getMessage());
|
|
||||||
}
|
|
||||||
List<String> problems = new ArrayList<>();
|
List<String> problems = new ArrayList<>();
|
||||||
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
|
validateExpressionKeysAgainstSnapshot(model, snapshot, problems);
|
||||||
validateExpressionTypesAndDomains(model, snapshot, problems);
|
validateExpressionTypesAndDomains(model, snapshot, problems);
|
||||||
@@ -248,6 +241,8 @@ public class FlowDefAdminService {
|
|||||||
"发布校验失败(表达式三查):\n" + String.join("\n", problems));
|
"发布校验失败(表达式三查):\n" + String.join("\n", problems));
|
||||||
}
|
}
|
||||||
def.setContentJson(embedFormContract(def.getContentJson(), snapshot));
|
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.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode());
|
||||||
def.setPublishTime(LocalDateTime.now());
|
def.setPublishTime(LocalDateTime.now());
|
||||||
@@ -435,22 +430,38 @@ 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()) {
|
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.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) {
|
private Map<String, Object> checkedSchema(Long schId, String uuid, Integer version, String operation) {
|
||||||
if (version == null || version < 1) {
|
if (version == null) {
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单必须选择确切的正整数版本,请重新选择");
|
throw new BizException(FormBindingErrorCode.VERSION_REQUIRED, "关联表单缺少明确版本,"
|
||||||
|
+ operation + "已阻断,请重新选择表单与版本");
|
||||||
|
}
|
||||||
|
if (version < 1 || uuid == null || uuid.trim().isEmpty()) {
|
||||||
|
throw new BizException(FormBindingErrorCode.INVALID_COORDINATE,
|
||||||
|
"关联表单必须选择确切的正整数版本," + operation + "已阻断,请重新选择");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
|
return formCenterSchemaClient.fetchSchema(schId, uuid, version);
|
||||||
} catch (FormCenterSchemaClient.SchemaUnavailableException
|
} catch (FormCenterSchemaClient.SchemaUnavailableException e) {
|
||||||
| FormCenterSchemaClient.SchemaContractViolationException e) {
|
throw new BizException(FormBindingErrorCode.SCHEMA_UNAVAILABLE,
|
||||||
throw new BizException(ErrorCode.PARAM_ERROR, "关联表单核验失败,操作已阻断:" + e.getMessage());
|
"关联表单快照拉取失败," + 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.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
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 lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -14,10 +17,10 @@ import java.util.Map;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
|
* form-center schema 快照客户端(2026-10-20 C1 第一刀:发布期契约快照)。<br>
|
||||||
* GET {目录解析 form-center}/oapi/form/schema?schId=&uuid= ——按 form_uuid 拉当前已发布版 schema
|
* GET {目录解析 form-center}/oapi/form/schema?schId=&uuid=&version= ——只取明确发布版本。
|
||||||
* (T11 建立的 by-uuid 形态;version 缺省=当前发布版)。调用形态对齐 {@link RouteJudgeClient}:
|
* 保存、发布与启用共用精确坐标契约;无 UUID-only 回退。调用形态对齐 YschOpenCall:
|
||||||
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
|
* 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。<br>
|
||||||
* <b>只在发布期调用</b>(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
|
* <b>只在管理期调用</b>(拉取失败=阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。<br>
|
||||||
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
|
* audience 解析:<b>契约 v2 C5/G3 起是字符串数组</b>(breaking,T13 切换)——本客户端按数组读取,
|
||||||
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
|
* 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。
|
||||||
*/
|
*/
|
||||||
@@ -60,48 +63,28 @@ public class FormCenterSchemaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** FC 将不存在、不可读、未发布的精确版本统一拒绝,不能靠文案区分资源存在性。 */
|
||||||
* 发布期快照:{formUuid, formVersion, fields:[{key,type,required,enum?}], audience(数组)}。
|
public static class SchemaResourceUnavailableException extends RuntimeException {
|
||||||
* fields 由 groups 扁平化(分组只是展示结构,表达式键域与分组无关);
|
public SchemaResourceUnavailableException(Throwable cause) {
|
||||||
* select/radio 类字段的 options 值域收进 enum(供域查③,本轮仅透传)。
|
super("表单或指定发布版本不存在或不可读", cause);
|
||||||
*/
|
}
|
||||||
public Map<String, Object> fetchSchema(Long schId, String formUuid) {
|
}
|
||||||
RuntimeException last = null;
|
|
||||||
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
public static class SchemaAccessDeniedException extends RuntimeException {
|
||||||
try {
|
public SchemaAccessDeniedException(Throwable cause) {
|
||||||
Map<String, String> query = new HashMap<>();
|
super("表单访问身份或授权被拒绝", cause);
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw new SchemaUnavailableException(
|
|
||||||
"关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
|
* 发布期快照的<b>确切版本</b>形态(v3/TASK_1):{@code GET /oapi/form/schema?schId=&uuid=&version=},
|
||||||
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
|
* 并把<b>返回坐标与请求坐标逐件核对</b>——不符即 {@link SchemaContractViolationException}(不重试)。
|
||||||
*
|
*
|
||||||
* <p>刻意不与两参 {@link #fetchSchema(Long, String)} 共用循环体:那条是 C1 第一刀的既有行为
|
* fields 按分组扁平化,options 值域收进 enum;没有缺省当前版本的重载。
|
||||||
* ({@code FormCenterSchemaClientTest} 逐条钉住它的重试与文案),改它等于同时动两条契约。
|
|
||||||
* 新管理链(def 带 {@code formVersion})只走本方法。</p>
|
|
||||||
*/
|
*/
|
||||||
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
|
public Map<String, Object> fetchSchema(Long schId, String formUuid, Integer version) {
|
||||||
if (version == null) {
|
if (version == null || version < 1 || formUuid == null || formUuid.trim().isEmpty()) {
|
||||||
throw new IllegalArgumentException("按确切版本取快照必须给 version(缺版本=取当前发布版,那是旧口径)");
|
throw new IllegalArgumentException("按确切版本取快照必须给 uuid 与正整数 version");
|
||||||
}
|
}
|
||||||
RuntimeException last = null;
|
RuntimeException last = null;
|
||||||
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
@@ -119,6 +102,20 @@ public class FormCenterSchemaClient {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
} catch (SchemaContractViolationException e) {
|
} catch (SchemaContractViolationException e) {
|
||||||
throw 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) {
|
} catch (RuntimeException e) {
|
||||||
last = e;
|
last = e;
|
||||||
log.warn("[flow-center] form-center schema 精确版本拉取失败(第 {}/{} 次):uuid={} v={} {}",
|
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) {
|
private static void requireCoordinate(Map<String, Object> snapshot, String formUuid, Integer version) {
|
||||||
Object gotUuid = snapshot.get("formUuid");
|
Object gotUuid = snapshot.get("formUuid");
|
||||||
Object gotVersion = snapshot.get("formVersion");
|
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)
|
|| !String.valueOf(gotUuid).equals(formUuid)
|
||||||
|| Integer.parseInt(String.valueOf(gotVersion)) != version) {
|
|| !parsedVersion.equals(version)) {
|
||||||
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
|
throw new SchemaContractViolationException("表单中心返回的坐标与请求不符(请求 "
|
||||||
+ formUuid + " v" + version + ",返回 " + gotUuid + " v" + gotVersion + ")");
|
+ 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.entity.FlwDef;
|
||||||
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.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.FlwCallbackRegRepository;
|
||||||
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
import com.yunhai.flowcenter.repository.FlwDefRepository;
|
||||||
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
import com.yunhai.flowcenter.repository.FlwInstanceRepository;
|
||||||
@@ -45,13 +48,12 @@ import static org.mockito.Mockito.when;
|
|||||||
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
|
* 取消关联=两列同时清空(留一个没人认领的版本号是下一轮误用的起点);</li>
|
||||||
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
|
* <li><b>发布取确切版本</b>:带版本的关联走 {@code schema?uuid=&version=};
|
||||||
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
|
* <b>中心返回的坐标与请求不符即阻断发布</b>,而不是把错的快照嵌进 def 版本行;
|
||||||
* 本列之前就已关联的存量行(formVersion=NULL)继续走"当前发布版"旧口径,行为逐字不变;</li>
|
* 存量 UUID-only 行发布与启用同样阻断,必须重新绑定明确版本;</li>
|
||||||
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
|
* <li><b>目录客户端的调用形态</b>:命名目标 {@code form-center} + 查询里带 schId/page/pageSize,
|
||||||
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
|
* 空 data 按"目录不可用"拒(不给一个看起来正常的空列表掩盖授权没配)。</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
* <p>注:{@code FlowDefAdminServicePublishContractTest}/{@code FlowDefAdminServiceSaveTest} 钉的是
|
* <p>发布与启用按同一错误码分类;表达式三查测试同时使用明确版本。</p>
|
||||||
* 改造前既有形态(两参 fetchSchema 与 8 参 save),本类只补 v3 新增的那几条,不改它们的断言。</p>
|
|
||||||
*/
|
*/
|
||||||
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
|
@DisplayName("流程定义绑定表单中心 - v3 单测(TASK_1)")
|
||||||
class FlFormBindingTest {
|
class FlFormBindingTest {
|
||||||
@@ -134,6 +136,7 @@ class FlFormBindingTest {
|
|||||||
@DisplayName("成对提交:uuid 与 version 一起落行")
|
@DisplayName("成对提交:uuid 与 version 一起落行")
|
||||||
void pairIsSavedTogether() {
|
void pairIsSavedTogether() {
|
||||||
when(defRepository.findById(9L)).thenReturn(Optional.of(draft(null, null)));
|
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,
|
FlwDef saved = service.save(SCH, 9L, "work-leave", "请假", SIMPLE_JSON,
|
||||||
null, null, null, "FU1", 3);
|
null, null, null, "FU1", 3);
|
||||||
@@ -198,7 +201,6 @@ class FlFormBindingTest {
|
|||||||
FlwDef published = service.publish(SCH, 9L);
|
FlwDef published = service.publish(SCH, 9L);
|
||||||
|
|
||||||
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
|
verify(schemaClient).fetchSchema(SCH, "FU1", 3);
|
||||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
|
|
||||||
assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
|
assertTrue(published.getContentJson().contains("\"formContract\""), published.getContentJson());
|
||||||
assertTrue(published.getContentJson().contains("FU1"));
|
assertTrue(published.getContentJson().contains("FU1"));
|
||||||
}
|
}
|
||||||
@@ -214,22 +216,112 @@ class FlFormBindingTest {
|
|||||||
|
|
||||||
BizException e = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
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());
|
||||||
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
|
assertTrue(e.getMessage().contains("发布已阻断"), e.getMessage());
|
||||||
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
|
assertEquals(FlwEnums.DefStatus.DRAFT.getCode(), def.getStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@ParameterizedTest
|
||||||
@DisplayName("存量行(本列之前已关联、无版本):继续走当前发布版旧口径")
|
@ValueSource(booleans = {false, true})
|
||||||
void legacyRowWithoutVersionKeepsOldBehaviour() {
|
@DisplayName("缺版本:发布和启用均阻断,不回落当前版本")
|
||||||
|
void missingVersionBlocksPublishAndEnable(boolean enable) {
|
||||||
FlwDef def = draft("FU1", null);
|
FlwDef def = draft("FU1", null);
|
||||||
|
if (enable) def.setStatus(FlwEnums.DefStatus.DISABLED.getCode());
|
||||||
when(defRepository.findById(9L)).thenReturn(Optional.of(def));
|
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(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
|
@Test
|
||||||
@@ -240,7 +332,6 @@ class FlFormBindingTest {
|
|||||||
|
|
||||||
service.publish(SCH, 9L);
|
service.publish(SCH, 9L);
|
||||||
|
|
||||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
|
|
||||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-9
@@ -85,6 +85,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
d.setAppKey("notice");
|
d.setAppKey("notice");
|
||||||
d.setContentJson(JSON);
|
d.setContentJson(JSON);
|
||||||
d.setFormUuid(formUuid);
|
d.setFormUuid(formUuid);
|
||||||
|
d.setFormVersion(formUuid == null ? null : 3);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
@Test
|
@Test
|
||||||
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
|
void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception {
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
|
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);
|
FlwDef published = service.publish(SCH, 9L);
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
// 出口条件改成引用快照外变量
|
// 出口条件改成引用快照外变量
|
||||||
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
|
d.setContentJson(JSON.replace("grade == 'g1'", "level == 'high'"));
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
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));
|
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||||
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
|
assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage());
|
||||||
@@ -147,7 +148,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
FlwDef d = draft("FU1");
|
FlwDef d = draft("FU1");
|
||||||
d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3"));
|
d.setContentJson(JSON.replace("grade == 'g1'", "reason > 3"));
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
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));
|
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||||
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
|
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"),
|
||||||
@@ -159,7 +160,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
FlwDef d = draft("FU1");
|
FlwDef d = draft("FU1");
|
||||||
d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'"));
|
d.setContentJson(JSON.replace("grade == 'g1'", "days == 'three'"));
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
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));
|
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||||
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
|
assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"),
|
||||||
@@ -171,7 +172,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
FlwDef d = draft("FU1");
|
FlwDef d = draft("FU1");
|
||||||
d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'"));
|
d.setContentJson(JSON.replace("grade == 'g1'", "grade == 'g9'"));
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
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));
|
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
||||||
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
|
assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"),
|
||||||
@@ -184,7 +185,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
FlwDef d = draft("FU1");
|
FlwDef d = draft("FU1");
|
||||||
d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5"));
|
d.setContentJson(JSON.replace("grade == 'g1'", "days >= 2.5"));
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
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());
|
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
|
||||||
}
|
}
|
||||||
@@ -195,7 +196,7 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
FlwDef d = draft("FU1");
|
FlwDef d = draft("FU1");
|
||||||
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
|
d.setContentJson(JSON.replace("grade == 'g1'", "bizType == 'x'"));
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(d));
|
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());
|
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus());
|
||||||
}
|
}
|
||||||
@@ -208,16 +209,18 @@ class FlowDefAdminServicePublishContractTest {
|
|||||||
|
|
||||||
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
|
assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus());
|
||||||
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
|
assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动");
|
||||||
verify(schemaClient, never()).fetchSchema(anyLong(), anyString());
|
verify(schemaClient, never()).fetchSchema(anyLong(), anyString(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void schemaUnavailableBlocksPublishAsRetryableBizError() {
|
void schemaUnavailableBlocksPublishAsRetryableBizError() {
|
||||||
when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1")));
|
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));
|
.thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null));
|
||||||
|
|
||||||
BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L));
|
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());
|
assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage());
|
||||||
verify(defRepository, never()).save(any());
|
verify(defRepository, never()).save(any());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ package com.yunhai.flowcenter.service;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.yunhai.platform.openapi.core.YschOpenCall;
|
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.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -72,7 +78,7 @@ class FormCenterSchemaClientTest {
|
|||||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class)))
|
||||||
.thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER")));
|
.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("U1", snap.get("formUuid"));
|
||||||
assertEquals(3, snap.get("formVersion"));
|
assertEquals(3, snap.get("formVersion"));
|
||||||
@@ -88,11 +94,11 @@ class FormCenterSchemaClientTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void legacySingleStringAudienceIsWrappedAsArray() {
|
void legacySingleStringAudienceIsWrappedAsArray() {
|
||||||
Map<String, Object> data = schemaData("U1", 1, null);
|
Map<String, Object> data = schemaData("U1", 3, null);
|
||||||
data.put("audience", "STUDENT");
|
data.put("audience", "STUDENT");
|
||||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data);
|
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"),
|
assertEquals(Arrays.asList("STUDENT"), snap.get("audience"),
|
||||||
"C5 切换窗口的旧单值串按一元素数组容错");
|
"C5 切换窗口的旧单值串按一元素数组容错");
|
||||||
@@ -104,7 +110,7 @@ class FormCenterSchemaClientTest {
|
|||||||
.thenThrow(new RuntimeException("connect refused"));
|
.thenThrow(new RuntimeException("connect refused"));
|
||||||
|
|
||||||
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
||||||
() -> client.fetchSchema(27L, "U1"));
|
() -> client.fetchSchema(27L, "U1", 3));
|
||||||
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
|
verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS))
|
||||||
.get(anyString(), anyString(), anyMap(), eq(Map.class));
|
.get(anyString(), anyString(), anyMap(), eq(Map.class));
|
||||||
}
|
}
|
||||||
@@ -115,7 +121,77 @@ class FormCenterSchemaClientTest {
|
|||||||
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
|
when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null);
|
||||||
|
|
||||||
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class,
|
||||||
() -> client.fetchSchema(27L, "U1"));
|
() -> client.fetchSchema(27L, "U1", 3));
|
||||||
assertTrue(true);
|
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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user