feat: 表单一期放行扩到 11 类(+checkbox/multiselect/phone/image)+ 契约审计修复
build / build-push (push) Successful in 2m9s

- 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)。
This commit is contained in:
chenxing
2026-09-16 20:22:02 +08:00
parent 9a47351725
commit 47beb4eaa4
4 changed files with 223 additions and 13 deletions
@@ -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;
@@ -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<String> 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<String> 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");