From 97dec39d4598356214580f57eef816e832f133d2 Mon Sep 17 00:00:00 2001 From: chenxing Date: Tue, 20 Oct 2026 22:50:58 +0800 Subject: [PATCH] =?UTF-8?q?C1=E7=AC=AC=E4=B8=80=E5=88=80:flw=5Fdef.form=5F?= =?UTF-8?q?uuid=E5=90=AF=E7=94=A8+=E5=8F=91=E5=B8=83=E6=9C=9Fform-center?= =?UTF-8?q?=20schema=E5=BF=AB=E7=85=A7=E5=B5=8C=E5=85=A5formContract+?= =?UTF-8?q?=E8=A1=A8=E8=BE=BE=E5=BC=8F=E9=94=AE=E6=9F=A5=E2=91=A0=E7=BA=A2?= =?UTF-8?q?=E7=89=8C=EF=BC=88audience=E6=8C=89=E6=95=B0=E7=BB=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/api/FlowAdminController.java | 5 +- .../flowcenter/engine/DefValidator.java | 8 +- .../service/FlowDefAdminService.java | 105 ++++++++++- .../service/FormCenterSchemaClient.java | 135 +++++++++++++ ...lowDefAdminServicePublishContractTest.java | 177 ++++++++++++++++++ .../service/FlowDefAdminServiceSaveTest.java | 3 +- .../FlowDefAdminServiceUpdateMetaTest.java | 3 +- .../service/FormCenterSchemaClientTest.java | 121 ++++++++++++ 8 files changed, 550 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java create mode 100644 src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java 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 5f3ff85..0b8dd13 100644 --- a/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java +++ b/src/main/java/com/yunhai/flowcenter/controller/api/FlowAdminController.java @@ -103,7 +103,7 @@ public class FlowAdminController { return defAdminService.get(adminGuard.requireAdmin(user, null), id); } - /** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;changeLog=升版本改动说明,仅新版本行落库,>500 截断) */ + /** 保存(新建草稿/编辑;已发布定义保存=升版本,flowGuid 不变;baseGuid=在指定流程线上开新版本草稿;changeLog=升版本改动说明,仅新版本行落库,>500 截断;formUuid=关联表单定义 uuid(C1,2026-10-20):null=保持、空串=取消关联、发布后不可改改=升版) */ @PostMapping("/def/save") public FlwDef defSave(BoaUser user, @RequestBody Map body) { Long schId = adminGuard.requireAdmin(user, null); @@ -111,7 +111,8 @@ public class FlowAdminController { return defAdminService.save(schId, id, (String) body.get("code"), (String) body.get("name"), (String) body.get("contentJson"), (String) body.get("baseGuid"), - (String) body.get("appKey"), (String) body.get("changeLog")); + (String) body.get("appKey"), (String) body.get("changeLog"), + (String) body.get("formUuid")); } /** diff --git a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java index 1d4cdf0..be2ec02 100644 --- a/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java +++ b/src/main/java/com/yunhai/flowcenter/engine/DefValidator.java @@ -282,8 +282,12 @@ public final class DefValidator { } } - /** 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集) */ - private static final Set EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList( + /** + * 表达式内置变量白名单(ConditionRouteService.builtinEnv 注入的同名集)。
+ * 2026-10-20 C1 起 {@link com.yunhai.flowcenter.service.FlowDefAdminService} 的键查①红牌 + * 复用本集合(表达式变量 ⊆ 快照字段 ∪ 内置变量)——两处必须是同一份,内置集扩了这边自动跟上。 + */ + public static final Set EXPRESSION_BUILTINS = new HashSet<>(java.util.Arrays.asList( "schId", "instanceId", "instanceVersion", "bizType", "bizId", "nodeId", "nodeName", "submitterId")); diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index ceec023..4033022 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -43,6 +43,7 @@ public class FlowDefAdminService { private final FlowDeptAdminService deptAdminService; private final FlowEngine flowEngine; private final ObjectMapper objectMapper; + private final FormCenterSchemaClient formCenterSchemaClient; /** 定义列表:status/appKey 可选过滤(各流程线各版本都展示;appKey 精确匹配,2026-10-10 按应用筛选) */ public List list(Long schId, Integer status, String appKey) { @@ -169,7 +170,32 @@ public class FlowDefAdminService { } } - /** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布 */ + /** + * 保存重载(2026-10-20 C1 第一刀):formUuid=关联表单定义 uuid(form-center 对外身份,32 位)。
+ * 发布后不可改,改=升版本由既有升版分派天然保证(非 DRAFT 直改必走升版新行); + * 参数 null=保持不动(缺省语义,兼容旧调用面);空串=显式取消关联(归一 null 落库); + * 非空=设置(不做格式强校验——寻址失败在发布期由快照拉取阻断,错误不进库但也不卡保存)。 + */ + @Transactional + public FlwDef save(Long schId, Long id, String code, String name, String contentJson, + String baseGuid, String appKey, String changeLog, String formUuid) { + FlwDef saved = save(schId, id, code, name, contentJson, baseGuid, appKey, changeLog); + if (formUuid != null) { + String clean = formUuid.trim(); + saved.setFormUuid(clean.isEmpty() ? null : clean); + } + try { + return defRepository.save(saved); + } catch (DataIntegrityViolationException e) { + throw new BizException(ErrorCode.PARAM_ERROR, "保存冲突:该流程可能正被他人同时升版本,请返回列表刷新后重试"); + } + } + + /** 发布:静态校验(START/END 唯一、无孤儿无环、DEPT_KEY 有启用映射)后置为已发布。
+ * 2026-10-20 C1 第一刀:def 关联了表单(form_uuid 非空)时,发布期拉 form-center schema 快照 + * 嵌入版本行 content_json.formContract(与 defId 快照纪律同构:在途办件按发起时快照校验), + * 并做表达式键查①红牌(变量 ⊆ 快照字段 ∪ 内置变量)。拉取失败=发布阻断(可重试); + * 运行期对 form-center 零依赖。form_uuid 为空(存量线/Mode C)行为逐字不变。 */ @Transactional public FlwDef publish(Long schId, Long id) { FlwDef def = requireDef(schId, id); @@ -178,11 +204,88 @@ 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 = formCenterSchemaClient.fetchSchema(schId, def.getFormUuid().trim()); + } catch (FormCenterSchemaClient.SchemaUnavailableException e) { + throw new BizException(ErrorCode.PARAM_ERROR, + "关联表单快照拉取失败,发布已阻断(可重试):" + e.getMessage()); + } + List problems = new ArrayList<>(); + validateExpressionKeysAgainstSnapshot(model, snapshot, problems); + if (!problems.isEmpty()) { + throw new BizException(ErrorCode.PARAM_ERROR, + "发布校验失败(表达式键查①):\n" + String.join("\n", problems)); + } + def.setContentJson(embedFormContract(def.getContentJson(), snapshot)); + } def.setStatus(FlwEnums.DefStatus.PUBLISHED.getCode()); def.setPublishTime(LocalDateTime.now()); return defRepository.save(def); } + /** + * 表达式键查①(C1 三查之一,红牌):对每条 EXPRESSION 判断的出边表达式, + * 变量集 ⊆ 快照字段 keys ∪ 内置变量({@link DefValidator#EXPRESSION_BUILTINS})。 + * 不执行表达式、不改沙箱(口径与 v2 方案 §4.7 一致)。 + */ + private void validateExpressionKeysAgainstSnapshot(FlowDefModel model, Map snapshot, + List problems) { + @SuppressWarnings("unchecked") + List> fields = (List>) snapshot.get("fields"); + java.util.Set fieldKeys = new java.util.HashSet<>(); + if (fields != null) { + for (Map f : fields) { + Object k = f.get("key"); + if (k != null) { + fieldKeys.add(String.valueOf(k)); + } + } + } + if (fieldKeys.isEmpty()) { + problems.add("关联表单快照无字段(formContract.fields 为空):请检查表单是否已发布"); + return; + } + for (FlowDefModel.Node n : model.nodes) { + if (n == null || !n.expressionJudge()) { + continue; + } + for (FlowDefModel.Edge e : model.outEdges(n.id)) { + if (e.condition == null || e.condition.trim().isEmpty()) { + continue; + } + List vars; + try { + vars = com.yunhai.flowcenter.engine.ExpressionEvaluator.variableNames(e.condition.trim()); + } catch (Exception ex) { + // 语法错已由 DefValidator 拦下,这里理论上到不了;防御口径跳过 + continue; + } + for (String v : vars) { + if (!fieldKeys.contains(v) && !DefValidator.EXPRESSION_BUILTINS.contains(v)) { + problems.add("节点「" + n.name + "」出口「" + e.name + "」表达式变量 " + v + + " 不在关联表单字段集内(快照 " + fieldKeys.size() + " 个字段)"); + } + } + } + } + } + + /** content_json 顶层嵌入 formContract 节(ObjectNode 保字段顺序;原值存在则整体替换=本版快照) */ + private String embedFormContract(String contentJson, Map snapshot) { + try { + com.fasterxml.jackson.databind.node.ObjectNode root = (com.fasterxml.jackson.databind.node.ObjectNode) + objectMapper.readTree(contentJson); + root.set("formContract", objectMapper.valueToTree(snapshot)); + return objectMapper.writeValueAsString(root); + } catch (BizException e) { + throw e; + } catch (Exception e) { + throw new BizException(ErrorCode.PARAM_ERROR, "快照嵌入失败:" + e.getMessage()); + } + } + /** 停用:不可新发起,存量跑完 */ @Transactional public void offline(Long schId, Long id) { diff --git a/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java new file mode 100644 index 0000000..9bae793 --- /dev/null +++ b/src/main/java/com/yunhai/flowcenter/service/FormCenterSchemaClient.java @@ -0,0 +1,135 @@ +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 lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +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}: + * 身份=本应用 appKey(签名拦截器由 {@link YschOpenCall} 装配链注入)。
+ * 只在发布期调用(拉取失败=发布阻断,可重试);运行期对 form-center 零依赖(快照嵌 def 版本行)。
+ * audience 解析:契约 v2 C5/G3 起是字符串数组(breaking,T13 切换)——本客户端按数组读取, + * 快照不消费人群语义、只透传形态,兼容旧单值串(读到串自动包一层数组,迁移窗口容错)。 + */ +@Slf4j +@Service +public class FormCenterSchemaClient { + + /** 服务目录 key(yh.open.services.form-center / targets 覆盖 / 枚举兜底三级解析) */ + static final String TARGET = "form-center"; + static final String SCHEMA_PATH = "/oapi/form/schema"; + /** 快照是发布期一次性动作,超时给足(对照 RouteJudgeClient 的 3s 运行期预算) */ + static final int TIMEOUT_MS = 10_000; + static final int MAX_ATTEMPTS = 2; + + private final YschOpenCall openCall; + private final ObjectMapper objectMapper; + + public FormCenterSchemaClient(YschOpenCall openCall, ObjectMapper objectMapper) { + this.openCall = openCall; + this.objectMapper = objectMapper; + } + + /** 快照不可用(网络/信封/形态异常);发布期转 BizException 阻断,可重试 */ + public static class SchemaUnavailableException extends RuntimeException { + public SchemaUnavailableException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * 发布期快照:{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()); + } + } + throw new SchemaUnavailableException( + "关联表单 schema 快照拉取失败(已重试 " + MAX_ATTEMPTS + " 次,发布阻断,可重试)", last); + } + + /** SchemaView(data) → 快照节(fields 扁平化 + audience 数组归一) */ + private Map toSnapshot(Map data) { + Map snap = new LinkedHashMap<>(); + snap.put("formUuid", data.get("formUuid")); + snap.put("formVersion", data.get("version")); + snap.put("audience", normalizeAudience(data.get("audience"))); + List> fields = new ArrayList<>(); + JsonNode groups = data.get("groups") == null ? null + : objectMapper.valueToTree(data.get("groups")); + if (groups != null && groups.isArray()) { + for (JsonNode group : groups) { + JsonNode groupFields = group.get("fields"); + if (groupFields == null || !groupFields.isArray()) { + continue; + } + for (JsonNode f : groupFields) { + Map field = new LinkedHashMap<>(); + field.put("key", f.path("key").asText(null)); + field.put("type", f.path("type").asText(null)); + JsonNode required = f.get("required"); + field.put("required", required == null ? Boolean.FALSE : required.asBoolean(false)); + JsonNode options = f.get("options"); + if (options != null && options.isArray() && options.size() > 0) { + List enums = new ArrayList<>(); + for (JsonNode opt : options) { + JsonNode v = opt.has("value") ? opt.get("value") : opt.get("label"); + if (v != null) { + enums.add(v.asText()); + } + } + field.put("enum", enums); + } + fields.add(field); + } + } + } + snap.put("fields", fields); + return snap; + } + + /** audience 数组归一:数组照读;旧单值串包一层(迁移窗口容错);null → 空数组 */ + private List normalizeAudience(Object audience) { + List out = new ArrayList<>(); + if (audience instanceof Iterable) { + for (Object a : (Iterable) audience) { + if (a != null) { + out.add(String.valueOf(a)); + } + } + } else if (audience != null) { + out.add(String.valueOf(audience)); + } + return out; + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java new file mode 100644 index 0000000..17f7cbc --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java @@ -0,0 +1,177 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.flowcenter.engine.FlowEngine; +import com.yunhai.flowcenter.entity.FlwDef; +import com.yunhai.flowcenter.enums.FlwEnums; +import com.yunhai.flowcenter.repository.FlwAppRepository; +import com.yunhai.flowcenter.repository.FlwCallbackRegRepository; +import com.yunhai.flowcenter.repository.FlwDefRepository; +import com.yunhai.flowcenter.repository.FlwInstanceRepository; +import com.yunhai.flowcenter.repository.FlwRouterRepository; +import com.yunhai.platform.apistd.BizException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * publish() 的 C1 契约测试(2026-10-20 第一刀:发布期快照 + 表达式键查①红牌)。
+ * 覆盖:① 关联表单(form_uuid 非空)发布成功=content_json 顶层嵌 formContract 且 status=1; + * ② 表达式变量 ∉ 快照字段 ∪ 内置变量 → 红牌阻断(点名节点/出口/变量);③ form_uuid 为空(存量线) + * 不拉快照、行为不变;④ 快照拉取失败=发布阻断(可重试语义)。 + */ +class FlowDefAdminServicePublishContractTest { + + private static final Long SCH = 27L; + + /** 含 CONDITION(EXPRESSION) 节点的合法定义:days/grade 是表单字段、schId 是内置变量 */ + private static final String JSON = "{\"start\":\"s\",\"nodes\":[" + + "{\"id\":\"s\",\"type\":\"START\"}," + + "{\"id\":\"c\",\"type\":\"CONDITION\",\"name\":\"判断\",\"judgeMode\":\"EXPRESSION\"}," + + "{\"id\":\"a\",\"type\":\"APPROVAL\",\"name\":\"审1\",\"assignee\":{\"type\":\"USERS\",\"userIds\":[1]}}," + + "{\"id\":\"e\",\"type\":\"END\"}]," + + "\"edges\":[" + + "{\"from\":\"s\",\"to\":\"c\"}," + + "{\"from\":\"c\",\"to\":\"a\",\"name\":\"准\",\"condition\":\"days > 3 && schId > 0\"}," + + "{\"from\":\"c\",\"to\":\"e\",\"name\":\"否\",\"condition\":\"grade == 'g1'\"}," + + "{\"from\":\"a\",\"to\":\"e\"}]}"; + + private FlwDefRepository defRepository; + private FormCenterSchemaClient schemaClient; + private FlowDefAdminService service; + private final ObjectMapper mapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + defRepository = mock(FlwDefRepository.class); + schemaClient = mock(FormCenterSchemaClient.class); + service = new FlowDefAdminService(defRepository, + mock(FlwRouterRepository.class), + mock(FlwCallbackRegRepository.class), + mock(FlwAppRepository.class), + mock(FlwInstanceRepository.class), + mock(FlowDeptAdminService.class), + mock(FlowEngine.class), + mapper, + schemaClient); + when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + } + + private FlwDef draft(String formUuid) { + FlwDef d = new FlwDef(); + d.setId(9L); + d.setSchId(SCH); + d.setFlowGuid("g1"); + d.setCode("work-leave"); + d.setVersion(1); + d.setStatus(FlwEnums.DefStatus.DRAFT.getCode()); + d.setAppKey("notice"); + d.setContentJson(JSON); + d.setFormUuid(formUuid); + return d; + } + + /** 快照:days(number)/grade(select enum g1,g2)/reason(text) */ + private Map snapshot() { + Map snap = new LinkedHashMap<>(); + snap.put("formUuid", "FU1"); + snap.put("formVersion", 3); + snap.put("audience", Arrays.asList("STUDENT", "TEACHER")); + snap.put("fields", Arrays.asList( + field("days", "number", true, null), + field("grade", "select", false, Arrays.asList("g1", "g2")), + field("reason", "text", true, null))); + return snap; + } + + private static Map field(String key, String type, boolean required, List enums) { + Map f = new LinkedHashMap<>(); + f.put("key", key); + f.put("type", type); + f.put("required", required); + if (enums != null) { + f.put("enum", enums); + } + return f; + } + + @Test + void publishEmbedsFormContractSnapshotIntoVersionRow() throws Exception { + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1"))); + when(schemaClient.fetchSchema(SCH, "FU1")).thenReturn(snapshot()); + + FlwDef published = service.publish(SCH, 9L); + + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus()); + JsonNode contract = mapper.readTree(published.getContentJson()).get("formContract"); + assertNotNull(contract, "content_json 顶层必须嵌 formContract"); + assertEquals("FU1", contract.get("formUuid").asText()); + assertEquals(3, contract.get("formVersion").asInt()); + assertEquals(3, contract.get("fields").size(), "fields 扁平化嵌入"); + assertTrue(contract.get("audience").isArray(), "audience 按数组解析入快照"); + } + + @Test + void unknownExpressionVariableIsRedCard() { + FlwDef d = draft("FU1"); + // 出口条件改成引用快照外变量 + 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()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage()); + assertTrue(ex.getMessage().contains("键查"), "错误文案给可定位的键查提示"); + verify(defRepository, never()).save(any()); + } + + @Test + void builtinVariablesPassKeyCheck() { + // days 与 schId(内置)都合法 → 上面的 happy path 已覆盖;此处单独断言纯内置表达式 + 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()); + + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus()); + } + + @Test + void legacyDefWithoutFormUuidSkipsSnapshotEntirely() { + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft(null))); + + FlwDef published = service.publish(SCH, 9L); + + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), published.getStatus()); + assertEquals(JSON, published.getContentJson(), "存量线 content_json 一字不动"); + verify(schemaClient, never()).fetchSchema(anyLong(), anyString()); + } + + @Test + void schemaUnavailableBlocksPublishAsRetryableBizError() { + when(defRepository.findById(9L)).thenReturn(java.util.Optional.of(draft("FU1"))); + when(schemaClient.fetchSchema(SCH, "FU1")) + .thenThrow(new FormCenterSchemaClient.SchemaUnavailableException("timeout", null)); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("快照拉取失败"), "发布阻断文案可辨认:" + ex.getMessage()); + verify(defRepository, never()).save(any()); + } +} diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java index 97d4a8d..eea6fc4 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceSaveTest.java @@ -51,7 +51,8 @@ class FlowDefAdminServiceSaveTest { mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), mock(FlowEngine.class), - new ObjectMapper()); + new ObjectMapper(), + mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class)); when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); // 归属应用白名单:非空 appKey 一律放行(校验拦截语义由专门用例覆盖) when(appRepository.isEnabledApp(any(), any())).thenReturn(true); diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java index 7173324..01f655c 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServiceUpdateMetaTest.java @@ -43,7 +43,8 @@ class FlowDefAdminServiceUpdateMetaTest { mock(com.yunhai.flowcenter.repository.FlwInstanceRepository.class), mock(com.yunhai.flowcenter.service.FlowDeptAdminService.class), mock(FlowEngine.class), - new ObjectMapper()); + new ObjectMapper(), + mock(com.yunhai.flowcenter.service.FormCenterSchemaClient.class)); when(defRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(appRepository.isEnabledApp(any(), any())).thenReturn(true); } diff --git a/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java new file mode 100644 index 0000000..472668f --- /dev/null +++ b/src/test/java/com/yunhai/flowcenter/service/FormCenterSchemaClientTest.java @@ -0,0 +1,121 @@ +package com.yunhai.flowcenter.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.yunhai.platform.openapi.core.YschOpenCall; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link FormCenterSchemaClient} 单元测试(2026-10-20 C1 第一刀)。
+ * 覆盖:① SchemaView(data) → 快照节的 fields 扁平化(分组展开、enum 收 options 值域); + * ② audience 数组解析(契约 v2 C5/G3 breaking)+ 旧单值串容错;③ 失败重试 2 次后转 + * SchemaUnavailableException(发布阻断语义)。 + */ +class FormCenterSchemaClientTest { + + private YschOpenCall openCall; + private FormCenterSchemaClient client; + + @BeforeEach + void setUp() { + openCall = mock(YschOpenCall.class); + client = new FormCenterSchemaClient(openCall, new ObjectMapper()); + } + + @SuppressWarnings("unchecked") + private Map schemaData(String uuid, int version, List audience) { + Map data = new HashMap<>(); + data.put("formUuid", uuid); + data.put("version", version); + data.put("code", "leave-form"); + data.put("appCode", "svc-hall"); + data.put("audience", audience); + data.put("groups", Arrays.asList( + map("key", "base", "label", "基本信息", "fields", Arrays.asList( + map("key", "days", "label", "天数", "type", "number", "required", true), + map("key", "grade", "label", "年级", "type", "select", "required", false, + "options", Arrays.asList(map("label", "高一", "value", "g1"), + map("label", "高二", "value", "g2"))))), + map("key", "extra", "label", "补充", "fields", Arrays.asList( + map("key", "reason", "label", "事由", "type", "text", "required", true))))); + return data; + } + + private static Map map(Object... kv) { + Map m = new HashMap<>(); + for (int i = 0; i + 1 < kv.length; i += 2) { + m.put((String) kv[i], kv[i + 1]); + } + return m; + } + + @Test + @SuppressWarnings("unchecked") + void flattensGroupsIntoFieldsAndCollectsEnumFromOptions() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenReturn(schemaData("U1", 3, Arrays.asList("STUDENT", "TEACHER"))); + + Map snap = client.fetchSchema(27L, "U1"); + + assertEquals("U1", snap.get("formUuid")); + assertEquals(3, snap.get("formVersion")); + assertEquals(Arrays.asList("STUDENT", "TEACHER"), snap.get("audience")); + List> fields = (List>) snap.get("fields"); + assertEquals(3, fields.size(), "两个分组的字段要扁平化到一处"); + Map grade = fields.stream() + .filter(f -> "grade".equals(f.get("key"))).findFirst().orElseThrow(AssertionError::new); + assertEquals("select", grade.get("type")); + assertEquals(Boolean.FALSE, grade.get("required")); + assertEquals(Arrays.asList("g1", "g2"), grade.get("enum"), "options 值域收进 enum(域查③的输入)"); + } + + @Test + void legacySingleStringAudienceIsWrappedAsArray() { + Map data = schemaData("U1", 1, null); + data.put("audience", "STUDENT"); + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(data); + + Map snap = client.fetchSchema(27L, "U1"); + + assertEquals(Arrays.asList("STUDENT"), snap.get("audience"), + "C5 切换窗口的旧单值串按一元素数组容错"); + } + + @Test + void retriesTwiceThenThrowsUnavailable() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))) + .thenThrow(new RuntimeException("connect refused")); + + assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, + () -> client.fetchSchema(27L, "U1")); + verify(openCall, times(FormCenterSchemaClient.MAX_ATTEMPTS)) + .get(anyString(), anyString(), anyMap(), eq(Map.class)); + } + + @Test + @SuppressWarnings("unchecked") + void nullDataTreatedAsFailure() { + when(openCall.get(anyString(), anyString(), anyMap(), eq(Map.class))).thenReturn(null); + + assertThrows(FormCenterSchemaClient.SchemaUnavailableException.class, + () -> client.fetchSchema(27L, "U1")); + assertTrue(true); + } +}