
做一个基于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我的观点是问卷系统的统计场景复杂经常要写多表联查的 SQLMyBatis 的灵活性明显占优。而纯 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注意加上useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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 版本JDKMyBatis Plus 依赖本机是 JDK 8最常见2.7.xJDK 8mybatis-plus-boot-starter 3.5.x本机已是 JDK 173.2.xJDK 17mybatis-plus-spring-boot3-starter 3.5.5需要 JDK 21 演示最新特性3.3.xJDK 21mybatis-plus-spring-boot3-starter 3.5.7顺带说一句SpringBoot 2.X 之后默认使用 CGLIB 代理不像早期版本那样优先用 JDK 动态代理。这意味着你的 Service 实现类不需要强制写接口也能被代理这种约定在写了很多年后回头看确实是省事的但面试官如果问起来你可别说不知道。3. 数据库表设计打地基3.1 五张核心表表设计是整个项目里最值得花心思的部分。我的方案是五张表问卷表、题目表、选项表、答卷主表、答卷明细表。这五张表构成了一个典型的主从模型既支持灵活的题型组合又方便后续统计。问卷表survey的字段字段类型说明idbigint主键titlevarchar(200)问卷标题descriptiontext问卷说明statustinyint0-草稿 1-发布中 2-已结束start_timedatetime发布时间end_timedatetime截止时间deletedtinyint逻辑删除标记题目表survey_question字段类型说明idbigint主键survey_idbigint所属问卷typetinyint1-单选 2-多选 3-判断 4-简答 5-评分titlevarchar(500)题干requiredtinyint是否必答sort_orderint排序号控制展示顺序score_maxint评分题的分值上限选项表survey_option字段类型说明idbigint主键question_idbigint所属题目contentvarchar(500)选项内容sort_orderint排序号答卷主表survey_answer字段类型说明idbigint主键survey_idbigint问卷IDuser_idvarchar(64)提交人标识匿名问卷可为空ipvarchar(50)提交IPsubmit_timedatetime提交时间答卷明细表survey_answer_detail字段类型说明idbigint主键answer_idbigint答卷主表IDquestion_idbigint题目IDoption_idsvarchar(255)选择的选项ID多选用逗号分隔answer_texttext简答题的文本内容scoreint评分题的分数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-formatyyyy-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 画图表这些加分项。主干通了这个项目的骨架就立住了剩下的事情只是往上面添肉。我个人在实际操作中的体会是问卷统计和防重复提交是最值得提前设计好的两个点前者决定了这套系统好不好用后者决定了它是否真正可靠。遇到问题先查日志、再看数据库、最后看代码这个排查顺序能帮你省下大量时间。这套方案我自己搭建和验证过多轮你照着走至少能避掉八成常见的坑。