ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Tduck开源表单系统:从部署到二次开发的完整实践指南

Tduck开源表单系统:从部署到二次开发的完整实践指南 简介这是一份填鸭Tduck开源表单在线收集系统的项目源码包面向需要自建信息反馈与数据收集平台的企业开发者、产品运营及技术维护人员。系统基于B/S架构围绕新建表单、表单设置、反馈统计三大模块展开支持拖拽式表单设计、多渠道收集与多维度数据统计适合泛零售、电商、金融、调研等场景。压缩包内共244个文件以221个Java源码文件为主体辅以XML与YML配置、HTML页面、SQL初始化脚本以及Dockerfile整包仅476KB部署与二次开发成本低。内容还包含验证码缓存服务、用户项目控制器、HTML表单邮件模板等关键实现可以清楚看到表单创建、信息收集与结果统计的完整链路。目前已有251人学习下载适合希望快速搭建轻量级表单系统并深入理解其内部机制的开发者参考。1. 不是所有收集都能用问卷星Tduck 开源表单在线收集系统是什么如果你经常要给客户、内部员工或者线下活动做信息收集你会发现问卷星这类平台有个绕不过去的坎数据全在人家服务器上字段想加个业务编号要开会员接口对接更是难谈。后来我拿到一套叫 Tduck 的开源表单在线收集系统源码部署完才发现表单生成器、收集策略、数据导出、API 推送全都自己说了算。这篇文章就是拆这套填鸭收集器 zip 源码包从部署到设计表单再到接进自己的业务系统把能复现的步骤和踩过的坑一次说清楚。适合需要私有化部署表单系统的开发者和运营人员。2. 拆开 Tduck 源码Spring Boot Vue 的表单生成器内核与三层表设计2.1 技术栈与代码结构哪一层管设计器、哪一层收数据先看这套包的整体结构。后端主体是 Spring Boot MyBatis Plus数据库用 MySQL缓存和防刷逻辑放在 Redis。前端拆成两个工程一个是给填表人用的展示端一个是给管理员用的表单设计器端基于 Vue 和 Element UI 实现。选这套组合的原因很现实Java 工程师好招MyBatis 对复杂 SQL 可控Vue 生态里做拖拽组件的轮子够多改起来比从零写设计器省一半时间。源码包解开以后目录基本是这个形态tduck/ ├── tduck-admin # 后端管理模块表单CRUD、收集数据查询、导出接口 ├── tduck-common # 公共模块工具类、常量、统一返回结构 ├── tduck-extend # 扩展模块OSS存储、短信、邮件等第三方集成 ├── tduck-manager # 管理后台前端表单生成器、数据列表、系统设置 ├── tduck-front # 用户填写端渲染表单、提交数据 └── sql/ # MySQL初始化脚本后端入口在 tduck-admin所有表单配置、收集数据、导出的接口都走这一层表单生成器的拖拽页面在 tduck-manager用户打开的填写页在 tduck-front。我建议你第一次看代码时按这个边界去翻要改表单组件就从 tduck-manager 里找要改数据落库逻辑就从 tduck-admin 里找。前后端通过/api路径通信用这一点在后面的 Nginx 配置里还会用到。2.2 数据库三层设计表单定义、字段定义、收集数据怎么落库一个收集系统能不能灵活应对各种表单需求关键看表怎么设计。这套系统的表结构分三层表单主表存表单的名称、收集开关、限填策略字段表存表单里每个组件的类型和属性数据表存用户实际的提交内容。三层分开之后新增一个表单不需要改任何数据库结构这就是表单生成器能“生成”表单的底层原因。表单主表的核心字段基本是这样CREATE TABLE td_form ( id bigint NOT NULL AUTO_INCREMENT COMMENT 表单ID, code varchar(64) NOT NULL COMMENT 表单唯一编码用于生成访问链接, name varchar(128) NOT NULL COMMENT 表单名称, open_status tinyint NOT NULL DEFAULT 1 COMMENT 是否开启收集1开启 0关闭, limit_type tinyint NOT NULL DEFAULT 0 COMMENT 限填策略0不限 1每设备一次 2每IP一次 3登录用户一次, start_time datetime DEFAULT NULL COMMENT 收集开始时间, end_time datetime DEFAULT NULL COMMENT 收集结束时间, create_user_id bigint DEFAULT NULL COMMENT 创建人, create_time datetime NOT NULL COMMENT 创建时间, PRIMARY KEY (id), UNIQUE KEY uk_code (code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT表单定义表;code字段值得单独说一下。这个编码同时决定了表单的访问链接和 API 标识比如https://your-domain/f/20241001里的20241001就是它。设计成唯一键的原因是为了防止两个表单共用一个链接也在导出数据时拿它做业务关联。open_status和start_time、end_time是控制收集窗口的三件套很多人在部署后只记得开关表单忘了设置起止时间后面我会在避坑章里讲这个问题的后果。字段表把设计器里的每一个组件展开成一条记录CREATE TABLE td_form_field ( id bigint NOT NULL AUTO_INCREMENT, form_id bigint NOT NULL COMMENT 所属表单ID, field_name varchar(32) NOT NULL COMMENT 字段标识提交数据时的key, field_label varchar(128) NOT NULL COMMENT 显示在页面上的字段名称, field_type varchar(32) NOT NULL COMMENT 组件类型input/radio/checkbox/select/date等, required tinyint NOT NULL DEFAULT 0 COMMENT 是否必填1必填 0非必填, sort int NOT NULL DEFAULT 0 COMMENT 排序号, props json DEFAULT NULL COMMENT 组件的扩展配置如placeholder、校验规则、选项列表, PRIMARY KEY (id), KEY idx_form_id (form_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT表单字段表;props存 JSON 是这套设计里最关键的一步。每个组件的 placeholder、最大长度、正则校验、下拉选项全装在这个 JSON 里。我一般不建议直接修改数据库里的 props 字段因为一旦 JSON 格式不合法前端渲染直接白屏。后面我专门写了一条排查记录讲这个坑。用户提交的数据表常见做法是通用字段单独建列组件值整体存 JSONCREATE TABLE td_form_data ( id bigint NOT NULL AUTO_INCREMENT, form_id bigint NOT NULL COMMENT 表单ID, submit_data json NOT NULL COMMENT 提交的字段值格式为 {fieldName: value}, submit_user_id bigint DEFAULT NULL COMMENT 登录用户ID匿名提交为空, device_fingerprint varchar(128) DEFAULT NULL COMMENT 设备指纹用于限填判断, ip_address varchar(64) DEFAULT NULL COMMENT 提交者IP, create_time datetime NOT NULL COMMENT 提交时间, PRIMARY KEY (id), KEY idx_form_id_time (form_id, create_time), KEY idx_user_id (submit_user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT表单收集数据表;这种“JSON 为主、公共字段单独建列”的存储方式对收集系统的场景来说性价比很高表单怎么变都不动表结构同时 create_time、form_id 这些高频筛选项又有索引可用后台的“按时间段查询导出”直接命中idx_form_id_time不会全表扫描。2.3 前端设计器的工作方式组件面板到 JSON Schema 再到发布链接表单生成器的本质是把“拖拽组件 配置属性”这个动作翻译成一份结构化的 JSON再把这份 JSON 渲染成用户端页面。组件面板上每一个控件保存后都是一段可序列化的配置。比如一个手机号输入框核心 JSON 长这样{ type: input, field: phone, label: 手机号, required: true, placeholder: 请输入11位手机号, maxLength: 11, pattern: ^1[3-9]\\d{9}$, message: 手机号格式不正确 }field是提交时存入submit_data的 key服务端校验和导出表头都靠它pattern是 HTML5 表单校验的正则前端在用户端页面上直接拦截不符合格式的输入message是校验失败时的提示文案。把这几个参数理解透你做二次开发时新增一个自定义组件就能照着这个结构写。设计器保存表单时的流程是固定的组件拖到画布 → 选中组件配置属性 → 点保存 → 前端把所有组件的 JSON 按顺序拼成数组连同表单名称、收集窗口、限填策略一起提交到后端。后端只做两件事把完整配置存到td_form的 config 字段同时把每个组件展开成一条td_form_field记录。用户访问填写链接时前端读取 config 渲染整个表单提交时前端校验规则和正则都来自这段 JSON。理解了这个闭环你可以确定一件事在数据库或者代码里新增组件类型必须先定义好它的 JSON schema再在 tduck-manager 的设计器面板里注册对应的拖拽控件。只改前端或者只改后端都跑不通。3. 从 zip 到能访问的站点Docker Compose 与源码编译两条部署路线3.1 Docker Compose 拉起来跑镜像、数据卷和 Nginx 代理如果你只是想先跑起来看效果我建议直接走 Docker Compose。这套系统依赖 MySQL 和 Redis用容器编排能一次性把依赖拉齐。源码包里如果带了 docker 目录一般会有一份 compose 文件没有的话自己照下面这份改也行结构基本是通用的version: 3.8 services: mysql: image: mysql:8.0 container_name: tduck-mysql environment: MYSQL_ROOT_PASSWORD: root123456 MYSQL_DATABASE: tduck MYSQL_USER: tduck MYSQL_PASSWORD: tduck123456 volumes: - ./mysql-data:/var/lib/mysql ports: - 3306:3306 command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci redis: image: redis:7-alpine container_name: tduck-redis ports: - 6379:6379 volumes: - ./redis-data:/data tduck-api: image: tduck/tduck-api:latest container_name: tduck-api environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/tduck?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai SPRING_DATASOURCE_USERNAME: tduck SPRING_DATASOURCE_PASSWORD: tduck123456 SPRING_REDIS_HOST: redis ports: - 8010:8010 volumes: - ./upload:/home/tduck/upload depends_on: - mysql - redis有两个地方要特别注意。第一是 MySQL 的启动命令里显式指定了utf8mb4字符集很多表单提交的文本里有表情符号如果不是 utf8mb4入库时直接报Incorrect string value第二是./upload:/home/tduck/upload这个数据卷挂载表单里的图片和附件默认会传到容器内目录不挂载出来的话容器一重建所有上传文件就消失了。启动命令就三行cd tduck docker compose up -d docker compose logs -f tduck-api-d是后台运行logs -f用来盯启动日志。如果容器起来了但接口报 502大概率是 Nginx 没配或者配错了代理地址看下一节的反向代理配置。3.2 源码编译部署Maven 后端与前端 Nginx 分发源码部署要分四步走初始化数据库、编译后端、编译前端、配置 Nginx。先说后端。JDK 版本看 pom.xml 里的java.version一般是 1.8 或 11不要凭感觉选版本。打包命令cd tduck mvn clean package -DskipTests打包完的 jar 在tduck-admin/target/下。启动前先改配置文件application-prod.yml核心是数据源和 Redisserver: port: 8010 spring: datasource: url: jdbc:mysql://localhost:3306/tduck?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: tduck password: tduck123456 redis: host: localhost port: 6379 tduck: upload: path: /opt/tduck/uploadtduck.upload.path是上传文件的本地根目录这个路径必须提前建好并且有写权限否则表单里的图片上传组件会提示失败。启动命令带上 prod 环境java -jar tduck-admin/target/tduck-admin.jar --spring.profiles.activeprod前端编译相对简单。tduck-manager 和 tduck-front 两个目录分别安装依赖、构建产物以管理后台为例cd tduck/tduck-manager npm install npm run build构建产物在dist/目录。把dist里的文件全部复制到 Nginx 的站点目录同时把/api路径反向代理到后端 8010 端口配置如下server { listen 80; server_name form.example.com; client_max_body_size 20m; location / { root /opt/tduck/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8010/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }client_max_body_size 20m这行千万别删。表单里挂了文件上传组件时默认的 1m 限制会让大图直接报 413 Request Entity Too Large。try_files那句是 Vue 路由 history 模式的标准写法不加的话刷新页面就 404。3.3 初始化配置SQL 脚本、管理员账号与系统参数数据库表结构不是 JPA 自动生成的需要手动导入 SQL 脚本。进入sql/目录按文件名顺序执行mysql -u tduck -p tduck sql/init.sql mysql -u tduck -p tduck sql/upgrade.sql执行后确认一下表是否建全mysql -u tduck -p tduck -e show tables;管理员账号不要凭网上教程硬试。SQL 脚本里通常预置了一个管理员用户但不同版本的初始密码可能不一样而且密码字段是加密存储的。我先查表确认账号名再走一遍“忘记密码重置”的逻辑或者直接用脚本里 insert 语句中的加密密码。这里有个小技巧如果登录时密码不对去sql/里搜INSERT INTO td_user那条看它的密码密文是否能对上你尝试的值。部署完成后的系统参数配置我建议按这个顺序过一遍站点名称、收集链接的域名前缀、文件存储方式本地还是 OSS、邮件服务 SMTP。其中邮件服务影响的是“提交成功通知”和“找回密码”功能不配置也不影响表单收集主体流程可以放到后面再补。4. 从零搭一张参会报名表字段设计、收集策略与数据导出4.1 设计器拖拽流程与常用字段参数后台先建表单登录管理后台 → 表单管理 → 新建表单 → 选空白模板进入设计器。左侧组件面板会列出所有可用控件拖到中间画布右侧属性面板就显示当前组件的可配置项。这张参会报名表我通常包含这几类字段字段用途组件类型关键参数配置姓名inputplaceholder请输入姓名requiredtrue手机号inputpattern^1[3-9]\d{9}$maxLength11公司名称input不做长度限制但设置 maxLength100参会城市select选项值用固定列表默认值设“北京”是否需要住宿radio选项需要/不需要默认选中“不需要”感兴趣的议题checkbox选项按会议安排配置至少给4个选项到达日期date默认当天时间范围设为会议前后三天个人头像uploadfileTypeimagelimitSize5limitCount1拖完组件之后重点检查“字段标识”这一项。比如姓名输入框的field默认可能是input_abc123这会导致提交数据里出现一串乱码 key。我习惯在发布前手动改成name、company、city这种语义明确的标识后面查数据库和做 API 对接时会省很多事。4.2 收集策略设置限填、匿名、白名单与防刷表单发布前收集设置里要决定三件事谁能填、能填几次、什么时候截止。匿名收集适合外部公开场景登录后收集适合公司内部问卷可以在submit_data里带上submit_user_id方便追溯是谁填的。限填策略这里我多说一句。后台提供的“每设备一次”通常依赖 Cookie 加设备指纹但 Cookie 清掉就能绕过。如果表单涉及抽奖、领券这类有利益诱惑的场景只靠这个挡不住恶意刷单。常见的做法是在后端加一层幂等校验用 Redis 记录“表单 ID 设备指纹 提交周期”作为 key代码逻辑不复杂-- 限填一次key 为 formId_deviceFingerprint -- SETNX 返回 1 表示第一次提交返回 0 表示已提交过 if redis.call(SETNX, KEYS[1], 1) 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]) return 1 end return 0ARGV[1] 是限填周期秒数比如 86400 就是一天内有效。用 Lua 脚本是为了保证“判断是否已提交”和“写入记录”是原子操作并发请求同时到达时不会两个都返回 1。这个脚本可以直接放在 Redis 里执行EVAL也可以在 Spring Boot 的 RedisTemplate 里封装调用。防刷还能做两个辅助动作一是表单里加一个隐藏的“时间戳”字段提交间隔小于 3 秒的直接丢弃二是提交接口做 IP 频率限制单 IP 每分钟超过 10 次返回错误。这两个策略不影响正常用户但能把大部分脚本刷量挡住。4.3 数据回收三件套后台查看、Excel 导出与 API 读取数据回收最直接的方式是后台数据列表。按表单进入数据管理页可以按提交时间筛选、按字段值搜索。后台导出的 Excel 一般会带上提交时间、IP、设备指纹这些公共列组件字段值作为数据列表头就是字段标识。如果你需要把数据接到别的系统里直接用导出接口做定时拉取更省事curl -X GET \ http://localhost:8010/api/form/export/123 \ -H Authorization: Bearer ${TOKEN} \ --output form_123.xlsx这个接口返回的是文件流--output指定保存到本地文件名。注意 token 要从后台登录接口拿拿 token 时如果后台开启了验证码需要先请求验证码接口再提交账号密码。接口导出的好处是能放进定时任务。我一般用 Python 脚本每个小时拉一次拉完把数据写入中间库再对接报表系统。脚本核心就三行import requests url http://localhost:8010/api/form/export/123 headers {Authorization: Bearer token} resp requests.get(url, headersheaders, timeout30)拉取之后检查resp.status_code是否为 200再判断文件内容的二进制长度小于 100 字节大概率是错误提示而不是 Excel 文件。定时任务不要直接拿requests.get的响应去覆盖上次的文件先写临时文件再改名避免任务执行到一半文件被截断。5. Tduck 避坑记录部署、表单设计与数据收集的五个实例5.1 部署与启动阶段的坑现象一前端页面能打开但登录接口一直 502 Bad Gateway。原因Nginx 容器或宿主机 Nginx 把/api请求转发到了错误的地址。很多人配proxy_pass http://localhost:8010/;但在 Docker Compose 网络里localhost指向的是 Nginx 容器自身不是后端容器。解决方法是改成后端服务名proxy_pass http://tduck-api:8010/;如果后端跑在宿主机进程里Nginx 在宿主机上也要写 127.0.0.1 而不是服务器的公网 IP。先确认网络模型再改配置不然proxy_pass改十遍都没用。现象二容器启动后端口正常但访问任何接口都返回 500日志显示Unknown database tduck。原因SQL 初始化脚本没执行或者 MySQL 的初始化环境变量顺序不对。MYSQL_DATABASE只会在数据目录为空时创建数据库如果你挂载了旧的 mysql-data 目录环境变量不会生效。解决方法是进容器手动建库导表docker exec -it tduck-mysql mysql -uroot -p然后在 MySQL 里执行CREATE DATABASE tduck DEFAULT CHARACTER SET utf8mb4;再导入 SQL 脚本。5.2 表单设计与字段配置的坑现象三表单保存成功但用户端打开白屏控制台报Unexpected token in JSON。原因有人在数据库里手工改过td_form的 config 字段或者 JSON 里某个字符串没有转义导致前端JSON.parse失败。解决方法是先定位问题 JSONSELECT id, JSON_VALID(config) FROM td_form WHERE id 123;JSON_VALID返回 0 就说明配置损坏。修复时不要直接在原记录上改先在后台复制一张新表单把字段重新拖一遍再发布然后对比两份 JSON 的差异定位是哪个组件出的问题。从那以后我再也不手工改 config 字段了要改就回设计器改。现象四必填字段没填也能提交。原因设计器里勾选了必填但前端渲染时没把required透传到表单校验插件或者用户用接口直接调提交接口绕过了页面校验。解决方法是后端提交接口里必须二次校验if (Boolean.TRUE.equals(field.getRequired())) { Object value submitData.get(field.getFieldName()); if (value null || value.toString().trim().isEmpty()) { throw new BusinessException(字段 field.getFieldLabel() 不能为空); } }后端校验是最后一道防线前端校验只能提升用户体验不能当安全边界。5.3 数据收集与导出的坑现象五后台数据列表能看到数据但导出的 Excel 是空的或者打开后中文乱码。原因分两种。Excel 为空通常是导出接口里没有限制表单 ID直接把全表数据导出而查询条件又把表单 ID 过滤掉了中文乱码则是导出 CSV 时没写 UTF-8 BOM。如果走的是 Apache POI 写 xlsx基本不会乱码乱码多半是导出的 CSV 文件。解决方法是先确认导出接口的请求参数formId和startTime、endTime是否传对再看服务端导出代码里是否拼了\uFEFF前缀。debug 时直接先导 1 个小时的数据试试数据量小更容易分辨问题出在参数上还是文件编码上。6. 再进一步把 Tduck 接入业务系统的三个二次开发方向6.1 用 API 方式创建表单并推送收集数据表单不一定要在后台手工建。我接过的一个场景是用户在小程序里发起活动系统自动生成一张报名表单活动结束后数据再回流到小程序管理端。实现方式是调用创建表单接口提交一份 JSON 的字段定义然后拿返回的formId生成填写链接。数据回流同理每个表单提交请求在落库后可以同步推送给业务系统的消息队列实现“一次提交多处消费”。创建表单的接口核心参数就是字段数组结构与设计器保存时的 JSON 一致。这一步跑通后表单系统的价值就从“在线收集”延伸到了“系统间的数据交换”收集的数据不再是死数据。6.2 自定义组件类型扩展设计器业务里最常见的自定义组件是“省市区联动”“组织机构选择”。新增一个组件要动三处后端枚举加类型前端设计器面板加拖拽控件用户端渲染逻辑加映射。后端枚举的修改很简单在字段类型枚举里加一个SELECT_REGION存储上仍走 props JSON。前端用 Vue 注册一个新组件拖拽时把它插入画布组件配置项里放省市区数据源 URL。用户端渲染时按fieldType匹配到对应组件拉取数据源渲染三级联动。这里容易忽略的是历史数据的兼容。新增组件类型后老表单的 config 不会自动包含新组件的渲染逻辑我一般会在渲染层写一个 unknown 类型的兜底组件至少保证老表单不白屏。6.3 收集数据的消费闭环用定时任务回传业务库如果不想动消息队列定时任务方案也能跑通每 5 分钟查一次td_form_data把create_time大于上次同步位点的数据取出来按业务主键写入业务库。同步位点我习惯存 Redis避免数据库表多一套同步状态也方便断点续传redis-cli set sync:form:123:last_time 2025-01-01T00:00:00定时任务里每次读这个 key 作为起始时间同步完成后把MAX(create_time)写回去。只要这个位点不丢即使任务重启也能从上次的位置继续。最后说个跳过的坑。有一回我在线上表单里忘记关收集开关第二天发现一晚上收了几千条测试数据只能按提交时间去筛。从那以后我每次上线新表单都强制走一遍完整流程建表单 → 试提交一份 → 查数据库落库 → 导出 Excel → 再关收集开关。这套流程花不了两分钟但能挡住 90% 的线上翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表