简介:这是一份填鸭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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单字段表';props存 JSON 是这套设计里最关键的一步。每个组件的 placeholder、最大长度、正则校验、下拉选项全装在这个 JSON 里。我一般不建议直接修改数据库里的 props 字段,因为一旦 JSON 格式不合法,前端渲染直接白屏。后面我专门写了一条排查记录讲这个坑。
用户提交的数据表,常见做法是通用字段单独建列,组件值整体存 JSON:
CREATE 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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-server=utf8mb4 --collation-server=utf8mb4_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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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,核心是数据源和 Redis:
server: port: 8010 spring: datasource: url: jdbc:mysql://localhost:3306/tduck?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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.active=prod前端编译相对简单。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 设计器拖拽流程与常用字段参数
后台先建表单:登录管理后台 → 表单管理 → 新建表单 → 选空白模板进入设计器。左侧组件面板会列出所有可用控件,拖到中间画布,右侧属性面板就显示当前组件的可配置项。这张参会报名表我通常包含这几类字段:
| 字段用途 | 组件类型 | 关键参数配置 |
|---|---|---|
| 姓名 | input | placeholder=请输入姓名,required=true |
| 手机号 | input | pattern=^1[3-9]\d{9}$,maxLength=11 |
| 公司名称 | input | 不做长度限制,但设置 maxLength=100 |
| 参会城市 | select | 选项值用固定列表,默认值设“北京” |
| 是否需要住宿 | radio | 选项:需要/不需要,默认选中“不需要” |
| 感兴趣的议题 | checkbox | 选项按会议安排配置,至少给4个选项 |
| 到达日期 | date | 默认当天,时间范围设为会议前后三天 |
| 个人头像 | upload | fileType=image,limitSize=5,limitCount=1 |
拖完组件之后,重点检查“字段标识”这一项。比如姓名输入框的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, headers=headers, timeout=30)拉取之后检查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失败。解决方法是先定位问题 JSON:
SELECT 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% 的线上翻车。希望帮到你。
本文还有配套的精品资源,点击获取