From 47beb4eaa46f34830aa47b387624f6527c12a3ec Mon Sep 17 00:00:00 2001 From: chenxing Date: Wed, 16 Sep 2026 20:22:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A8=E5=8D=95=E4=B8=80=E6=9C=9F?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=E6=89=A9=E5=88=B0=2011=20=E7=B1=BB=EF=BC=88+?= =?UTF-8?q?checkbox/multiselect/phone/image=EF=BC=89+=20=E5=A5=91=E7=BA=A6?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FormValidator:PHASE1_TYPES 加 checkbox/multiselect/phone/image;options 必填口径扩到 select/radio/checkbox/multiselect(4 类);validateValue 补多选(值须在 options 内)分支, image 并入 upload 的附件数组通道 - 审计修复:maxFiles 上限判定改为 maxFiles>0(缺省或 0=不限),与前端设计器「留空不限」 及渲染器口径对齐——原实现把 0 当上限,会让 maxFiles:0 的表单任何附件都被拒 - ServiceForm 注释纠偏:契约类名 FormValidator(原误写 FormSchemaValidator)、一期 11 类 - 测试:FormValidatorTest +6(新 4 类 schema、多分组计数、options 必填、多选越界、phone pattern/maxLength、image maxFiles/非数组、precision 非白名单)、ServiceFormServiceTest +4 (冻结行不可变契约:带已发布版本 id 被拒 / 不传 id 按 code 升版出草稿 / 草稿就地更新) 验证:mvn test 102/102 绿(基线 92)。 --- .../yunhai/svchall/entity/ServiceForm.java | 4 +- .../yunhai/svchall/utils/FormValidator.java | 42 +++++-- .../service/impl/ServiceFormServiceTest.java | 76 +++++++++++- .../svchall/utils/FormValidatorTest.java | 114 +++++++++++++++++- 4 files changed, 223 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java index 6d78757..bcc006c 100644 --- a/src/main/java/com/yunhai/svchall/entity/ServiceForm.java +++ b/src/main/java/com/yunhai/svchall/entity/ServiceForm.java @@ -14,7 +14,7 @@ import java.time.LocalDateTime; * 自定义表单定义(V2.0 Part B.2.3,自流程中心 flw_form 平移——表单能力归办事大厅)。 * 行不可变原则:已发布版本不可改,编辑已发布表单 = 克隆出新草稿版本(version+1)。 * 事项经 form_code 绑定,发起时取**最新已发布版本**并快照 form_version。 - * schema_json 契约见 FormSchemaValidator(14 类命名冻结、声明式校验白名单、groups 分组)。 + * schema_json 契约见 FormValidator(14 类命名冻结、一期放行 11 类、声明式校验白名单、groups 分组)。 */ @Getter @Setter @@ -46,7 +46,7 @@ public class ServiceForm extends BaseEntity { @Column(name = "status", nullable = false) private Integer status = 0; - /** 表单结构 JSON({"groups":[{title,fields:[…]}]};M1 仅放行 7 类控件) */ + /** 表单结构 JSON({"groups":[{title,fields:[…]}]};一期放行 11 类控件) */ @Column(name = "schema_json", nullable = false, columnDefinition = "LONGTEXT") private String schemaJson; diff --git a/src/main/java/com/yunhai/svchall/utils/FormValidator.java b/src/main/java/com/yunhai/svchall/utils/FormValidator.java index fd3519b..b47dee2 100644 --- a/src/main/java/com/yunhai/svchall/utils/FormValidator.java +++ b/src/main/java/com/yunhai/svchall/utils/FormValidator.java @@ -17,7 +17,7 @@ import java.util.regex.Pattern; /** * 表单 schema 契约校验 + 提交数据强校验(V2.0 Part B.3;契约自流程中心概览 v1.2 §4.2 平移)。 - * 纪律:类型用字符串 key(一期 7 类 / 14 类命名冻结);校验只用声明式白名单 + * 纪律:类型用字符串 key(14 类命名一次冻结,一期放行 11 类);校验只用声明式白名单 * required/pattern/min/max/maxLength;严禁存库 JS + eval(旧 CustomForm 反面先例)。 */ public final class FormValidator { @@ -27,9 +27,10 @@ public final class FormValidator { "text", "textarea", "number", "date", "select", "radio", "upload", "checkbox", "multiselect", "phone", "address", "image", "user", "users")); - /** 一期启用 7 类 */ + /** 一期启用 11 类(设计器迁移后放行 checkbox/multiselect/phone/image;address/user/users 二期) */ public static final Set PHASE1_TYPES = new HashSet<>(Arrays.asList( - "text", "textarea", "number", "date", "select", "radio", "upload")); + "text", "textarea", "number", "date", "select", "radio", "upload", + "checkbox", "multiselect", "phone", "image")); /** 声明式校验白名单属性(其余属性一律拒绝,防契约外语义渗入) */ private static final Set ALLOWED_PROPS = new HashSet<>(Arrays.asList( @@ -113,10 +114,12 @@ public final class FormValidator { throw new BizException(ErrorCode.PARAM_ERROR, "字段 pattern 不可编译(字段 " + key + ")"); } } - if ("select".equals(type) || "radio".equals(type)) { + if ("select".equals(type) || "radio".equals(type) + || "checkbox".equals(type) || "multiselect".equals(type)) { JsonNode options = field.get("options"); if (options == null || !options.isArray() || options.size() == 0) { - throw new BizException(ErrorCode.PARAM_ERROR, "select/radio 必须提供 options(字段 " + key + ")"); + throw new BizException(ErrorCode.PARAM_ERROR, + "select/radio/checkbox/multiselect 必须提供 options(字段 " + key + ")"); } } Integer min = intVal(field, "min"); @@ -210,16 +213,39 @@ public final class FormValidator { } break; } - case "upload": { + case "upload": + case "image": { if (!value.isArray()) { - throw fail(field, key, "必须是附件数组"); + throw fail(field, key, "必须是附件/图片数组"); } + // maxFiles 缺省或 ≤0 = 不限(与设计器属性面板、渲染器的「留空/0 不限」同口径) Integer maxFiles = intVal(field, "maxFiles"); - if (maxFiles != null && value.size() > maxFiles) { + if (maxFiles != null && maxFiles > 0 && value.size() > maxFiles) { throw fail(field, key, "附件数超过上限 " + maxFiles); } break; } + case "checkbox": + case "multiselect": { + if (!value.isArray()) { + throw fail(field, key, "必须是多选数组"); + } + JsonNode options = field.get("options"); + boolean hit; + for (JsonNode v : value) { + hit = false; + for (JsonNode option : options) { + if (option.asText().equals(v.asText())) { + hit = true; + break; + } + } + if (!hit) { + throw fail(field, key, "选项值不在候选列表内"); + } + } + break; + } default: { String v = value.asText(); Integer maxLength = intVal(field, "maxLength"); diff --git a/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java b/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java index 9de6480..f5afb28 100644 --- a/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java +++ b/src/test/java/com/yunhai/svchall/service/impl/ServiceFormServiceTest.java @@ -1,7 +1,9 @@ package com.yunhai.svchall.service.impl; +import com.yunhai.platform.apistd.BizException; import com.yunhai.svchall.entity.ServiceForm; import com.yunhai.svchall.entity.ServiceItem; +import com.yunhai.svchall.model.svc.SvcModels.SvcFormSaveRequest; import com.yunhai.svchall.model.svc.SvcModels.SvcFormVo; import com.yunhai.svchall.repository.ServiceFormRepository; import com.yunhai.svchall.repository.ServiceItemRepository; @@ -9,19 +11,24 @@ import com.yunhai.svchall.service.SiteDirectory; import com.yunhai.svchall.service.SiteGuard; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.Arrays; import java.util.List; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** - * 表单列表使用场景可见性单测:list() 必须带回"被哪些事项绑定"的明细(refItems), + * 表单列表使用场景可见性 + 保存升版契约单测:list() 必须带回"被哪些事项绑定"的明细(refItems), * 管理员才能从表单页反查使用位置(一期可见闭环);表单 sch 级共享,跨站点绑定如实展示。 + * save() 侧冻结「行不可变」:已发布/停用版本只接受"不传 id"的重提(按 code 升版),带 id 一律拒。 */ class ServiceFormServiceTest { @@ -130,4 +137,71 @@ class ServiceFormServiceTest { assertEquals(0, list.get(1).getRefItems().size(), "seal 表单不应带上 leave 的事项明细"); assertEquals("seal", list.get(1).getCode()); } + + // ===== save():行不可变契约下「编辑已发布表单=不传 id 重提,后端按 code 升版」 ===== + + private SvcFormSaveRequest req(Integer id) { + SvcFormSaveRequest r = new SvcFormSaveRequest(); + r.setId(id); + r.setCode("leave"); + r.setName("请假申请单"); + r.setSchemaJson("{\"groups\":[{\"title\":\"基本信息\",\"fields\":[" + + "{\"key\":\"reason\",\"label\":\"事由\",\"type\":\"textarea\",\"required\":true}]}]}"); + return r; + } + + @Test + void savingPublishedVersionWithIdIsRejected() { + // 设计器必须自行「去掉 id」——带已发布版本的 id 提交一律拒(P1 回归:曾导致编辑主路径整体不可用) + when(formRepository.findByIdAndSchId(9, 1L)).thenReturn(Optional.of(form("leave", 1, 1))); + assertThrows(BizException.class, () -> service.save(null, "S1", req(9)), + "已发布版本不可就地改"); + } + + @Test + void savingWithoutIdOnPublishedCodeClonesNewDraftVersion() { + ServiceForm published = form("leave", 1, 1); + published.setId(9); + when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "leave")) + .thenReturn(Arrays.asList(published)); + when(formRepository.save(org.mockito.ArgumentMatchers.any(ServiceForm.class))) + .thenAnswer(inv -> { + ServiceForm f = inv.getArgument(0); + f.setId(10); + return f; + }); + + assertEquals(Integer.valueOf(10), service.save(null, "S1", req(null))); + + ArgumentCaptor saved = ArgumentCaptor.forClass(ServiceForm.class); + verify(formRepository).save(saved.capture()); + assertEquals(Integer.valueOf(2), saved.getValue().getVersion(), "已发布版本重提应升一版"); + assertEquals(Integer.valueOf(0), saved.getValue().getStatus(), "升版产物必须是草稿"); + assertEquals("leave", saved.getValue().getCode()); + } + + @Test + void savingWithoutIdOnExistingDraftUpdatesItInPlace() { + ServiceForm draft = form("leave", 2, 0); + draft.setId(10); + when(formRepository.findBySchIdAndCodeOrderByVersionDesc(1L, "leave")) + .thenReturn(Arrays.asList(draft)); + when(formRepository.save(draft)).thenReturn(draft); + + assertEquals(Integer.valueOf(10), service.save(null, "S1", req(null))); + assertEquals(Integer.valueOf(2), draft.getVersion(), "已有草稿不再升版,就地更新"); + assertEquals("请假申请单", draft.getName()); + } + + @Test + void savingDraftWithIdUpdatesItInPlace() { + // 设计器装载到草稿版本(status=0)时应带 id:就地更新,不产生多余版本行 + ServiceForm draft = form("leave", 2, 0); + draft.setId(10); + when(formRepository.findByIdAndSchId(10, 1L)).thenReturn(Optional.of(draft)); + when(formRepository.save(draft)).thenReturn(draft); + + assertEquals(Integer.valueOf(10), service.save(null, "S1", req(10))); + verify(formRepository).save(draft); + } } diff --git a/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java b/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java index 6ff1f3b..9ba01c2 100644 --- a/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java +++ b/src/test/java/com/yunhai/svchall/utils/FormValidatorTest.java @@ -9,7 +9,7 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * 表单 schema 契约单测(V2.0 Part B.3:14 类命名冻结/一期 7 类/声明式校验白名单/提交强校验)。 + * 表单 schema 契约单测(V2.0 Part B.3:14 类命名冻结/一期 11 类/声明式校验白名单/提交强校验)。 */ @DisplayName("FormValidator - 表单 schema 契约") class FormValidatorTest { @@ -23,6 +23,18 @@ class FormValidatorTest { + "{\"key\":\"joinDate\",\"label\":\"日期\",\"type\":\"date\"}" + "]}]}"; + /** 设计器迁移后新放行的 4 类 + 多分组(checkbox/multiselect/phone/image) */ + private static final String NEW_TYPES_SCHEMA = "{\"groups\":[" + + "{\"title\":\"基本信息\",\"fields\":[" + + "{\"key\":\"mobile\",\"label\":\"手机号\",\"type\":\"phone\",\"required\":true," + + "\"pattern\":\"^1[3-9]\\\\d{9}$\",\"maxLength\":11}," + + "{\"key\":\"hobby\",\"label\":\"爱好\",\"type\":\"checkbox\",\"options\":[\"读书\",\"运动\"]}," + + "{\"key\":\"courses\",\"label\":\"课程\",\"type\":\"multiselect\",\"options\":[\"数学\",\"语文\"]}]}," + + "{\"title\":\"附件\",\"fields\":[" + + "{\"key\":\"files\",\"label\":\"证明材料\",\"type\":\"upload\",\"accept\":\".pdf\",\"maxSize\":20,\"maxFiles\":2}," + + "{\"key\":\"photo\",\"label\":\"证件照\",\"type\":\"image\",\"accept\":\".jpg,.png\",\"maxSize\":5,\"maxFiles\":1}" + + "]}]}"; + @Test @DisplayName("合法 schema 通过校验并返回字段数") void validSchemaPasses() { @@ -30,7 +42,7 @@ class FormValidatorTest { } @Test - @DisplayName("一期只放行 7 类控件:user 为二期占位 → 拒绝") + @DisplayName("二期类型(user/address/users)仍拒绝") void phase2TypeRejected() { String schema = "{\"groups\":[{\"fields\":[" + "{\"key\":\"u\",\"label\":\"用户\",\"type\":\"user\"}]}]}"; @@ -39,6 +51,104 @@ class FormValidatorTest { .hasMessageContaining("二期开放"); } + @Test + @DisplayName("设计器新放行的 4 类(checkbox/multiselect/phone/image)通过;多分组按全表单计数字段") + void newTypesAndMultiGroupPass() { + assertThat(FormValidator.validateSchema(NEW_TYPES_SCHEMA)).isEqualTo(5); + } + + @Test + @DisplayName("checkbox/multiselect 未配 options 拒绝(与 select/radio 同口径)") + void optionRequiredForMultiChoice() { + for (String type : new String[]{"checkbox", "multiselect"}) { + String noOptions = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"h\",\"label\":\"爱好\",\"type\":\"" + type + "\"}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(noOptions)) + .isInstanceOf(BizException.class) + .hasMessageContaining("options"); + + String emptyOptions = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"h\",\"label\":\"爱好\",\"type\":\"" + type + "\",\"options\":[]}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(emptyOptions)) + .isInstanceOf(BizException.class) + .hasMessageContaining("options"); + } + } + + @Test + @DisplayName("提交校验:checkbox/multiselect 必须是数组且每个值都在 options 内") + void submitMultiChoiceValidation() { + FormValidator.validateSchema(NEW_TYPES_SCHEMA); + String base = "\"mobile\":\"13800138000\""; + + // 非数组 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{" + base + ",\"hobby\":\"读书\"}", NEW_TYPES_SCHEMA)) + .hasMessageContaining("必须是多选数组"); + // 候选外 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{" + base + ",\"courses\":[\"数学\",\"体育\"]}", NEW_TYPES_SCHEMA)) + .hasMessageContaining("候选列表"); + // 合法多选(含单元素数组与空对象提交的必填口径) + assertThatCode(() -> FormValidator.validateSubmit( + "{" + base + ",\"hobby\":[\"读书\",\"运动\"],\"courses\":[\"数学\"]}", NEW_TYPES_SCHEMA)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("提交校验:phone 走文本分支(pattern 与 maxLength 都生效)") + void submitPhoneValidation() { + FormValidator.validateSchema(NEW_TYPES_SCHEMA); + // 位数够但首位非 1 → pattern 不匹配 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{\"mobile\":\"23800138000\",\"hobby\":[\"读书\"]}", NEW_TYPES_SCHEMA)) + .hasMessageContaining("格式不正确"); + // 超长 → maxLength 先行拦截 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{\"mobile\":\"13800138000123\",\"hobby\":[\"读书\"]}", NEW_TYPES_SCHEMA)) + .hasMessageContaining("长度超过上限"); + assertThatCode(() -> FormValidator.validateSubmit( + "{\"mobile\":\"13900139000\",\"hobby\":[\"读书\"]}", NEW_TYPES_SCHEMA)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("提交校验:image 与 upload 同通道(数组 + maxFiles 上限;缺省/0=不限)") + void submitImageValidation() { + FormValidator.validateSchema(NEW_TYPES_SCHEMA); + String mobile = "\"mobile\":\"13800138000\""; + + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{" + mobile + ",\"photo\":\"http://x/1.jpg\"}", NEW_TYPES_SCHEMA)) + .hasMessageContaining("必须是附件/图片数组"); + // maxFiles=1 超限 + assertThatThrownBy(() -> FormValidator.validateSubmit( + "{" + mobile + ",\"photo\":[{\"id\":1},{\"id\":2}]}", NEW_TYPES_SCHEMA)) + .hasMessageContaining("附件数超过上限"); + // 1 张通过 + assertThatCode(() -> FormValidator.validateSubmit( + "{" + mobile + ",\"photo\":[{\"id\":1}]}", NEW_TYPES_SCHEMA)) + .doesNotThrowAnyException(); + + // maxFiles 缺省 = 不限;显式 0 与缺省同口径(设计器「留空/0 不限」) + String noLimit = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"p\",\"label\":\"图\",\"type\":\"image\",\"maxFiles\":0}]}]}"; + FormValidator.validateSchema(noLimit); + assertThatCode(() -> FormValidator.validateSubmit( + "{\"p\":[{\"id\":1},{\"id\":2},{\"id\":3}]}", noLimit)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("number 的 precision 不在白名单 → 拒绝(设计器不产出该属性)") + void precisionPropRejected() { + String schema = "{\"groups\":[{\"fields\":[" + + "{\"key\":\"amount\",\"label\":\"金额\",\"type\":\"number\",\"precision\":2}]}]}"; + assertThatThrownBy(() -> FormValidator.validateSchema(schema)) + .isInstanceOf(BizException.class) + .hasMessageContaining("precision"); + } + @Test @DisplayName("未冻结类型拒绝(14 类之外的字符串 key)") void unfrozenTypeRejected() {