做后台开发这些年,我经手过不少业务系统,问卷调查管理系统算是一个麻雀虽小但五脏俱全的典型项目。基于SpringBoot来搭这一套,几乎成了Java方向毕设和内部工具系统的标配,因为它的业务链路完整——从问卷创建、题目配置、发布回收,到用户填写、数据统计,每一环都能讲出设计点,又不像电商那样复杂到劝退新人。我最近整理了一套完整的“基于SpringBoot的问卷调查管理系统”源码,顺手把部署文档和代码讲解也补齐了,这篇就把整个实践过程掰开揉碎,从技术选型、数据库设计、核心逻辑,到服务器部署、常见坑排查,一条线写清楚。不管你是拿它做毕业设计,还是想在团队内部快速搭一套轻量问卷工具,都有可以直接抄作业的部分。
1. 项目定位与整体设计思路
1.1 为什么用SpringBoot做问卷系统
先说结论:SpringBoot不是功能最强的,却是这个场景下综合成本最低的。
问卷调查系统的核心诉求是“快速搭建、稳定跑起来、能改能扩”,它没有复杂的实时交互,也没有海量并发的硬指标,绝大多数场景就是几百上千人同时填问卷,数据量撑死几万条。SpringBoot的自动装配让配置量直线下降,内嵌Tomcat让部署变成一个java -jar命令的事,这对小团队和单机部署来说极其友好。
我做这套系统时也纠结过要不要上微服务、要不要拆模块,后来想清楚了:业务边界就那么大,硬拆只会徒增维护成本。最终定下来的方案是单体应用 + 分层架构,后续如果问卷量爆炸,优先做缓存和索引优化,而不是急着拆服务,这一条对所有中小型系统都适用。
1.2 系统功能与模块边界
这套问卷调查管理系统覆盖了一整套业务闭环,功能拆成四个核心模块:
- 问卷管理:创建问卷、编辑题目、设置问卷状态(草稿、发布中、已结束)、复制问卷。
- 用户填写:按问卷链接或问卷码进入,作答后提交答卷,系统做幂等控制防止重复提交。
- 数据统计:按问卷维度统计回收量、各题选项分布、填空题答案列表,并支持导出Excel。
- 系统管理:维护管理员账号、查看操作日志,这块是给后台运维用的。
这四个模块合起来,基本就是一个商用问卷工具的MVP版本。没有做权限细化到角色那种复杂设计,而是用最直接的管理员登录态控制后台操作,前台填写完全开放,这也是大多数内部问卷系统的通行做法。
1.3 这套系统适合谁
如果你正在准备Java方向的毕业设计,这套系统能让你在答辩时有足够的内容可讲——SpringBoot核心机制、MyBatis数据访问、前端模板渲染、权限控制、AOP日志,每一个点都能展开。如果你是团队里负责内部工具的开发者,它也能直接用,把问卷配置好丢给同事填,后台导出数据就完事。
当然,它不适合拿去跟问卷星这类商业产品正面竞争,功能深度和并发能力都差着量级,但作为一套学习范式和内部工具,够用了。
2. 技术选型与项目结构详解
2.1 技术选型背后的取舍
整套系统的技术栈如下:
| 技术组件 | 选型版本 | 选型理由 |
|---|---|---|
| JDK | 1.8 / 11 | 稳定且兼容性最好,服务器部署不会遇到版本兼容噩梦 |
| SpringBoot | 2.7.x | 2.x系兼容性和资料丰富度最佳,避免3.x新特性带来的坑 |
| MyBatis | 2.x | SQL可控性强,统计类复杂查询更好调优 |
| MySQL | 5.7+ | 通用性最强,免费,资料多 |
| Thymeleaf | 3.x | 后台管理端直接用服务端模板渲染,简单直接 |
| Bootstrap + jQuery | 5.x / 3.x | 管理端UI,不用引入重型前端框架 |
| 原生HTML + AJAX | — | 前台问卷填写页,轻量快速 |
| EasyExcel / POI | 3.x | Excel导入导出 |
| Druid | 1.x | 数据库连接池 + 监控页面 |
为什么后台管理端不用Vue或React?这里很多人会踩坑。问卷管理后台的交互复杂度和数据实时性要求都不高,用Thymeleaf服务端渲染开发效率极高,一个Controller返回视图名就把页面跳转解决了,不用额外搭Node环境、不用处理跨域,对单体应用来说这是最优解。如果问卷填写页用Vue则同理,它其实就是一个页面+接口,AJAX请求提交JSON数据就够了,不需要引入完整的构建链路。
2.2 项目目录结构约定
源码的包结构沿用了我多年固定的分层习惯,清晰到看一眼就知道代码在哪:
com.example.survey ├── controller # 接口层,接收请求、返回结果 ├── service # 业务层,核心逻辑都在这 │ └── impl ├── mapper # MyBatis数据访问层,纯接口 ├── entity # 数据库实体类 ├── dto # 数据传输对象,避免实体直接暴露给前端 ├── vo # 视图对象,按需组装返回给页面的数据 ├── config # 配置类,拦截器、跨域等 ├── interceptor # 登录拦截器 ├── aspect # AOP切面,写日志用 ├── common # 统一返回结果、异常处理、工具类 └── SurveyApplication.java这里有一个容易被忽视的规范问题:实体类(entity)和视图对象(vo)必须分开。很多人图省事直接拿实体给前端返回,结果密码字段漏出去、多余字段暴露,后面怎么改都别扭。我在这个项目里用VO做了前端数据的二次封装,接口返回什么都由VO决定,安全性可控很多。
2.3 核心依赖与版本兼容建议
pom.xml里的依赖不用全列出来,但有几个关键点值得提醒:
- SpringBoot的父级版本和MyBatis starter版本一定要匹配,我遇到过2.7.x的SpringBoot配了1.3.x的mybatis-spring-boot-starter,结果自动装配失败,报找不到SqlSessionFactory。
- 数据库驱动坐标在SpringBoot 2.x里用
com.mysql:mysql-connector-j,如果你还在用老的mysql:mysql-connector-java,部分版本会报警告但不影响运行,技术上没大问题,但建议统一。 - Lombok建议加上,实体类的getter/setter/toString靠注解搞定,代码量少一大截,而且是编译期处理,对运行无影响。
- 分页插件pagehelper直接引入
com.github.pagehelper:pagehelper-spring-boot-starter,一行配置就能用,比手写limit强在不用每写一条查询都算页码。
3. 数据库设计是这套系统的地基
3.1 核心数据表与字段解析
问卷系统的数据库设计准确说就围绕一个核心思想:问卷-题目-选项-答卷,四层结构拆开存。我设计了五张核心表,建表SQL可以直接用到你的项目里:
-- 问卷表 CREATE TABLE `survey` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '问卷标题', `description` text COMMENT '问卷描述', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0草稿 1发布中 2已结束', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', `questionnaire_code` varchar(32) DEFAULT NULL COMMENT '问卷码', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, `deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '逻辑删除', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问卷表'; -- 题目表 CREATE TABLE `question` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `survey_id` bigint(20) NOT NULL COMMENT '所属问卷ID', `type` tinyint(4) NOT NULL COMMENT '1单选 2多选 3简答', `stem` varchar(500) NOT NULL COMMENT '题干', `sort_order` int(11) NOT NULL DEFAULT '0' COMMENT '排序', `required` tinyint(4) NOT NULL DEFAULT '1' COMMENT '是否必答', PRIMARY KEY (`id`), KEY `idx_survey_id` (`survey_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表'; -- 选项表 CREATE TABLE `option` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `question_id` bigint(20) NOT NULL COMMENT '所属题目ID', `option_text` varchar(300) NOT NULL COMMENT '选项内容', `sort_order` int(11) NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `idx_question_id` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选项表'; -- 答卷表 CREATE TABLE `answer_sheet` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `survey_id` bigint(20) NOT NULL, `user_key` varchar(64) DEFAULT NULL COMMENT '填写人标识,如IP+浏览器指纹', `submit_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_survey_id` (`survey_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷表'; -- 答卷明细表 CREATE TABLE `answer_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `answer_sheet_id` bigint(20) NOT NULL, `question_id` bigint(20) NOT NULL, `option_ids` varchar(300) DEFAULT NULL COMMENT '选中的选项ID,逗号分隔', `answer_text` text COMMENT '简答题答案', PRIMARY KEY (`id`), KEY `idx_sheet_id` (`answer_sheet_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷明细表';这套设计的核心思路是:题目和选项不冗余存在问卷表里,而是各自独立成表,用外键关联。好处是统计时可以直接对option表做分组聚合,要改题目也只动question表,不动survey表。
3.2 状态设计与逻辑删除
状态字段我用的是数字枚举值,没有用字符串。有人喜欢用字符串如draft、published这样直观,但数字在数据库存储上更省空间、索引效率更高,代码里用枚举类去做语义映射,可读性一样有保障。
逻辑删除是我特别提的一个点,也是很多项目会忽略的设计。问卷数据是有统计价值的资产,物理删除一旦误操作就找不回来。我在每个核心表都加了deleted字段,所有查询默认带deleted = 0条件,删除操作实际上执行的是UPDATE而不是DELETE。MyBatis里我会建一个公用的SQL片段避免每个查询都重复写这句条件。
3.3 索引与外键取舍
数据库设计中一个常见争议是用不用物理外键。我的习惯是:业务开发中不使用物理外键,只保留逻辑关联和普通索引。
原因很现实:物理外键会让插入和更新的性能下降,而且一旦数据量大起来,外键约束排查问题非常费劲。数据一致性完全可以在service层通过事务控制保证。所以我在表结构里只建了普通索引,比如idx_survey_id,查询题目列表和统计答卷时靠它加速。
另外要提醒的是,option_ids这个字段我故意设计成了逗号分隔的字符串,这在外行眼里是“反范式设计”,但它在这个场景里是合理的权衡。多选题的答案天然就是一对多关系,如果强行拆行存,统计和展示都要多做一次聚合,反而复杂。只要题目选项数量有限,字符串存ID列表完全够用,查询时用FIND_IN_SET或其他方式处理也不慢。如果你想做得更规范,可以拆成关联表,但那是另一套复杂度。
4. 核心功能实现与代码讲解
4.1 问卷CRUD与状态流转
问卷管理后台的Controller层很薄,真正的逻辑在Service里。以发布问卷为例,这个操作不是简单改一个状态字段就完事,还包含一系列校验:问卷下至少有一道题目,题目内容不能为空,结束时间必须晚于当前时间。我把这些校验统一抽到validateSurvey()方法里,发布前调用,草稿保存时不做校验,因为用户可能只填了一半就去保存。
状态流转用代码约束死:
public Boolean publishSurvey(Long surveyId) { Survey survey = surveyMapper.selectById(surveyId); if (survey == null || survey.getDeleted() == 1) { throw new BusinessException("问卷不存在"); } if (survey.getStatus() == SurveyStatus.PUBLISHED.getCode()) { throw new BusinessException("问卷已发布,请勿重复操作"); } // 校验题目数量 int questionCount = questionMapper.countBySurveyId(surveyId); if (questionCount == 0) { throw new BusinessException("问卷下至少需要一道题目"); } survey.setStatus(SurveyStatus.PUBLISHED.getCode()); surveyMapper.updateById(survey); return true; }这种状态校验放在service层而不是controller层的用意是:controller只负责参数接收和路由,如果业务校验散落在多个接口里,状态流转的规则就不够集中,后面有人改代码容易漏掉某种状态组合。
4.2 题目类型的统一抽象
选择题和简答题的处理逻辑差异很大,但我用类型字段统一管理,而不是建多张表分表存。题目类型用type字段区分:1单选、2多选、3简答。
前端页面的处理是,加载问卷时先获取全部题目,再根据题目类型动态渲染不同的交互组件。单选渲染radio,多选渲染checkbox,简答渲染textarea。这里有一个我踩过的坑:单选题的name属性必须是题号,如果所有单选题都用同一个name,浏览器会把它们当成一组,选A题会影响B题。一定要用name="question_${id}"这种方式做隔离。
后端接收答案时,也按类型分别处理:
if (question.getType() == QuestionType.SINGLE_CHOICE.getCode()) { // 单选只允许一个选项ID,直接转int answerDetail.setOptionIds(singleOptionId.toString()); } else if (question.getType() == QuestionType.MULTIPLE_CHOICE.getCode()) { // 多选是数组,拼接成"1,3,5" answerDetail.setOptionIds(StringUtils.join(multiOptionIds, ",")); } else { // 简答存文本 answerDetail.setAnswerText(answerText); }核心思想就一个:答案的存储结构统一,但解析逻辑按类型分支处理。这样统计模块就能通过一个统一的入口拿到所有答案数据,再做二次计算。
4.3 答卷提交与幂等控制
问卷提交最怕的是用户手滑点了两次提交按钮,或者网络超时后前端重试,导致同一份答卷被插了两条。我在后端做了两层控制:
第一层,前端提交按钮在发送请求后立即置灰,禁止重复点击,这是体验层面的兜底。第二层,后端在提交接口里做了用户标识查重,同一用户对同一问卷只能提交一次:
// 用户标识:优先取登录用户ID,否则用IP+User-Agent哈希 String userKey = buildUserKey(request); int exists = answerSheetMapper.countBySurveyIdAndUserKey(surveyId, userKey); if (exists > 0) { throw new BusinessException("您已经提交过该问卷,请勿重复提交"); }这个方案简单可靠,虽然极端情况下会有并发穿透,但对问卷系统这种低频并发场景完全够用。如果你要更高强度,可以在answer_sheet表加上(survey_id, user_key)的唯一索引,用数据库层面兜底。
提交的整个动作放在一个事务里,答卷表插一条主记录,明细表循环插入多条子记录,任何一条失败全部回滚:
@Transactional(rollbackFor = Exception.class) public void submitAnswer(SubmitDTO dto, HttpServletRequest request) { // 插入答卷主记录 // 循环插入明细记录 }4.4 统计报表的实现思路
统计模块的SQL是这个项目里最能体现MyBatis优势的地方。问卷回收量是count,单选题做分组统计,多选题要先拆分再统计。多选的统计我推荐的做法是:先查出该题所有答案的option_ids,在Java代码里拆分成列表再计数,而不是硬写SQL。
为什么不用SQL直接拆?因为MySQL没有内置split函数,写起来要么用SUBSTRING_INDEX循环处理,要么用JSON函数,性能和可维护性都差。数据量在几千条时,纯Java处理毫秒级完成,不构成性能瓶颈。强行炫技反而给自己留坑。
单选题最适合用SQL一步搞定:
SELECT option_id, COUNT(*) AS count FROM answer_detail WHERE question_id = #{questionId} GROUP BY option_id查出结果后,再到option表把选项文本补上,就能拼出前端图表需要的结构。我在管理后台直接用了ECharts展示统计图表,后端只需要返回选项名和对应数量,前端配置一个饼图或柱状图即可,工作量很小。
5. 部署文档与实操踩坑全记录
5.1 开发环境准备与关键配置
这套系统的开发环境需要JDK 8+、Maven 3.6+、MySQL 5.7+和一个趁手的IDE。环境装好后,先把数据库建出来:
mysql -u root -p CREATE DATABASE survey_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后导入项目里sql目录下的初始化脚本,注意脚本里包含了建表和初始管理员数据。
核心配置文件在src/main/resources/application.yml:
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/survey_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 你的密码 type: com.alibaba.druid.pool.DruidDataSource thymeleaf: cache: false mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.survey.entity configuration: map-underscore-to-camel-case: true这里三个配置项是最容易出问题的:
serverTimezone=Asia/Shanghai必须加,否则数据库连接会报时区错误,或者时间字段少8小时。characterEncoding=utf8必须加,否则读中文会乱码。map-underscore-to-camel-case: true建议加,它能自动将数据库的create_time映射到实体的createTime字段,省掉大量resultMap配置。
5.2 打包构建与jar部署
开发调试完成后,要部署到服务器,核心就是用Maven打包成可执行的jar文件。我推荐直接用IDEA右侧Maven面板,双击package,打包前先跑clean,避免旧的class文件干扰。
打包完成后,target目录下会生成一个survey-system-0.0.1-SNAPSHOT.jar,这个jar包含了内嵌的Tomcat,传到服务器后用一条命令就能启动:
java -jar survey-system-0.0.1-SNAPSHOT.jar想让它在后台持续运行,用nohup命令:
nohup java -jar survey-system-0.0.1-SNAPSHOT.jar > survey.log 2>&1 &查看实时日志用:
tail -f survey.log停在后台启动的方式也有讲究,> survey.log 2>&1把标准输出和错误输出都重定向到日志文件里,排查问题时全靠这个文件。别小看这一步,我第一次部署时少写2>&1,报错信息直接丢到终端看不到,排查了很久。
如果想随服务器启动自动运行,推荐用systemd写一个服务文件,这样就算进程意外退出,也可以自己拉起来。
5.3 Nginx反向代理与静态资源配置
生产环境我一般会在jar前面再挂一层Nginx,主要做三件事:端口转发把80端口转到8080、缓存静态资源、统一处理跨域。一个最简配置如下:
server { listen 80; server_name your.domain.com; location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg)$ { proxy_pass http://127.0.0.1:8080; expires 7d; } }注意proxy_set_header X-Real-IP $remote_addr必须配,不然后端通过request.getRemoteAddr()拿到的都是Nginx的IP 127.0.0.1,这会导致我之前做的用户提交幂等判断全部失效,因为每个用户看起来IP都一样,第一个人提交后其他人就都提交不了了。这个坑非常隐蔽,不配Nginx时本机环境根本发现不了。
5.4 服务器部署避坑清单
我把部署过程中遇到的典型问题和解决方案整理成了一张表:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 页面中文乱码 | 数据库连接URL没加characterEncoding | 在url上加characterEncoding=utf8 |
| 时间字段少了8小时 | 连接时区没指定 | url加上serverTimezone=Asia/Shanghai |
| 前端提交问卷一直转圈 | 接口405或500 | 看后台日志,多半是参数名不对或类型转换失败 |
| 无法连接MySQL | 数据库权限或端口没开 | 检查bind-address和防火墙,给用户授权'user'@'%' |
| jar包启动后过几秒自动退出 | 端口占用或配置错误 | 看日志的开头有没有APPLICATION FAILED TO START |
| Nginx 502 Bad Gateway | 后端jar没启动或proxy_pass配置错 | 确认8080端口能通,curl http://127.0.0.1:8080 |
| 提交问卷提示重复 | 同一IP多人填写,且用了IP做用户标识 | 改用IP+浏览器指纹哈希,或放宽为同一IP+N分钟内限制一次 |
6. 常见问题排查与代码学习建议
6.1 部署期高频问题的排查思路
部署类问题有一个通用的排查顺序:先看日志,再看端口,再看网络,最后看权限。很多人一遇到问题就改代码,这是错误路径。日志永远是最快定位问题的入口。
端口占用的问题也频繁出现。如果你8080端口被其他程序占了,要么改端口,要么杀掉占用进程。Linux上查端口占用:
netstat -tlnp | grep 8080查到PID后直接kill -9即可,或者干脆从源头解决,把Nginx里映射的端口换一个空闲的。
数据库连接问题更常见的是权限不对。本地能连,服务器上连不上,绝大多数是MySQL用户授权问题,SQL执行一下:
GRANT ALL PRIVILEGES ON survey_system.* TO 'root'@'%' IDENTIFIED BY '密码'; FLUSH PRIVILEGES;这里要提醒:生产环境不建议直接用root远程连数据库,更安全的做法是建一个独立的账号,只授权这个库的最小权限。安全意识从开发期就养成,省得后面出事故。
6.2 代码学习与二次开发的建议
拿到这套源码,建议不要从Controller看起,而是按数据流向看:先看数据库表结构,再看entity实体,然后看mapper接口和XML,理解数据怎么读写,最后看service和controller,理解业务逻辑怎么编排。
有几个核心文件建议精读:
SurveyController.java,看一个完整业务模块的接口怎么写。AnswerSheetServiceImpl.java,看事务和幂等控制怎么实现。StatisticServiceImpl.java,看统计类的聚合逻辑怎么组织。LoginInterceptor.java,看登录态校验怎么做,注意哪些路径放行哪些拦截。
二次开发时最常改的点是:统计维度扩展、问卷模板复用、题目类型增加新玩法(比如评分题、排序题)。评分题本质上可以看作单选的变体,可以复用单选的结构,只是渲染和统计的逻辑不同。
6.3 一套代码的边界:什么场景需要升级方案
最后泼一点冷水。这套系统在内部使用场景非常合适,但如果要做成SaaS产品,或者预计单问卷会收集几十万份答卷,有几个点就必须重新设计:option_ids字符串存多选的方案要拆成明细行或JSON,统计逻辑要支持异步计算或跑批,文件导出要异步化,前端要换成Vue/React + 构建链路。真要走到那一步,就不是搭个demo的事,但本项目的分层和思路可以给你提供一个相对清晰的升级路径。
我在实际使用中发现,这套代码最重要的是把“够用就好”的边界守住了,没有为了炫技引入一堆不必要的复杂度。每次有人拿它当毕设改需求,我也建议先想清楚哪些地方是锦上添花,哪些是画蛇添足,改代码之前先改思路,比什么都重要。