做一个基于SpringBoot的调查问卷系统,听起来像是毕业设计里最经典的那类选题,但实际上手之后你会发现,它远没有题目看起来那么“标准”。问卷要支持多少种题型、答案怎么存才能方便统计、如何防止同一个人重复提交、前端怎么和一个后端工程打包在一起部署——每一样都够你踩上几轮坑。我帮人梳理过好几套类似的项目,也踩过不少莫名其妙的雷,这篇文章就把我自己比较推荐的一套完整做法写下来:SpringBoot + MyBatis Plus + MySQL 做后端,Vue + Element UI 做管理端和填写页,最后把前端构建产物直接放进 SpringBoot 的静态目录里整体部署。适合正在准备毕设、刚学完框架想找个完整实战项目练手,以及公司内部想快速搭一套小型问卷工具的朋友参考。
1. 项目定位与需求拆解
1.1 这类系统到底在解决什么问题
先说个场景。传统发纸质问卷,发出去一百份,回收四五十份,还得手动Excel录入,单选题还好,遇到多选题、评分题,光清洗数据就能耗掉大半天。企业在做员工满意度调查、学校做课程反馈、运营做用户调研,本质诉求其实都一样:一是问卷的创建和发布要快,二是答卷数据要能自动汇总,三是统计结果最好一眼能看懂。
所以这个系统要解决的从来不是“能不能填问卷”,而是“从创建问卷到拿到统计结果”这一整条链路能不能跑得顺。想明白这一点,功能范围的边界就清楚了:不需要做复杂的权限体系,不需要社交分享,也不需要海量并发支撑,核心就是把问卷、答题、统计这三件事做到干净利落。
1.2 核心需求拆解:问卷、答题、报表三件事
我把功能拆成三个大模块,这也是整个后端设计的骨架。
第一块是问卷管理。管理员可以创建问卷、编辑标题和说明、添加题目。题目需要覆盖常见题型:单选、多选、判断、简答、评分。每种题型有不同的配置项,比如单选要配选项、判断题可以固定成“对/错”、评分题要设置分值上限。问卷本身有状态流转:草稿、发布中、已结束。
第二块是答卷管理。用户在页面上填写问卷并提交,后端要做两件事——校验哪些题目是必填的,防止同一个人重复提交。这里有一个容易被忽略的点:如果问卷要求匿名,就不能用用户名做唯一标识;如果要求实名,则需要一个 userId 字段做幂等控制。
第三块是统计分析。管理员能看到每份问卷的回收数量、每题的选择分布、平均分、简答题文本汇总。这是整个系统里最有价值的部分,也是数据库设计时要重点照顾的地方——答案的存储结构必须让统计SQL好写才好用。
1.3 谁能用得上这套实现
这套内容适合的人群,我观察下来主要三类。第一是准备毕业设计的同学,拿这个题目做完整的前后端项目,无论是工作量还是技术覆盖面都足够,从框架整合到表设计再到部署都有得写。第二是刚学完 SpringBoot 和 MyBatis 但没做过完整项目的人,你可以照着这篇文章把系统从零搭一遍,过程中顺便理解事务、注解、条件构造器这些知识点到底在真实业务里怎么用。第三类是想在公司内部搭套小问卷工具的后端同学,整套代码量不大、部署也简单,一个 jar 包就能跑,省去申请外部服务的时间。
2. 技术选型与工程初始化
2.1 为什么是 SpringBoot + MyBatis Plus + MySQL
先说后端框架,为什么 SpringBoot 是这个项目的最优解。调查问卷系统的逻辑属于典型的中小型业务系统,CRUD 多、配置多、第三方集成少,这种情况下 SpringBoot 的自动装配特性非常省事:引入一个spring-boot-starter-web起步依赖,内嵌 Tomcat 就起来了,不用像 SSM 时代那样手写一堆 XML 配置。同时它的生态足够成熟,遇到问题搜一下基本都有答案,对新手极友好。
持久层我推荐 MyBatis Plus。有些人可能纠结要不要用 JPA,我的观点是:问卷系统的统计场景复杂,经常要写多表联查的 SQL,MyBatis 的灵活性明显占优。而纯 MyBatis 需要手写大量单表 CRUD 的基础方法,效率低。MyBatis Plus 正好卡在中间:单表操作用内置的BaseMapper和条件构造器,复杂统计自己写 XML,两不耽误。这个选型逻辑适用于大部分毕设和中小型管理系统,你哪怕换个主题,结论也成立。
数据库就是 MySQL,没有特别的理由,就是稳、常用、学习成本低。如果后续要演示集群或者大数据量查询,MySQL 也撑得住。
2.2 工程目录与基础配置
工程结构上,我习惯按分包分层,而不是按功能模块分包。虽然这两种方式各有拥趸,但在项目规模不大时,按技术层分包思路更清爽,也更好向别人解释:
com.example.survey ├── controller # 接收请求、参数校验 ├── service # 业务逻辑、事务控制 ├── mapper # MyBatis Plus 数据访问 ├── entity # 数据库实体 ├── dto # 接收前端参数的传输对象 ├── vo # 返回前端的视图对象 ├── config # 全局配置(跨域、MyBatis Plus 分页等) └── common # 统一返回结果、异常处理、工具类基础配置application.yml里,需要关注的几个点:
- 端口和上下文路径:端口默认 8080,如果前端要反向代理到同一个服务,建议设置
server.servlet.context-path: /api,让所有后端接口统一走/api前缀。 - 数据源:配置
url、username、password,注意加上useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,不然中文乱码和时区问题会轮番折磨你。 - MyBatis Plus 配置:开启驼峰映射
map-underscore-to-camel-case: true,配置逻辑删除字段,再配一个分页插件。逻辑删除这个功能强烈建议加上,问卷和题目数据删错了还能找回来,比物理删除稳妥。
2.3 版本兼容:这个坑必须提前排
这应该是第一个需要敲黑板的地方。热词里有一条“springboot版本太高”,我太有共鸣了,SpringBoot 版本直接决定了你后面能不能顺畅地启动项目。
SpringBoot 3.x 要求 JDK 17 及以上。如果你电脑上还是 JDK 8,老老实实用 SpringBoot 2.x,不要硬上 3.x,否则UnsupportedClassVersionError能让你怀疑人生。重点讲 MyBatis Plus 的兼容性:SpringBoot 3.x 必须引入mybatis-plus-spring-boot3-starter,而不是以前那个mybatis-plus-boot-starter,这俩差一个字,包名和自动配置完全不同,引错了直接启动失败。
我整理了一个实测过不会出错的组合:
| 场景 | SpringBoot 版本 | JDK | MyBatis Plus 依赖 |
|---|---|---|---|
| 本机是 JDK 8(最常见) | 2.7.x | JDK 8 | mybatis-plus-boot-starter 3.5.x |
| 本机已是 JDK 17 | 3.2.x | JDK 17 | mybatis-plus-spring-boot3-starter 3.5.5+ |
| 需要 JDK 21 演示最新特性 | 3.3.x | JDK 21 | mybatis-plus-spring-boot3-starter 3.5.7+ |
顺带说一句,SpringBoot 2.X 之后默认使用 CGLIB 代理,不像早期版本那样优先用 JDK 动态代理。这意味着你的 Service 实现类不需要强制写接口也能被代理,这种约定在写了很多年后回头看确实是省事的,但面试官如果问起来,你可别说不知道。
3. 数据库表设计:打地基
3.1 五张核心表
表设计是整个项目里最值得花心思的部分。我的方案是五张表:问卷表、题目表、选项表、答卷主表、答卷明细表。这五张表构成了一个典型的主从模型,既支持灵活的题型组合,又方便后续统计。
问卷表survey的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| title | varchar(200) | 问卷标题 |
| description | text | 问卷说明 |
| status | tinyint | 0-草稿 1-发布中 2-已结束 |
| start_time | datetime | 发布时间 |
| end_time | datetime | 截止时间 |
| deleted | tinyint | 逻辑删除标记 |
题目表survey_question:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| survey_id | bigint | 所属问卷 |
| type | tinyint | 1-单选 2-多选 3-判断 4-简答 5-评分 |
| title | varchar(500) | 题干 |
| required | tinyint | 是否必答 |
| sort_order | int | 排序号,控制展示顺序 |
| score_max | int | 评分题的分值上限 |
选项表survey_option:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| question_id | bigint | 所属题目 |
| content | varchar(500) | 选项内容 |
| sort_order | int | 排序号 |
答卷主表survey_answer:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| survey_id | bigint | 问卷ID |
| user_id | varchar(64) | 提交人标识,匿名问卷可为空 |
| ip | varchar(50) | 提交IP |
| submit_time | datetime | 提交时间 |
答卷明细表survey_answer_detail:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| answer_id | bigint | 答卷主表ID |
| question_id | bigint | 题目ID |
| option_ids | varchar(255) | 选择的选项ID,多选用逗号分隔 |
| answer_text | text | 简答题的文本内容 |
| score | int | 评分题的分数 |
3.2 题型多样化怎么存
这是问卷系统设计里最关键的一个决策:题目配置和答卷内容都用“通用字段”存储,而不是为每种题型单独建表。
举个例子,判断题我完全可以不强社交论选项,因为它的答案只有对和错;但为了统一,我依然在选项表里放“对/错”两条数据。这么做的好处是,前端渲染和各种题型的统计逻辑统一了——单选和多选统计的是选项ID的分布,判断统计的也是选项ID的分布,只不过看到“1/0”意思是对/错。你不需要针对判断题写单独的查询代码,这就是通用模型的威力。
多选题的答案存储方式值得单独说。我的做法是把多个选项ID用逗号拼接存进option_ids字段,比如用户选了 A、B、C,就存成"1,3,5"。看到这里可能有人担心:这样怎么统计每个选项被选了几次?用FIND_IN_SET或者LIKE都能查,但更稳妥的写法是先在代码里把字符串拆开,再逐条写入统计表,或者直接用GROUP_CONCAT配合聚合查询。在数据量几十万以内,这种存储是实测很稳的方案。
3.3 索引与数据一致性设计
索引这块,我维护三条原则。第一,所有外键关联字段必须加索引:survey_question.survey_id、survey_option.question_id、survey_answer.survey_id、survey_answer_detail.answer_id、survey_answer_detail.question_id。加了索引之后,统计接口的查询速度是肉眼可见的提升。
第二,防重复提交不能只靠代码判断。我在survey_answer表上建了一个联合唯一索引:(survey_id, user_id)。注意这个字段,如果用户可能从多个入口提交,比如电脑端一次、手机端一次,光靠前端按钮禁用是不够的。数据库层面的唯一约束才是最终的兜底方案,插入时如果撞了唯一索引,直接抛异常,再由全局异常处理器翻译成“您已提交过该问卷”。
第三,逻辑删除字段deleted统一放每张表里,并且所有查询语句默认拼接deleted = 0。MyBatis Plus 的@TableLogic注解可以自动帮你完成这个拼接,不用每次手写。但这里有个小陷阱:唯一索引不能包含deleted字段,否则逻辑删除后再创建同名问卷会报错。你自己权衡,如果不用逻辑删除,就把删除做成真正的物理删除,至少保持一致。
4. 核心功能实现:从增删改查到统计报表
4.1 问卷CRUD与事务细节
创建一个问卷的操作,表面上是插入一条survey记录,实际上是一连串操作:插入问卷主表、批量插入题目、批量插入选项。这三个操作必须在一个事务里完成,否则问卷做到一半系统崩溃,留下一堆孤儿题目数据。
事务的写法很简单,但有两个细节我踩过坑。第一,@Transactional注解默认只回滚RuntimeException和Error,如果抛出的是受检异常比如IOException,事务是不会回滚的。所以创建问卷这类写操作,我会明确写成@Transactional(rollbackFor = Exception.class)。
第二,事务只对 Spring 管理的代理对象生效。如果你在同一个类的内部调用自己的另一个@Transactional方法,事务会失效,因为调用没有经过代理对象。我早期写代码经常犯这个错,后来干脆把复杂操作拆到独立的 Service 类里,让调用关系清晰化,避免自调用。
保存问卷的实现思路,我建议做成“先删后插”而不是“逐项更新”。也就是说,编辑问卷时,把原问卷下的题目和选项全部逻辑删除,再按照前端传回的最新结构重新插入。这样代码逻辑极其简单:不需要逐题比对哪些改了哪些没改,只要前端能把完整的问卷结构回传就行。实测下来,这个方案在问卷编辑场景下是最不容易出 bug 的。
4.2 答卷提交接口的设计
答卷提交接口是整个后端里并发压力最大的一个接口,因为可能同时多人提交。我把它设计成接收一个包含surveyId、userId和answers列表的 DTO,然后在 Service 层做四件事:
第一步校验问卷状态。只有status = 1(发布中)且当前时间在start_time和end_time之间才允许提交。问卷还没开始或者已经截止,直接返回业务异常。
第二步校验必答项。遍历问卷的所有题目,对照前端提交的答案列表,凡required = 1的题目必须有答案。多选题至少要有一个选项,简答题不能是空字符串。这些校验写在校验器里,不要散布在业务代码里,否则维护时会很痛苦。
第三步防重复提交。先查询survey_answer表,如果发现(survey_id, user_id)已存在记录,直接拒绝。即便并发场景下出现极小概率的“同时查询都不存在”,数据库的唯一索引也会兜住。
第四步批量插入明细。先把答案主表插入拿到主键 id,再遍历 answers 逐条插入明细表,整个流程加@Transactional(rollbackFor = Exception.class)。注意明细表插入前要做数据清洗:对字符串做trim(),超长文本做截断,防止数据库报字段超长错误。
4.3 统计聚合SQL
统计模块是问卷系统的灵魂,这里直接上核心 SQL。统计单选题各选项的选中次数:
SELECT q.id AS question_id, q.title AS question_title, o.id AS option_id, o.content AS option_content, COUNT(detail.id) AS option_count FROM survey_question q LEFT JOIN survey_option o ON o.question_id = q.id LEFT JOIN survey_answer_detail detail ON detail.question_id = q.id AND FIND_IN_SET(o.id, detail.option_ids) WHERE q.survey_id = #{surveyId} GROUP BY q.id, o.id ORDER BY q.sort_order, o.sort_order统计回收率和平均分:
SELECT COUNT(DISTINCT a.id) AS total_answers, ROUND(AVG(d.score), 2) AS avg_score FROM survey_answer a JOIN survey_answer_detail d ON d.answer_id = a.id WHERE a.survey_id = #{surveyId}判断题、评分题的统计逻辑都可以复用上面这个模板。实际开发中我推荐把统计 SQL 写成 XML 放在 mapper 里,方便后续根据新的统计需求调整 SQL,而不是在 Java 代码里写字符串拼接。
4.4 定时任务控制问卷上下线
问卷的发布时间和截止时间如果完全靠管理员手动操作,很容易出现忘了截止导致问卷一直开放的尴尬场景。我建议加一个 SpringBoot 定时任务兜底:每五分钟扫描一次所有状态为发布中的问卷,如果当前时间超过end_time,自动把状态改成“已结束”。
@Component @Slf4j public class SurveyStatusTask { @Scheduled(cron = "0 */5 * * * *") public void autoCloseSurveys() { // 查询所有 status = 1 且 end_time < now 的问卷 // 批量更新 status = 2 } }@Scheduled用起来很简单,但有两个前提:启动类上要加@EnableScheduling;如果项目部署在多台机器上,你要想清楚定时任务会不会重复执行。问卷自动截止这个任务重复执行影响不大,幂等性好,所以不用引入分布式锁,这也是选这个场景练手的原因之一。
5. 前端整合与部署细节
5.1 Vue打包放进SpringBoot静态目录
现在的毕设和中小型项目很流行“vue打包放进springboot中”,因为部署省事——一个 jar 包搞定前后端,不用单独部署 Nginx 和 Node 环境。做法是这样的:
前端项目用 Vue + Element UI 写好页面,本地调试时通过 axios 请求后端接口,接口地址配成/api开头,开发环境用 devServer 代理到localhost:8080。开发完成后执行npm run build,生成dist目录。
然后把dist里的文件全部复制到 SpringBoot 的src/main/resources/static目录下。重新打包,启动 SpringBoot 后直接访问http://localhost:8080/就能看到问卷首页。为了让它正确工作,还有一步很关键:给入口页面配置首页转发。
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController("/").setViewName("forward:/index.html"); registry.addViewController("/admin").setViewName("forward:/index.html"); } }5.2 刷新404处理与发布踩坑
如果你用的是 Vue Router 的 history 模式,打包放进 SpringBoot 后刷新任意子路由页面会 404,因为后端没有对应的路径。这个问题有现成的解决方案:实现一个ErrorPageRegistrar,把所有非接口路径的请求转发到index.html,让前端路由接管。但注意不能把/api/**也转发进去,否则接口请求会走到前端页面。
@Component public class HistoryModeConfig implements ErrorPageRegistrar { @Override public void registerErrorPages(ErrorPageRegistry registry) { registry.addErrorPages(new ErrorPage(HttpStatus.NOT_FOUND, "/index.html")); } }还有两个小坑如果你遇到了,大概率和我一样糟心。第一是静态资源路径问题:前端图片、CSS、JS 的引用路径,在vue.config.js里把publicPath设置成'./',用相对路径,避免部署到非根路径时资源全部 404。第二是接口跨域问题:前后端打成同一个 jar 之后不存在跨域,但开发阶段你是前后端分离跑的,所以后端要配置跨域过滤器或者使用@CrossOrigin,不然 devServer 调试时接口会被浏览器拦截。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
我把我自己和身边人实际遇到过的问题整理成一个速查表,方便你定位:
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
启动报ClassNotFoundException: javax.servlet.* | SpringBoot 3.x 移除了 javax,改用 jakarta | 降低到 SpringBoot 2.7,或改用 jakarta 命名 |
MyBatis Plus 的BaseMapper方法都用不了 | 版本和 SpringBoot 不匹配,引入了错误的 starter | 见上文版本兼容矩阵 |
| 查询时逻辑删除字段没生效,删掉的数据还能查出来 | 实体类缺@TableLogic | 在实体deleted字段上标注 |
| 日期字段返回前端变成时间戳 | 全局 JSON 序列化默认格式 | 配置spring.jackson.date-format=yyyy-MM-dd HH:mm:ss和时区 |
| Vue 打包后刷新子路由 404 | 未配置 history 模式后端转发 | 使用 ErrorPageRegistrar 转发到 index.html |
| 多选答案统计不准确 | 存了"1,3,5"字符串但统计时没有拆开处理 | 用 FIND_IN_SET 或先拆后统计 |
6.2 几个值得说说的经验
最后分享几点个人经验,不算标准答案,但确实是我做了几轮之后沉淀下来的东西。
第一个是关于“springboot 自动装配原理”这一类面试题的。很多人背了概念但不知道在自己项目里对应哪个环节。实际上,你引入mybatis-plus-boot-starter后什么配置都不用写就能注入SqlSessionFactory,这就是自动装配在起作用:SpringBoot 扫描META-INF/spring.factories里的自动配置类,按条件注解判断是否生效。在你自己的项目里,如果你也想封装一个通用的“xx组件”给别人用,照着这个思路写一个自定义 starter,就是模范答案。
第二个是启动 Banner。作为一个彩蛋,可以在src/main/resources/banner.txt放一段 ASCII 艺术字,启动时显示在控制台。白嫖一个在线 Banner 生成器,给自己项目加点乐趣,也方便和同事区分不同环境的 jar 包是谁启动的,虽然没什么技术含量,但体验好。
第三个是数据模型抽象的重要性。我做这个项目最大的体会是,问卷调查系统的核心难点绝对不是 CRUD,而是如何用一套通用的数据模型去承载千变万化的题型和统计需求。一开始如果图省事,把每道题设计成表里的一个列,后面想加新题型、想做统计报表,全部推倒重来。后来我重构成了“问卷-题目-选项-答卷-明细”这五张表,新增题型时只需要加一个 type 枚举值,前端渲染器和统计 SQL 基本不用动。
写在最后的体会
如果你准备拿这个题目做项目,我的建议是不要一上来就堆砌功能。先把“创建问卷 → 发布 → 用户填写 → 查看统计”这条主干链路彻底跑通,再去考虑逻辑跳转、问卷导出 Excel、用 ECharts 画图表这些加分项。主干通了,这个项目的骨架就立住了,剩下的事情只是往上面添肉。
我个人在实际操作中的体会是,问卷统计和防重复提交是最值得提前设计好的两个点,前者决定了这套系统好不好用,后者决定了它是否真正可靠。遇到问题先查日志、再看数据库、最后看代码,这个排查顺序能帮你省下大量时间。这套方案我自己搭建和验证过多轮,你照着走,至少能避掉八成常见的坑。