智能表单生成器:结构化输出契约、JSON Schema 驱动渲染与 EAV/JSONB 混合存储的 Vue3 + Spring AI 生产级实战

智能表单生成器:结构化输出契约、JSON Schema 驱动渲染与 EAV/JSONB 混合存储的 Vue3 + Spring AI 生产级实战

注:

博客:

https://blog.csdn.net/badao_liumang_qizhi

一、项目架构设计

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 解决了校验规则的一致性,混合存储解决了动态字段的查询性能。四者叠加,表单生成才从一个"演示效果"变成一个"可运营的系统"。

相关推荐
lpfasd1232 小时前
Mac mini M6(24GB)本地大模型部署实战:选型考量、踩坑记录与全链路搭建
人工智能
SEO_juper2 小时前
Bash 交叉验证 robots.txt 和 sitemap:两个文件自相矛盾,页面永远收录不了
开发语言·python·node.js·json·bash
云上工程笔记2 小时前
2026年如何保障Token传输过程中的数据安全与隐私?加密、脱敏、审计与权限指南
人工智能
2601_970703832 小时前
工业除湿机选型与安全使用研究报告:把铭牌数字放回工况里
大数据·人工智能
进击的横打2 小时前
【人工智能】AI将推翻了传统软件开发吗
人工智能
银河麒麟操作系统2 小时前
两案例入选全球计算联盟年度产业应用案例汇编
大数据·汇编·人工智能
狂师2 小时前
GitHub上开源真机测试工具mobile-mcp,说句话就能让AI在手机上跑用例!
人工智能·开源·测试
云飞云共享云桌面2 小时前
智能装备工厂研发部门多设计师共享一台SolidWorks服务器做设备装配建模
运维·服务器·网络·人工智能·3d·自动化·制造
skywalk81632 小时前
hive —— 多机 AI Agent 协同的任务总线:分布式,多节点同时运行,保障不会宕机
人工智能·hive·分布式