智能表单生成器:结构化输出契约、JSON Schema 驱动渲染与 EAV/JSONB 混合存储的 Vue3 + Spring AI 生产级实战
注:
博客:
一、项目架构设计
1.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ 前端 (Vue3 + FormKit) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 表单设计 │ │ AI 生成 │ │ 表单渲染 │ │ 数据分析 │ │
│ │ (DSL编辑) │ │ (Schema) │ │ (JSON) │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
│ HTTP
┌─────────────────────────────────────────────────────────────┐
│ 后端 (Spring Boot 3.x + Spring AI 1.1) │
│ ┌──────────────────────────────────────────────────────────┐│
│ │ 表单契约层 (Form Contract) ││
│ │ FormDefinition DSL → JSON Schema → 前后端共用校验 ││
│ └──────────────────────────────────────────────────────────┘│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ 表单服务 │ │ AI 服务 │ │ 数据服务 │ │ 导出服务 │ │
│ │ │ │ .entity()│ │ EAV+JSONB│ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ MySQL(表单定义/字段元数据/EAV 值/JSONB 快照) │
│ Redis(Schema 缓存) │
└─────────────────────────────────────────────────────────────┘
1.2 核心功能
- AI 结构化生成表单 :
ChatClient.entity()+validateSchema()自纠正,输出符合FormDefinition契约的字段列表,而非裸 JSON 字符串 - JSON Schema 驱动渲染 :前端基于 FormKit 的
$formkit模式数组渲染表单,设计器产出可序列化的 DSL,而非直接操作 DOM 组件树 - 统一校验契约 :后端
networknt/json-schema-validator基于同一份 JSON Schema 做服务端校验,前端 FormKit 做即时校验,规则不重复维护 - EAV + JSONB 混合存储:字段元数据用 EAV 保证可查询、可索引;每次提交的完整答案用 JSONB 快照保证读取简单
- 提交数据防御性处理:服务端强制过滤未知字段、校验必填与类型、JSONB 幂等写入
二、后端实现
2.1 依赖配置
xml
<properties>
<java.version>17</java.version>
<spring-ai.version>1.1.4</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- JSON Schema 校验:服务端动态校验的核心 -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.6</version>
</dependency>
<!-- JSONB 列映射(Hibernate 6 原生支持 JSON 类型) -->
<dependency>
<groupId>io.hypersistence</groupId>
<artifactId>hypersistence-utils-hibernate-63</artifactId>
<version>3.9.0</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
networknt/json-schema-validator 是 Java 生态中性能表现突出且完整支持 Draft 2020-12 的实现,支持 format 断言、自定义关键字和运行时 schema 刷新,适合作为动态表单的服务端校验引擎。
2.2 FormDefinition 契约与实体设计
FormDefinition 是系统的唯一事实来源 。它用 Java record 定义,ChatClient.entity() 会基于它生成 JSON Schema,设计器保存时序列化为 JSON 存入数据库,渲染器读取同一份 JSON 渲染表单。
java
public record FormDefinition(
String title,
String description,
List<FieldDefinition> fields,
String submitAction // STAY / REDIRECT / MESSAGE
) {}
public record FieldDefinition(
@JsonPropertyDescription("字段唯一标识,snake_case,同一表单内不可重复")
String name,
@JsonPropertyDescription("字段显示标签")
String label,
@JsonPropertyDescription("字段类型:TEXT/TEXTAREA/NUMBER/EMAIL/SELECT/RADIO/CHECKBOX/DATE/FILE/RATING/SWITCH")
FieldType type,
@JsonPropertyDescription("是否必填")
boolean required,
@JsonPropertyDescription("占位提示文本")
String placeholder,
@JsonPropertyDescription("选项列表,仅 SELECT/RADIO/CHECKBOX 使用")
List<String> options,
@JsonPropertyDescription("校验规则,如 min/max/pattern/maxLength")
ValidationRule validation
) {}
public record ValidationRule(
Integer min,
Integer max,
Integer maxLength,
String pattern
) {}
@JsonPropertyDescription 的注解文本会被 BeanOutputConverter 生成到 JSON Schema 的 description 字段中,模型在生成时能看到每个字段的语义约束。这比在 Prompt 里写一大段"请确保 name 是 snake_case"可靠得多------约束直接附着在 schema 上,而不是游离在自然语言里。
字段的业务元数据 仍然需要持久化,但不再与 AI 生成路径耦合。FormFieldMeta 只负责"这个表单有哪些字段、顺序如何、属于哪个租户",字段的具体定义以 FormDefinition JSON 为准:
java
@Data
@Entity
@Table(name = "form_definition")
public class FormDefinitionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 200)
private String title;
/** 完整的 FormDefinition JSON,唯一契约 */
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "json")
private FormDefinition definition;
@Column(nullable = false)
private Long creatorId;
@Enumerated(EnumType.STRING)
private FormStatus status = FormStatus.DRAFT;
private Integer responseCount = 0;
@CreationTimestamp
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public enum FormStatus { DRAFT, PUBLISHED, CLOSED }
}
类比讲解:FormDefinition 是整个系统的"宪法"。AI 是立法者(按宪法生成条款),设计器是修宪者(在宪法框架内调整),渲染器是执法者(按宪法执行),校验器是最高法院(按宪法裁决)。宪法只有一份,所有人引用同一个版本,不存在"AI 理解的字段类型"和"前端支持的字段类型"两张皮。
2.3 AI 生成:结构化输出 + Schema 自纠正
java
@Slf4j
@Service
@RequiredArgsConstructor
public class AiFormService {
private final ChatClient formChatClient;
public FormDefinition generate(String requirement, String titleHint) {
String prompt = """
根据以下业务需求生成一份表单定义:
需求:%s
标题提示:%s
约束:
1. 字段数量 5-10 个
2. 必填字段不超过 3 个
3. SELECT/RADIO/CHECKBOX 必须提供至少 2 个选项
4. 字段 name 使用 snake_case,同一表单内不可重复
5. RATING 字段的 validation.min/max 限定评分范围
""".formatted(requirement,
titleHint != null ? titleHint : "未命名表单");
return formChatClient.prompt()
.user(prompt)
.call()
.entity(FormDefinition.class, spec -> spec.validateSchema());
}
}
validateSchema() 的实际行为 :Spring AI 拿到模型响应后,用目标类型的 JSON Schema 做校验。如果失败(例如 required 被写成字符串、type 枚举值不合法、fields 不是数组),校验错误会被追加到 prompt 并重新调用模型,默认最多 3 次,每次重试都带着具体错误信息,而不是盲目重试。这比原文的 try-catch 兜底硬编码默认表单有本质区别:原文的失败是静默降级,这里是带反馈的纠错。
一个必须知道的限制 :validateSchema() 激活时不支持流式输出,因为校验需要完整响应。对于表单生成这种"一次性产出完整结构"的场景,这个限制没有影响;但如果未来要支持"边生成边预览字段",需要拆成非流式生成 + 前端逐字段渐入。
2.4 前端渲染:FormKit Schema
FormKit 提供了更干净的分离:设计器产出可序列化的 JSON Schema(或 DSL),<FormKitSchema> 组件直接消费 schema 渲染表单。FormKit 的 schema 支持 $formkit 简写、条件渲染、表达式和动态数据,且保证序列化为字符串,天然适合存储和传输。
前端的设计器不再直接操作 Vue 组件实例,而是维护一个 FormDefinition JSON:
vue
<template>
<div class="form-designer">
<div class="designer-layout">
<div class="component-panel">
<div v-for="t in fieldTypes" :key="t.type"
class="component-item"
draggable="true"
@dragstart="onDragStart($event, t)">
{{ t.label }}
</div>
</div>
<div class="canvas"
@dragover.prevent
@drop="onDrop">
<draggable v-model="definition.fields" item-key="name">
<template #item="{ element, index }">
<div class="field-block" @click="selected = index">
<span class="field-label">{{ element.label || element.name }}</span>
<el-tag size="small">{{ element.type }}</el-tag>
</div>
</template>
</draggable>
</div>
<div class="property-panel" v-if="selected !== null">
<el-form label-width="70px">
<el-form-item label="标签">
<el-input v-model="definition.fields[selected].label" />
</el-form-item>
<el-form-item label="字段名">
<el-input v-model="definition.fields[selected].name" />
</el-form-item>
<el-form-item label="必填">
<el-switch v-model="definition.fields[selected].required" />
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
渲染时,FormDefinition 被转换为 FormKit schema 数组:
ts
import type { FormDefinition, FieldDefinition } from '@/types/form'
const toFormKitSchema = (def: FormDefinition) => {
return def.fields.map((f: FieldDefinition) => ({
$formkit: mapToFormKitType(f.type),
name: f.name,
label: f.label,
placeholder: f.placeholder,
options: f.options,
validation: f.required ? 'required' : undefined,
...(f.validation?.maxLength ? { 'validation:max': f.validation.maxLength } : {})
}))
}
const mapToFormKitType = (type: string) => {
const map: Record<string, string> = {
TEXT: 'text', TEXTAREA: 'textarea', NUMBER: 'number',
EMAIL: 'email', SELECT: 'select', RADIO: 'radio',
CHECKBOX: 'checkbox', DATE: 'date', RATING: 'rating',
SWITCH: 'toggle'
}
return map[type] || 'text'
}
设计器与渲染器的解耦 :设计器只负责编辑 FormDefinition JSON,渲染器只负责把 JSON 转成 FormKit schema。两者不共享 Vue 组件状态,设计器的升级不会破坏已有表单的渲染,渲染器的更换也不会影响设计器的数据结构。
2.5 存储方案:EAV 元数据 + JSONB 答案快照
动态表单的存储有两条主流路线,生产级方案需要理解它们的差异并做出有意识的取舍。
EAV 方案 :属性定义表存字段元数据,属性值表按"实体 ID + 属性 ID + 值"逐行存储。优点是灵活性极高,加字段零发版;缺点是查询是噩梦------查"信用等级为 A 的客户"需要自关联属性表,复杂查询的性能和可读性都堪忧,BI 工具也无法直接消费 EAV 结构。
JSON 列方案:业务表保留一个 JSON 列,自定义字段以键值对存入。MySQL 5.7+ 原生支持 JSON 类型和 JSON 路径索引,查询能力比 EAV 强得多,工程成本也更低。JSONB 的主要风险是"校验 bug",而 EAV 的主要风险是"产品慢到不可用"------两者不是同等风险。
生产级选择是混合模式 :字段定义和"哪些字段可查"用 EAV 风格的元数据表管理,每次提交的完整答案用 JSONB 快照存储。查询时,高频过滤字段通过元数据表上的索引列(冗余的 searchable_values)支持,低频字段和完整答案直接读 JSONB。
java
@Data
@Entity
@Table(name = "form_response")
public class FormResponse {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long formId;
private Long respondentId;
/** 完整答案快照,JSONB 存储 */
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "json")
private Map<String, Object> answers;
/** 用于高频查询的冗余索引列(EAV 思想) */
@ElementCollection
@CollectionTable(name = "form_response_index",
joinColumns = @JoinColumn(name = "response_id"))
@MapKeyColumn(name = "field_name")
@Column(name = "field_value", length = 500)
private Map<String, String> searchableValues;
@Column(length = 50)
private String ipAddress;
@CreationTimestamp
private LocalDateTime submittedAt;
}
类比讲解 :EAV 像把一本书拆成一张张索引卡片 ,每张卡片记录"第几页、第几行、写了什么"。查询某一行很快,但想读完整的一章要把卡片全部拼起来。JSONB 像整本书按原样扫描成 PDF ,读整章很快,但想在 PDF 里按关键词定位就需要额外的全文索引。混合方案是PDF 存正文 + 索引卡片管检索:高频查询走卡片,完整读取走 PDF。
JSONB 列的一个重要工程细节 :JSON 存储的每个键名会在每行中重复,大表上的存储开销不可忽视。字段名应保持简短(email 而非 user_primary_email_address),并且在 MySQL 中对 JSONB 列建立生成列 + 索引来支持高频路径查询:
sql
ALTER TABLE form_response
ADD COLUMN submit_date DATE
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(answers, '$.submit_date'))) STORED,
ADD INDEX idx_submit_date (submit_date);
2.6 提交校验:前后端同一份 Schema
动态表单的提交数据不能信任前端。后端必须在写入前做一次完整校验,且校验规则必须与前端一致------这正是 JSON Schema 作为契约的价值。
java
@Slf4j
@Service
@RequiredArgsConstructor
public class FormValidationService {
private final JsonSchemaFactory schemaFactory =
JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);
private final ConcurrentMap<Long, JsonSchema> schemaCache = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper;
/**
* 从 FormDefinition 生成 JSON Schema,缓存
*/
public JsonSchema schemaFor(FormDefinitionEntity entity) {
return schemaCache.computeIfAbsent(entity.getId(), id -> {
try {
String schemaJson = toJsonSchema(entity.getDefinition());
return schemaFactory.getSchema(schemaJson);
} catch (Exception e) {
throw new IllegalStateException("生成表单 Schema 失败", e);
}
});
}
public ValidationResult validate(Long formId, Map<String, Object> answers) {
FormDefinitionEntity entity = formRepository.findById(formId).orElseThrow();
JsonSchema schema = schemaFor(entity);
Set<ValidationMessage> errors = schema.validate(
objectMapper.valueToTree(answers));
if (!errors.isEmpty()) {
return ValidationResult.fail(errors.stream()
.map(m -> m.getMessage())
.toList());
}
return ValidationResult.pass();
}
private String toJsonSchema(FormDefinition def) throws Exception {
ObjectNode root = objectMapper.createObjectNode();
root.put("$schema", "https://json-schema.org/draft/2020-12/schema");
root.put("type", "object");
ObjectNode properties = root.putObject("properties");
ArrayNode required = root.putArray("required");
for (FieldDefinition field : def.fields()) {
ObjectNode prop = properties.putObject(field.name());
prop.put("type", jsonType(field.type()));
if (field.label() != null) prop.put("description", field.label());
if (field.validation() != null) {
ValidationRule v = field.validation();
if (v.maxLength() != null) prop.put("maxLength", v.maxLength());
if (v.min() != null) prop.put("minimum", v.min());
if (v.max() != null) prop.put("maximum", v.max());
if (v.pattern() != null) prop.put("pattern", v.pattern());
}
if (field.required()) {
required.add(field.name());
}
}
root.put("additionalProperties", false); // 拒绝未知字段
return objectMapper.writeValueAsString(root);
}
private String jsonType(FieldType type) {
return switch (type) {
case NUMBER, RATING -> "number";
case SWITCH -> "boolean";
case CHECKBOX -> "array";
default -> "string";
};
}
public record ValidationResult(boolean valid, List<String> errors) {
static ValidationResult pass() { return new ValidationResult(true, List.of()); }
static ValidationResult fail(List<String> errors) {
return new ValidationResult(false, errors);
}
}
}
additionalProperties: false 是一个关键的安全设置:前端如果提交了表单定义之外的字段(无论是误传还是恶意注入),校验会直接失败,而不是把脏数据静默写入 JSONB。
2.7 提交服务:防御性写入
java
@Transactional
public FormResponse submit(Long formId, Long respondentId,
Map<String, Object> rawAnswers,
String ipAddress) {
FormDefinitionEntity form = formRepository.findById(formId)
.orElseThrow(() -> new RuntimeException("表单不存在"));
if (form.getStatus() != FormStatus.PUBLISHED) {
throw new RuntimeException("表单未发布");
}
// 1. 白名单过滤:只保留定义中存在的字段
Set<String> allowedNames = form.getDefinition().fields().stream()
.map(FieldDefinition::name)
.collect(Collectors.toSet());
Map<String, Object> answers = rawAnswers.entrySet().stream()
.filter(e -> allowedNames.contains(e.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// 2. JSON Schema 校验
ValidationResult result = validationService.validate(formId, answers);
if (!result.valid()) {
throw new FormValidationException(result.errors());
}
// 3. 构建索引值(仅对高频可查字段)
Map<String, String> searchable = new HashMap<>();
for (FieldDefinition field : form.getDefinition().fields()) {
if (isSearchable(field)) {
Object value = answers.get(field.name());
if (value != null) {
searchable.put(field.name(), String.valueOf(value));
}
}
}
// 4. 写入
FormResponse response = new FormResponse();
response.setFormId(formId);
response.setRespondentId(respondentId);
response.setAnswers(answers);
response.setSearchableValues(searchable);
response.setIpAddress(ipAddress);
form.setResponseCount(form.getResponseCount() + 1);
formRepository.save(form);
return responseRepository.save(response);
}
private boolean isSearchable(FieldDefinition field) {
return field.type() == FieldType.SELECT
|| field.type() == FieldType.RADIO
|| field.type() == FieldType.DATE;
}
关键设计 :searchableValues 只索引枚举型和日期型字段,不对自由文本做索引。自由文本的索引会快速膨胀且查询价值低,而枚举字段(如"状态""等级")和日期字段是最常见的过滤条件。
2.8 REST 控制器
java
@RestController
@RequestMapping("/api/form")
@RequiredArgsConstructor
public class FormController {
private final AiFormService aiFormService;
private final FormDefinitionRepository formRepository;
private final FormSubmissionService submissionService;
@PostMapping("/ai/generate")
public FormDefinition generate(@RequestBody GenerateRequest req) {
return aiFormService.generate(req.requirement(), req.title());
}
@PostMapping
public FormDefinitionEntity save(@RequestBody SaveFormRequest req) {
FormDefinitionEntity entity = new FormDefinitionEntity();
entity.setTitle(req.definition().title());
entity.setDefinition(req.definition());
entity.setCreatorId(req.creatorId());
return formRepository.save(entity);
}
@PostMapping("/{id}/publish")
public FormDefinitionEntity publish(@PathVariable Long id) {
FormDefinitionEntity entity = formRepository.findById(id).orElseThrow();
entity.setStatus(FormDefinitionEntity.FormStatus.PUBLISHED);
return formRepository.save(entity);
}
@PostMapping("/{id}/submit")
public FormResponse submit(@PathVariable Long id,
@RequestBody SubmitRequest req) {
return submissionService.submit(id, req.respondentId(),
req.answers(), req.ipAddress());
}
public record GenerateRequest(String requirement, String title) {}
public record SaveFormRequest(FormDefinition definition, Long creatorId) {}
public record SubmitRequest(Long respondentId,
Map<String, Object> answers,
String ipAddress) {}
}
三、前端实现
3.1 表单填写渲染器
渲染器直接消费 FormDefinition,不依赖设计器的 Vue 组件状态:
vue
<template>
<FormKit type="form" :actions="false" @submit="onSubmit">
<FormKitSchema :schema="schema" :data="formData" />
</FormKit>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { FormKitSchema } from '@formkit/vue'
import type { FormDefinition } from '@/types/form'
const props = defineProps<{ definition: FormDefinition; formId: number }>()
const emit = defineEmits<{ (e: 'submitted', response: any): void }>()
const formData = ref<Record<string, any>>({})
const schema = computed(() => props.definition.fields.map(f => ({
$formkit: mapToFormKitType(f.type),
name: f.name,
label: f.label,
placeholder: f.placeholder,
options: f.options,
validation: f.required ? 'required' : undefined,
...(f.validation?.maxLength
? { 'validation:length': `[0,${f.validation.maxLength}]` } : {})
})))
const onSubmit = async () => {
const res = await fetch(`/api/form/${props.formId}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers: formData.value })
})
if (!res.ok) {
const err = await res.json()
ElMessage.error(err.errors?.join('; ') || '提交失败')
return
}
emit('submitted', await res.json())
}
const mapToFormKitType = (type: string) => {
const map: Record<string, string> = {
TEXT: 'text', TEXTAREA: 'textarea', NUMBER: 'number',
EMAIL: 'email', SELECT: 'select', RADIO: 'radio',
CHECKBOX: 'checkbox', DATE: 'date', RATING: 'rating',
SWITCH: 'toggle'
}
return map[type] || 'text'
}
</script>
3.2 AI 生成的交互
AI 生成调用的后端接口返回的是 FormDefinition 对象(entity() 已经完成了反序列化),前端直接将其载入设计器画布,不需要再做 JSON 解析:
ts
const generateWithAi = async () => {
aiLoading.value = true
try {
const { data } = await formApi.generate({
requirement: aiRequirement.value,
title: aiTitle.value
})
definition.value = data // 直接是 FormDefinition 结构
aiDialogVisible.value = false
ElMessage.success('生成完成,可在画布中调整')
} catch (e: any) {
ElMessage.error(e.response?.data?.message || '生成失败,请调整需求描述')
} finally {
aiLoading.value = false
}
}
如果后端的 validateSchema() 在 3 次重试后仍然失败,会抛出异常,前端展示的是明确的"生成失败"提示,而不是原文那种"看似成功、实则默认表单"的静默降级。
四、生产级扩展
4.1 设计器产物的版本兼容
FormDefinition 会随业务演进增加字段类型或校验规则。设计器保存时应写入 schemaVersion,渲染器按版本兼容处理:
java
public record FormDefinition(
String schemaVersion, // 如 "1.0"
String title,
String description,
List<FieldDefinition> fields,
String submitAction
) {}
当渲染器遇到不认识的字段类型时,降级为只读文本展示并记录告警,而不是渲染崩溃。旧版本表单继续用旧版本渲染器服务,新表单用新渲染器,避免"升级一次、所有历史表单全部不可用"。
4.2 Schema 缓存与热更新
JSON Schema 的编译有一定开销,不应在每次提交时重新生成。FormValidationService 用 ConcurrentHashMap 缓存已编译的 JsonSchema,但缓存必须能在表单定义更新时失效。表单发布时清除对应 formId 的缓存条目即可:
java
public void evict(Long formId) {
schemaCache.remove(formId);
}
4.3 可观测性
| 指标 | 采集方式 | 告警阈值 |
|---|---|---|
form.ai.generate.retry.count |
validateSchema() 重试次数 |
平均重试 > 1.5 说明 Prompt 约束不足 |
form.ai.generate.fail.rate |
3 次重试后仍失败的比例 | > 5% 需检查模型或 Prompt |
form.validation.fail.rate |
服务端 Schema 校验失败率 | 突增说明前后端契约不一致 |
form.submit.unknown_field.count |
被 additionalProperties:false 拦截的未知字段数 |
> 0 需排查前端版本 |
4.4 安全边界
- AI 生成的字段名不可直接作为数据库列名或 JSON 路径拼接 。
name必须在服务端用正则^[a-z][a-z0-9_]{0,49}$二次校验,防止注入。 - 提交频率限制:同一 IP 或用户对同一表单的提交频率应有限制,防止刷单或垃圾数据。
- 文件字段的隔离 :如果表单包含
FILE类型,文件必须存储在独立的对象存储中,JSONB 只保存文件引用,不保存文件内容。
五、总结
本文围绕"表单契约"这一核心,构建了一个生产级的智能表单生成器:
| 能力 | 实现方案 | 关键收益 |
|---|---|---|
| AI 生成 | .entity(FormDefinition.class, spec -> spec.validateSchema()) |
结构化输出 + 自纠正重试,消除手工 JSON 解析的脆弱性 |
| 前后端契约 | 单一 FormDefinition DSL,设计器/渲染器/AI/校验器共用 | 不存在"AI 生成的结构前端不认识" |
| 渲染 | FormKit $formkit schema 数组 |
设计器与渲染器解耦,schema 可序列化、可存储 |
| 存储 | EAV 元数据 + JSONB 快照混合 | 高频查询有索引,完整读取无 JOIN |
| 校验 | networknt JSON Schema,additionalProperties:false |
前后端同一份规则,未知字段被拦截 |
| 提交防御 | 白名单过滤 + Schema 校验 + 索引值提取 | 脏数据不进库,查询性能有保障 |
核心认知 :智能表单生成器的难点不在"让 AI 吐出一段 JSON",而在让 AI、前端、后端三方对同一份契约达成一致 。entity() + validateSchema() 解决了 AI 侧的可靠性,FormDefinition DSL 解决了三方的契约统一,JSON Schema 解决了校验规则的一致性,混合存储解决了动态字段的查询性能。四者叠加,表单生成才从一个"演示效果"变成一个"可运营的系统"。