diff --git a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java index 4033022..4d01424 100644 --- a/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java +++ b/src/main/java/com/yunhai/flowcenter/service/FlowDefAdminService.java @@ -214,9 +214,10 @@ public class FlowDefAdminService { } List problems = new ArrayList<>(); validateExpressionKeysAgainstSnapshot(model, snapshot, problems); + validateExpressionTypesAndDomains(model, snapshot, problems); if (!problems.isEmpty()) { throw new BizException(ErrorCode.PARAM_ERROR, - "发布校验失败(表达式键查①):\n" + String.join("\n", problems)); + "发布校验失败(表达式三查):\n" + String.join("\n", problems)); } def.setContentJson(embedFormContract(def.getContentJson(), snapshot)); } @@ -286,6 +287,101 @@ public class FlowDefAdminService { } } + // ===================== 三查②③(2026-10-20 C1 第一刀余量项) ===================== + + /** 数值桶:可与数值字面量比大小的快照字段类型 */ + private static final java.util.Set NUMERIC_TYPES = + new java.util.HashSet<>(java.util.Arrays.asList("number")); + /** 值域字面量型(enum 域查③适用):options 收进快照 enum 的类型 */ + private static final java.util.Set ENUM_TYPES = + new java.util.HashSet<>(java.util.Arrays.asList("select", "radio")); + + /** `字段 op '字面量'` / `字段 op 数值`(op 含两侧空格容忍) */ + private static final java.util.regex.Pattern FIELD_STR_EQ = java.util.regex.Pattern.compile( + "([A-Za-z_][A-Za-z0-9_]*)\\s*(==|!=)\\s*'([^']*)'"); + private static final java.util.regex.Pattern FIELD_NUM_CMP = java.util.regex.Pattern.compile( + "([A-Za-z_][A-Za-z0-9_]*)\\s*(>=|<=|>|<)\\s*(-?\\d+(\\.\\d+)?)"); + private static final java.util.regex.Pattern NUM_FIELD_CMP = java.util.regex.Pattern.compile( + "(-?\\d+(\\.\\d+)?)\\s*(>=|<=|>|<)\\s*([A-Za-z_][A-Za-z0-9_]*)"); + + /** + * 类型桶查②红牌 + 域查③红牌(启发式实现:正则模式匹配字面量比较形态,非 AST—— + * v2 §4.7 保守边界:不执行表达式、不做隐式转换,只拦"数值比较 × 非数值字段"与 + * "枚举等值 × 字面量不在值域"两类高发错配;不命中的形态留给运行期既有挂起纪律)。 + */ + @SuppressWarnings("unchecked") + private void validateExpressionTypesAndDomains(FlowDefModel model, Map snapshot, + List problems) { + List> fields = (List>) snapshot.get("fields"); + if (fields == null || fields.isEmpty()) { + return; + } + Map> fieldByKey = new HashMap<>(); + for (Map f : fields) { + Object k = f.get("key"); + if (k != null) { + fieldByKey.put(String.valueOf(k), f); + } + } + 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; + } + String where = "节点「" + n.name + "」出口「" + e.name + "」"; + String expr = e.condition.trim(); + // ② 类型桶查:数值比较算子作用于非数值字段 → 红牌 + java.util.regex.Matcher m = FIELD_NUM_CMP.matcher(expr); + while (m.find()) { + checkNumericBucket(where, m.group(1), m.group(2), fieldByKey, problems); + } + m = NUM_FIELD_CMP.matcher(expr); + while (m.find()) { + checkNumericBucket(where, m.group(4), m.group(3), fieldByKey, problems); + } + // ② 类型桶查(字符串侧):文本字面量等值比较数值字段 → 红牌 + // ③ 域查:枚举字段等值比较的字面量 ∉ 快照 enum 值域 → 红牌 + m = FIELD_STR_EQ.matcher(expr); + while (m.find()) { + String key = m.group(1); + String op = m.group(2); + String literal = m.group(3); + Map field = fieldByKey.get(key); + if (field == null) { + continue; // 内置变量或未知键:①已拦/不属本查 + } + String type = field.get("type") == null ? null : String.valueOf(field.get("type")); + if (NUMERIC_TYPES.contains(type)) { + problems.add(where + ":字段 " + key + " 是 number 类型,不能与字符串字面量 '" + + literal + "' 做 " + op + " 比较(类型桶查②)"); + } else if (ENUM_TYPES.contains(type) && "==".equals(op)) { + List enums = (List) field.get("enum"); + if (enums != null && !enums.isEmpty() && !enums.contains(literal)) { + problems.add(where + ":字段 " + key + " 的值域是 " + enums + + ",字面量 '" + literal + "' 不在其中(域查③)"); + } + } + } + } + } + } + + private void checkNumericBucket(String where, String key, String op, + Map> fieldByKey, List problems) { + Map field = fieldByKey.get(key); + if (field == null) { + return; + } + String type = field.get("type") == null ? null : String.valueOf(field.get("type")); + if (type != null && !NUMERIC_TYPES.contains(type)) { + problems.add(where + ":字段 " + key + " 是 " + type + " 类型,不能做数值比较 " + + key + " " + op + " 数字(类型桶查②)"); + } + } + /** 停用:不可新发起,存量跑完 */ @Transactional public void offline(Long schId, Long id) { diff --git a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java index 17f7cbc..18f9c38 100644 --- a/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java +++ b/src/test/java/com/yunhai/flowcenter/service/FlowDefAdminServicePublishContractTest.java @@ -138,10 +138,57 @@ class FlowDefAdminServicePublishContractTest { BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); assertTrue(ex.getMessage().contains("level"), "红牌要点名未知变量:" + ex.getMessage()); - assertTrue(ex.getMessage().contains("键查"), "错误文案给可定位的键查提示"); + assertTrue(ex.getMessage().contains("不在关联表单字段集内"), "键查①给可定位的问题行"); verify(defRepository, never()).save(any()); } + @Test + void numericCompareOnTextFieldIsRedCardTypeBucket() { + 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()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("reason"), + "text 字段做数值比较要点名(类型桶查②):" + ex.getMessage()); + } + + @Test + void stringLiteralEqualsOnNumberFieldIsRedCardTypeBucket() { + 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()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("类型桶查②") && ex.getMessage().contains("days"), + "number 字段与字符串字面量等值要拦:" + ex.getMessage()); + } + + @Test + void enumLiteralOutOfDomainIsRedCard() { + 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()); + + BizException ex = assertThrows(BizException.class, () -> service.publish(SCH, 9L)); + assertTrue(ex.getMessage().contains("域查③") && ex.getMessage().contains("g9"), + "枚举字面量不在值域要点名(域查③):" + ex.getMessage()); + } + + @Test + void numericFieldWithNumericLiteralPassesAllChecks() { + // days(number) > 3 与内置 schId > 0:happy path(publishEmbeds…已覆盖主链),此处锁数值正向桶 + 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()); + + assertEquals(FlwEnums.DefStatus.PUBLISHED.getCode(), service.publish(SCH, 9L).getStatus()); + } + @Test void builtinVariablesPassKeyCheck() { // days 与 schId(内置)都合法 → 上面的 happy path 已覆盖;此处单独断言纯内置表达式