1. 为什么做这套问卷系统:从需求到整体设计思路
在线问卷调查绝对不是一个可笑的“小项目”。真正落到企业场景里,问卷系统要面对的是组织内部员工调研、客户满意度回收、产品方向投票、活动报名收集等一堆真实需求。我之前给一家企业做过一个“问卷调查小程序 + 管理后台”的项目,最初以为就是换着花样做表单,实际做下来才发现:问卷的设计自由度、数据回传的准确性、权限隔离的细粒度、统计报表的实时性,每一样都能磨掉人一层皮。
这套源码当时定下的目标很明确:企业级在线问卷调查系统,是基于 SpringBoot + Vue + MyBatis + MySQL 的典型前后端分离项目,既要能支撑企业内部数百人同时填报,又要让管理员可以灵活创建题目、控制问卷上下架、自动统计回收情况。它不是一个学习用的CRUD Demo,而是真的按照生产项目标准去做的。整个项目拆成了三个端:后台管理端(创建问卷、配置题目、查看报表)、前端答题页(链接/二维码分享后受访者填写)、服务端接口(统一鉴权、数据持久化、结果统计)。
对于正准备做类似系统的开发同学,这套源码的价值在于:你能看到一套相对完整的权限模型、一套可扩展的题库结构、一套从创建到分析的全流程实现。文章里我尽量把核心设计逻辑和踩坑记录写全,不只是贴代码。如果你需要写课程设计、公司内部管理系统或商业项目MVP,这套思路可以直接拿来复刻。
2. 技术选型的背后逻辑:为什么是 SpringBoot + Vue + MyBatis + MySQL
2.1 后端框架的选择
后端只用 SpringBoot,这是Java领域最主流的快速开发框架,内嵌Tomcat,几行配置就能跑起来。团队里不管是应届生还是老手,都能快速上手。持久层选了原生 MyBatis 而不是 Spring Data JPA,是因为问卷系统的统计报表会涉及很多多表关联和动态条件查询,用 MyBatis 写SQL完全可控。比如按问卷ID统计每个选项的回收数量,这种SQL用MP(MyBatis-Plus)的 QueryWrapper 反而要绕一大圈,直接写XML里的动态SQL一眼就能看懂逻辑。
另外,MyBatis 的分页我直接用了分页插件 PageHelper,实体和Mapper都用传统XML映射。这样不会让面试官觉得你是只会“MP打天下”的选手,而且企业里很多老项目的确就是这么组合的。至于为什么不用 Spring Cloud 这一套微服务,原因很直接:问卷系统本身不是高并发核心链路,单体应用加合理索引就足够应对几千人同时填报,拆微服务反而把事务和部署复杂度拉高了。
2.2 前端框架与UI组件
前端选的是 Vue 2 + Element UI,这一组合在企业内部系统里覆盖率极高。虽然有 Vue 3 和 Element Plus 新方案,但考虑到大部分资料、现成组件、招聘要求,Vue 2 生态在2025年仍然有大量存量项目。技术债可以后面再还,先把业务跑通。如果你手头环境完全新起项目,直接上 Vue 3 + Vite + Element Plus 也没问题,逻辑层面的代码迁移成本不高。
前端项目不是简单的页面堆叠,我大概分成了几个模块:
- 登录模块:账号密码登录、JWT Token 存储、路由守卫。
- 问卷管理模块:列表、创建、发布、下线、复制。
- 问卷设计器:题干、选项、必填、题型切换、排序。
- 答题模块:适配 PC 和移动端的渲染,支持单选/多选/填空/下拉。
- 数据报表模块:展示回收量、各选项占比、明细下载。
组件层面,按题型抽取了一个 BaseQuestion.vue,再往下细分 SingleChoice.vue、MultipleChoice.vue、FillBlank.vue、DropdownQuestion.vue。这样的好处是,将来加一个评分题或矩阵题,只需要新写一个组件,并在“根据类型渲染”的映射表里注册即可,不用动答题页主逻辑。
Vue 项目本身用了 Vue Router 的路由懒加载,接口请求统一封装在 utils/request.js 里,请求头自动挂 Authorization,业务码统一判断,配合拦截器处理 401 跳转登录。复制问卷时,前端做了一次深拷贝然后重新保存,后端接口保证了题目和选项能一次性写入,而不是每道题各自请求接口。
2.3 MySQL 的选型与关键配置
数据库用的 MySQL 8.0,主要看中两点:JSON 字段类型和窗口函数。问卷答案天然适合用 JSON 存储,而统计时像“每份答卷平均答了多道题”这种需求,用窗口函数会快很多。字符集直接选了 utf8mb4,默认排序规则 utf8mb4_general_ci,避免用户填了表情符号导致报错。数据库连接串里必须带 serverTimezone=Asia/Shanghai,否则日期查询会差 8 小时。
说实话,Redis 在这套系统里不是必须的,但我还是加了一个轻量使用场景:问卷提交防重。利用 Redis 的 SETNX 或者简单的 key 过期机制,20秒内同一答卷人重复提交会被拦截。如果公司没引入 Redis,也可以退化为数据库唯一索引或前端按钮置灰,后面我会在防重设计里详细说。
3. 数据库设计:问卷系统的核心表结构与索引细节
3.1 主表结构设计
先看问卷主表 questionnaire。我把所有和问卷本身相关的属性都放在这张表里,字段设计如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| title | varchar(200) | 问卷标题 |
| description | text | 问卷描述 |
| status | tinyint | 0草稿,1发布中,2已结束 |
| type | tinyint | 0匿名,1实名 |
| start_time | datetime | 投放开始时间 |
| end_time | datetime | 投放结束时间 |
| creator_id | bigint | 创建人ID |
| question_count | int | 题目总数,冗余字段 |
| deleted | tinyint | 逻辑删除标记 |
| create_time | datetime | 创建时间 |
这里强调三个细节。第一,status 字段是核心状态机,草稿可以编辑,发布中的问卷不能改题目,只能强制停止。第二,question_count 冗余字段很有用,列表页不需要每次 count 题目表。第三,逻辑删除用 deleted 而不是物理删除,因为问卷一旦发布就有答卷关联,物理删除会把回收记录一起干掉。
题目表 question 的设计更关键。每道题的公共属性:id、questionnaire_id、type(1单选,2多选,3填空,4下拉)、title、required、sort_no、其他配置项。选项部分我们并没有单独建一堆列,而是用 option_json 字段存所有选项。这个设计刚出的时候团队里也有人反对,认为违反第一范式,但实际用起来很舒服。因为问卷题型扩展太频繁,如果今天加一个“上限多少个选项”、明天加一个“选项是否可补充”,难道都要加一列?用 JSON 只需在后端读取时解析,前端少传两个属性而已。
答卷表 answer_sheet 负责记录每次提交。关键字段是 questionnaire_id、respondent_user_id(匿名问卷可以为空)、submit_time、use_time_seconds。每道题的具体答案放 answer_detail,字段包括 id、sheet_id、question_id、question_type(冗余)、answer_value。为什么冗余 question_type?因为报表阶段需要知道这道题是单选还是填空,如果单独去关联题目表,多一层 JOIN 且题目表可能未来被修改。冗余一个类型字段,统计的时候查询更快,逻辑更简单。
3.2 索引与事务约束
数据库索引我并没有滥用。三个核心索引组合:
- idx_questionnaire_status(status, deleted):支撑后台列表按状态筛选。
- idx_questionnaire_creator(creator_id):支撑“只看我创建的问卷”。
- idx_answer_sheet_questionnaire(questionnaire_id):支撑每份问卷回收明细。
- idx_answer_detail_sheet(sheet_id):支撑答卷详情回显。
唯一性约束方面,answer_sheet 表对“问卷ID + 用户ID”加了唯一索引(匿名问卷单独处理)。这是为了防止同一用户重复提交最硬的兜底。如果企业场景里允许多次提交,就必须在业务层放开并加一个“第几次”字段,而不是简单地挪唯一索引。
创建问卷的保存是一个典型事务。一个问卷里可能包含几十道题目,每道题目又有多个选项,如果逐条插入,中途一旦报错就会产生“半份问卷”。我在 Service 层直接加了 @Transactional,每次保存先删掉旧题目和旧选项,再批量插入新的。这个做法在草稿阶段可行,但编辑已发布问卷要非常小心,所以我设计了“已发布问卷不可编辑题目”的规则,从源头杜绝脏数据。
4. 后端核心模块:问卷创建、发布、提交答卷与统计报表
4.1 问卷创建与保存:批量操作的事务处理
问卷保存接口是典型的一对多写入,前端一次传来 questionnaire 对象、questions 数组。后端处理思路是:
- 校验问卷标题非空、至少有一道题。
- 如果 id 为空,插入问卷主表拿到主键。
- 在循环里为每道题生成 sort_no,从1开始自增。
- 批量插入 question 和 option_json。
- 如果题目有必填校验,后端也要同步校验。
这里最容易踩的坑是 sort_no 重复。前端如果拖拽排序,sort_no 是数组下标加1,理论上没问题。但用户如果复制问卷,前端复制时忘深拷贝,导致所有题目 sort_no 全是1,数据库就会因为业务逻辑问题出现排序错乱。我后来在后端保存前做了一次“强制重新编号”,不再信任前端传的 order 字段,而是按数组顺序重新生成,彻底杜绝了这类问题。
4.2 发布与状态控制
问卷状态机不算复杂,但我用了一张状态流转表来约束:
- 草稿(0) -> 发布(1)
- 发布(1) -> 结束(2)
- 结束(2) -> 重新发布(1)
- 草稿/结束 -> 删除(逻辑删除)
接口层会先加载问卷,比对当前状态,如果不允许流转就直接抛出业务异常。这个判断不能只靠前端按钮控制,因为别人可以直接 curl 接口强制修改状态。比如“结束”的问卷要改成发布,后端必须校验结束时间是否已过;如果要重置问卷,需要将已有答卷全部清空并二次确认。发布时的权限也很关键:非创建者、非超管,无权修改状态。
4.3 提交答卷:唯一性约束与幂等防重
答卷提交是整个系统的重头戏。一次提交包含问卷ID、答卷人信息、每道题的答案。后端接收时做了三步校验:
- Token 解析出当前用户身份。
- 查询问卷状态是发布中,且当前时间在 start_time 和 end_time 之间。
- 检查 answer_sheet 是否已存在同问卷同用户的记录,如果存在,直接返回“您已提交过”。
为了应对并发提交,光靠查询不够,必须靠数据库唯一索引兜底。当两个请求同时进来,都发现不存在记录,然后同时 insert,唯一索引会拦住一条。插入失败后捕获 DuplicateKeyException,转成友好的业务提示。Redis 方案是基于 sessionId 或 userId 加一个 30 秒的 SETNX 锁,逻辑上更优雅,缓存失效后再查数据库。
答题细节上,我还在每个 answer_detail 存了 question_type 的快照。这样即便管理员后来把题从单选改成多选,历史答卷的统计仍然能按当时题型处理。统计报表时如果发现答案 JSON 数量和 issues 不一致,也能根据快照识别出脏数据。
4.4 统计报表:SQL 聚合与导出
统计功能直接写 SQL 比在内存里聚合更高效。比如统计某份问卷单选第一题每个选项的票数,可以用:
SELECT qd.id AS question_id, json_unquote(json_extract(qd.answer_value, '$.optionId')) AS option_id, COUNT(*) AS cnt FROM answer_detail qd WHERE qd.questionnaire_id = #{id} AND qd.question_type = 1 GROUP BY qd.id, json_extract(qd.answer_value, '$.optionId')说实话,这个 SQL 在数据量大之后不太好维护,所以生产版本里我加了一层“答卷选项明细表”,提交答案时把选项ID单独拆成一列,统计走明细表,而不是做 JSON 解析。虽然冗余存储多一点,但统计性能好很多。导出 Excel 时后端用 EasyExcel 实现,报表接口先把聚合结果封装成 List
5. 前端核心页面:问卷设计器、答题页与管理后台实现
5.1 问卷设计器:拖拽排序背后的组件映射
设计器是用户感知最强的功能。左栏是可选的题型,中间是题目编辑区,右侧是题目的属性面板。我这里没用太重型的拖拽库,只是用原生 HTML5 的 drag 事件实现排序,足够支撑“上移、下移、拖拽交换位置”三种操作。
编辑区的数据模型是一个 questions 数组。每个对象包含 type、title、required、optionList、jsonConfig 等字段。新增题目时,往数组 push 一个默认对象;删除时 splice;排序变化后更新每个 question.sortNo。保存时把所有题目状态提交到后端。页面本身还有实时预览功能,编辑区的组件和答题渲染组件是同一套,保证“所见即所得”。
设计器最怕的是用户改了选项但忘记保存直接跳走。我在路由守卫里加了 beforeRouteLeave,如果问卷是“草稿”且 hasChange 为 true,则弹出浏览器 confirm 提示。这个小小的保护功能在真实使用中非常有价值。
5.2 答题页:多题型动态渲染
答题页的 URL 类似 /fill/{id},进入后先加载问卷信息,再加载题目列表。因为是动态表单,组件渲染用了一个 typeMap:
const typeMap = { 1: SingleChoice, 2: MultipleChoice, 3: FillBlank, 4: DropdownQuestion }遍历 questions 时通过 component :is="typeMap[question.type]" 渲染组件。每个子组件用 v-model 绑定 answerValue,父组件收集全部答案后一次性提交。答题页还需要处理必填校验、逻辑跳转,比如“如果选A则跳到第5题”。逻辑跳转做得不复杂,前端在 nextValidQuestionIndex 里根据 beforeQuestionId 匹配下一道题索引,后端只负责保存答案,不建议把题间跳转规则放在服务端,不然每次都要算下一题,接口性能会很差。
这段还有两个细节。第一,答题页如果中途关闭,已经填写的答案要不要保存?这个项目里没有做“草稿答卷”,而是直接用 localStorage 缓存了部分答案,问卷重新打开时恢复,提交成功就清空。第二,移动端适配用了 viewport 配置 + 大按钮样式,企业扫码填写场景里很少有人用电脑,所以答题页第一优先级是手机。
5.3 管理后台报表:图表与明细
后台首页是问卷列表,支持按状态搜索和分页。点击“查看报表”后进入统计页,页面分三块:总回收量、题目占比图、答卷明细表。占比图我用 ECharts,单选和多选题显示环形图,填空题不参与比例计算,只展示文字答案列表。
报表接口返回的数据结构在前后端做了对齐:每个题目返回选项列表和 count,前端再构建图表数据。这里有一个实际发现:如果问卷题目有几十道,前端一次性渲染所有 ECharts 实例会卡顿。优化方案是改成 Tab 切换懒加载,每次只初始化当前题目的图表,切走时销毁实例。实际体验提升非常明显。
6. 权限与多组织隔离:企业级系统不可回避的部分
6.1 基于 JWT + 拦截器的轻量鉴权
这套系统没有引入 Spring Security,原因是内部系统的权限模型只要做“认证 + 接口级角色控制”,自己封装拦截器足够。用户登录成功后会签发 JWT,JWT 中存放 userId、username、roleCode,有效期 12 小时。前端每次请求带 Authorization: Bearer token,后端拦截器解析并校验,验签失败或过期直接返回 401。
为什么不推荐在这个项目里硬上 Spring Security?它的过滤器链、UserDetailsService、权限表达式需要不少学习成本。对于一套做内部调研问卷的系统,权限控制粒度到“角色”基本够了,真的不需要细到方法级。当然,如果系统需要集成 OAuth2 单点登录,再引入 Spring Security 也是后话。
6.2 数据权限与多组织隔离
除了认证,真正让系统“企业级”的是数据权限。同一个平台的问卷,不能让普通用户看到别人的问卷数据。我在用户表加了一个 dept_id,agency 维度。查询问卷列表时强制带上 dept_id 条件;如果当前用户是超级管理员,则取消这个过滤。
具体实现上,数据权限既可以用 MyBatis 拦截器自动拼接,也可以在每个 Mapper XML 里手动传 deptId。因为项目里涉及 dept_id 的查询就几张表,我选择了手动拼接,简单直白。如果有人想复用这套源码,只需要在 BaseMapper 里定义一个自定义 SQL 片段,比如<sql id="deptFilter"> AND q.dept_id = #{query.deptId}</sql>。这样不会因为拦截器自动拼接导致 SQL 复杂度失控。
在接口权限层面,我也只做了粗粒度:问卷管理接口要求 roleCode 是 admin 或 manager,答题和普通查询接口任何登录用户都能访问。如果超管想创建问卷,直接用后台的“切换视角”菜单,前端带着一个 isSuper 标记,后端再对部分接口放行。这套简化后的 RBAC 模型,内部使用完全够用。
7. 部署上线实录:从打包到 Nginx 反向代理
7.1 环境准备与配置分离
生产环境我们准备了两台机器,一台部署 MySQL,一台部署后端和前端。其实纯小型项目也可以后端和前端塞同一台 2核4G 的服务器。后端打包用 Maven,先跳过测试:
mvn clean package -DskipTests打包产出的 jar 包放在服务器 /opt/quiz 目录,启动命令:
java -jar quiz-system.jar --spring.profiles.active=prod生产配置我放在 application-prod.yml 里,数据源地址、Redis 地址、JWT 密钥全部通过环境变量注入,不写死在配置文件里。部署时用一个 systemd 服务来守护进程,进程崩了自动重启,日志输出到 /var/log/quiz.log。
7.2 前端构建与 Nginx 配置
前端构建很简单:
npm run build生成 dist 目录后,把静态文件上传到 Nginx 的 html 目录。Nginx 要做两件事:静态文件服务 + API 反向代理。具体配置片段如下:
server { listen 80; server_name survey.example.com; location / { root /usr/share/nginx/html/quiz; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最大的坑是 history 路由模式。Vue Router 如果用 history 模式,刷新页面会 404,必须用try_files $uri $uri/ /index.html;这句配置把所有路径都重新交给 index.html 处理。如果忘了,用户进入问卷详情页一刷新就白屏。用 hash 模式可以躲开这个坑,但 URL 上会带 #,分享出去不够好看。
7.3 上线时容易忽略的数据库细节
上线第一天就遇到一个诡异问题:服务器上保存的问卷标题汉字出现乱码。排查后发现 MySQL 建库时没有指定 utf8mb4,默认是 latin1。重新建库后,补了一句:
CREATE DATABASE quiz DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;同时,驱动连接串上加上useUnicode=true&characterEncoding=utf8。这个问题只在 Linux 服务器上出现,本地开发环境因为系统默认 UTF-8,很难复现。所有新项目我都直接在初始化 SQL 脚本里加库级字符集,避免后来补数据。
8. 常见问题与排坑记录速查表
8.1 各场景踩坑实录
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 查询用户列表时 user_name 字段是 null | MyBatis 默认不开启驼峰映射 | 在 application.yml 设置 map-underscore-to-camel-case: true |
| 批量保存问卷时题库插入不完整 | 外层事务没生效 | Service 方法必须是 public,且通过 Spring 代理调用,不能同类内部 this 调用 |
| 提交答卷提示“问卷不存在” | 缓存的问卷状态是旧值 | 提交时重新查库,而不是直接信任前端传入的状态 |
| 高峰期大量重复提交 | 数据库压力大,前端的禁点按钮失效 | 用 Redis SETNX + 数据库唯一索引双保险 |
| 导出Excel中文文件名乱码 | Excel框架对中文文件名处理不一致 | 使用 URLEncoder.encode 编码文件名为 UTF-8 |
| 问卷统计数量比答卷数多 | 有些题目未答,统计时 inner join 过滤了空答案 | 改用 LEFT JOIN,分组统计时用 COUNT(answer.id) |
| 前端图表显示空白 | ECharts 容器初始化时宽度为0 | 在 nextTick 和 resize 事件里重新 init |
这些坑里我最想强调两个。第一个是 MyBatis 驼峰映射,几乎所有从 SpringBoot 2 开始写项目的新手都会遇到。表字段是 create_time,Java 属性是 createTime,如果不开启驼峰映射,查询结果全是 null。第二个是事务失效问题,我之前写好一个保存函数,发现只插入一半,排查半天发现是在同一个类里的另一个方法调用了这个事务方法,因为 Spring 的代理机制,内部方法调用不会触发事务。
8.2 性能优化与后续扩展建议
问卷系统如果访问量逐渐变大,优先考虑三点:
- 静态资源前端上 CDN,答题页本身就一个 index.html 加几个 JS 文件,CDN 能缓解大部分服务器带宽压力。
- 热点问卷的答卷提交可以引入消息队列,先写入 Redis 再做异步落库,但需要保证最终一致性。
- 报表统计可以考虑用定时任务每日生成一次快照,而不是每次请求实时聚合所有明细。
扩展方向里,我感觉最实用的是“答卷回收提醒”。问卷发布后,后台管理员希望能定时收到回收进度邮件。用 SpringBoot 自带的 @Scheduled 每半小时扫描一次,统计回收率,达到阈值就发邮件。这个功能不复杂,但能明显提升系统的运营价值。另外就是问卷逻辑跳转的升级版,支持按答案分值自动推荐下一题,适合做各种测评问卷。
9. 写在最后的经验沉淀
我做完这套系统之后最大的体会是:问卷系统看起来是 CRUD,但真正决定可用性的全在细节里。一个是状态机,草稿、发布、结束三个状态绝对不能穿;一个是幂等,答卷提交一旦重复,后台数据直接烂掉;还有一个是统计口径,不同类型题目的统计逻辑必须统一,不然运营人员看到两个对不上的数字,整套系统信任度瞬间归零。
如果你现在打算自己写一个问卷系统,我的建议是:先做出来草稿、发布、回收这条主链路,再考虑好看的设计器。把主链路的异常处理、权限隔离、数据约束全部想清楚,比加一百个酷炫题型更重要。复用这套源码时,请保留核心表结构和状态校验逻辑,前端样式和题目类型都可以放开改,但底层数据的一致性一定不能破坏。希望这篇文章能帮你少踩几个坑。