news 2026/9/16 2:33:22

基于SpringBoot的问卷调查管理系统:从数据库设计到防重提交全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于SpringBoot的问卷调查管理系统:从数据库设计到防重提交全解析

简介:基于SpringBoot框架的问卷调查管理系统源码与数据库,属于高分开源毕业设计项目,评审获得九十九分。面向计算机相关专业正在准备毕业设计、课程设计或期末大作业的学生,也适合需要项目实战练习的学习者。压缩包共三百七十五个文件,包含两百六十七个交互脚本、三十七个业务逻辑类、三十四个样式表、十二个页面以及SQL数据库脚本等,整体仅2.25MB,目录结构清晰,便于按模块查阅。项目代码完整、可直接运行,覆盖问卷创建、发布、填写、统计等核心流程;数据库表设计规范,并附初始化脚本,方便快速部署。前端使用响应式组件构建页面,后端提供清晰的业务接口,能帮助读者理解SpringBoot开发模式与前后端协作方式。已有一百零一人学习下载,对于需要快速搭建问卷系统或借鉴项目结构的学习者,具有较高参考价值。

1. 基于SpringBoot的问卷调查管理系统,真正的分水岭在答卷存储

问卷调查管理系统在课程设计里出现频率极高,绝大多数人把SpringBoot源码跑起来、数据库导进去就算交差。但答辩时真正拉开差距的往往是两个问题:多选答案怎么统计、同一人重复提交怎么拦。这两个问题都绕开SpringBoot本身,落在数据库模型和事务边界上。你当然可以把整份答卷塞进一个大JSON字段,写代码时很痛快,可一旦要按选项分组统计,就得把解析逻辑写进Java,数据库索引也用不上。这篇文章以“基于SpringBoot的问卷调查管理系统源码+数据库”这种典型项目为对象,按我搭课设的习惯拆解:先设计问卷、题目、选项、答卷四层表,再写SpringBoot+MyBatis的发布与提交接口,最后给出防重、初始化和演示验证。适合正在找SpringBoot源码参考的同学,也适合想让自己的项目从“能跑”变成“能讲”的工程师。

2. 问卷调查系统的数据库设计:先建四张表再写SpringBoot代码

2.1 核心表拆解:问卷、题目、选项、答卷明细一张不能少

数据库设计是这种项目管理系统的地基。我的习惯是先建问卷表(quiz)、题目表(question)、选项表(option)、答卷主表(answer)和答卷明细表(answer_item),再回过来写实体类。选项表单独建而不是把选项存成JSON,是为了能在数据库里直接做选项维度的统计分析,也方便后端做选项批量维护。

以下是一组可直接执行的建表SQL,我用的是MySQL 8,字符集固定utf8mb4。

CREATE TABLE quiz ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '问卷ID', title VARCHAR(100) NOT NULL COMMENT '问卷标题', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1发布 2关闭', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', start_time DATETIME DEFAULT NULL COMMENT '可见开始时间', end_time DATETIME DEFAULT NULL COMMENT '可见结束时间', created_by BIGINT NOT NULL COMMENT '创建人ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status_time (status, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问卷表'; CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, quiz_id BIGINT NOT NULL COMMENT '所属问卷ID', sort_no INT NOT NULL DEFAULT 0 COMMENT '排序号', type TINYINT NOT NULL COMMENT '1单选 2多选 3填空', content VARCHAR(500) NOT NULL COMMENT '题干', required TINYINT NOT NULL DEFAULT 1 COMMENT '是否必答', KEY idx_quiz (quiz_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表'; CREATE TABLE answer ( id BIGINT PRIMARY KEY AUTO_INCREMENT, quiz_id BIGINT NOT NULL COMMENT '问卷ID', user_id BIGINT NOT NULL COMMENT '填答用户ID', submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_quiz_user (quiz_id, user_id) COMMENT '同一人一份问卷只允许答一次' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷主表'; CREATE TABLE answer_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, answer_id BIGINT NOT NULL COMMENT '答卷主表ID', question_id BIGINT NOT NULL COMMENT '题目ID', option_id VARCHAR(255) DEFAULT NULL COMMENT '单选存一个选项ID,多选存逗号分隔的ID串', text_value VARCHAR(500) DEFAULT NULL COMMENT '填空题答案', KEY idx_answer (answer_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷明细表';

表建完后,几个关键点要理解。quiz 表里的 status 和 start_time 做了联合索引,后台按状态筛选时不会全表扫描;answer 表上的 uk_quiz_user 是防重复提交的第一道物理屏障;answer_item.option_id 我没有用整数类型,而是故意设计成 varchar(255),因为多选题需要保存多个选项ID,用逗号拼接后查询更快,也比再拆一张映射表少两次关联。这里需要提醒一下,多选存逗号串会让数据库第二范式选择退让,但对一套演示性质的管理系统来说,读写性能收益远大于理论代价。

2.2 问卷状态字段与发布流程的数据库约束

状态字段如果只有status,发布流程很容易出现并发问题。比如两个管理员同时点击“发布”,两个请求都读到status=0,然后都执行更新,最后把同一份问卷发了两遍。常见做法是给quiz表加version乐观锁,发布时把status和version一起更新,影响行数为1才表示发布成功。

UPDATE quiz SET status = 1, version = version + 1 WHERE id = #{quizId} AND status = 0;

如果影响行数等于0,说明问卷已经被其他请求发布,后端直接返回“问卷已发布,请勿重复操作”。除了状态,发布前还要校验两件事:start_time 必须早于 end_time,以及该问卷下至少存在一条有效题目。没有题目的问卷一旦发布,填答方会看到一张空问卷,这种问题在演示现场非常尴尬。校验放在Service层即可,用SpringBoot的@Transactional包裹,先查题目数,再执行上面的更新。

2.3 答卷明细表:关系表为主、JSON快照为辅的混合方案

有同学会问,既然选项和答案都落在表上,为什么还要留JSON字段?核心原因是题目和选项在问卷发布后可能被修改,而答卷应该是对“提交时刻问卷内容”的事实记录。关系明细表适合统计,JSON快照适合回显,两者各有分工。我见过的高分源码里,大多数最终选择了混合方案:把答案主体放answer_item,同时在answer主表上留一个snapshot_json字段存放整份答卷的原始快照。只做课程设计时,你可以先不加这个字段,让模型更简单。

方案优点缺点适用场景
纯关系明细表统计SQL好写,事务回滚容易处理问卷改题后历史答卷不可完全还原教学项目、时间紧张
纯JSON快照写入快、回显简单无法用数据库做选项维度统计小流量快速问卷
关系表+JSON快照统计与追溯兼顾双写冗余,需要保证一致性正式产品、高分论文

回到代码里,我一般会把answer_item的answer_id建立索引,因为统计时最常见的查询是“取某一份答卷的所有答案”或“按选项聚合”。后面第3章的提交接口里,多选选项就是拼成1,3,5这样的串写入 option_id,单选则只写一个ID。这样写虽然让option_id字段不再严格对应一行,但至少让answer_item表保持了一张明细表该有的数量级,统计接口可以在Java里把逗号串展开,避免表连接过深。

3. SpringBoot 后端接口实现:从创建项目到提交答卷的完整链路

3.1 创建SpringBoot项目的最小配置:版本、依赖和数据源

用SpringBoot写这种管理系统,最稳的组合是SpringBoot 2.7.x + JDK8 + MySQL8。SpringBoot 3.x虽然已经成熟,但包名从javax改成jakarta,很多网上旧源码直接粘贴会编译失败;如果你的课程设计必须用JDK17,才往3.x走。表单一对比就清楚:

环境项SpringBoot 2.7SpringBoot 3.x
JDK版本8或1117
包名javax.servletjakarta.servlet
MyBatis-Plus兼容3.5.x通用需要3.5.3以上

创建项目时只需要引入spring-boot-starter-web、mybatis-plus-boot-starter和mysql-connector-j即可。核心配置写在application.yml里,下面这段是能直接用的数据源配置:

spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/quiz_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 sql: init: mode: always schema-locations: classpath:sql/schema.sql >@Data @TableName("quiz") public class Quiz { @TableId(type = IdType.AUTO) private Long id; private String title; private Integer status; private Long createdBy; private LocalDateTime createTime; }

注意字段名create_time在数据库中带下划线,配置了map-underscore-to-camel-case后能自动映射成createTime。如果有些字段不想从Java侧插入,比如create_time由数据库默认值生成,可以在插入时用FieldStrategy.NOT_NULL或者直接在实体里不赋值。MyBatis-Plus的BaseMapper内置了insert、updateById、selectList等通用方法,所以隔行CRUD不需要手写XML。

一旦遇到统计类查询,我一般直接写在Mapper接口上。下面是一个查询问卷和题目数的例子:

public interface QuizMapper extends BaseMapper<Quiz> { @Select("SELECT q.*, " + "(SELECT COUNT(*) FROM question q2 WHERE q2.quiz_id = q.id) AS question_count " + "FROM quiz q WHERE q.id = #{id}") QuizVO selectWithQuestionCount(@Param("id") Long id); }

用子查询而不是JOIN,是因为JOIN会把quiz行扩展成多行,最后还要DISTINCT回来,子查询直接写入VO字段,既清楚又不会产生临时行膨胀。

3.3 发布问卷和提交答卷:事务边界是这两个接口的唯一难点

发布问卷接口逻辑不复杂,但要防并发。我在Service里这样写:

@Transactional(rollbackFor = Exception.class) public boolean publish(Long quizId) { Long questionCount = questionMapper.selectCount( new LambdaQueryWrapper<Question>().eq(Question::getQuizId, quizId)); if (questionCount == null || questionCount == 0) { throw new BizException("问卷没有题目,不能发布"); } int rows = quizMapper.update(null, new LambdaUpdateWrapper<Quiz>() .eq(Quiz::getId, quizId) .eq(Quiz::getStatus, 0) .set(Quiz::getStatus, 1)); return rows == 1; }

先查题目数,再用status=0作为乐观条件执行更新。如果两个请求同时进来,数据库的行锁会保证只有一个请求的更新影响一行,另一个影响0行,于是后一个请求拿到的rows为0,接口返回发布失败。这里的@Transactional注解必须写rollbackFor=Exception.class,因为SpringBoot默认只对RuntimeException回滚,当抛的是自定义BizException时,如果不继承RuntimeException,事务依然提交,脏数据就写进去了。

提交答卷接口是另一个必须用事务的地方。用户把整份答卷发到后端,后端先插answer主表,再循环插入answer_item明细。如果循环到第10题失败,前面9题已经插入,没有事务就会出现只有一半答案的脏答卷。下面是核心逻辑:

@Transactional(rollbackFor = Exception.class) public Answer submitAnswer(AnswerSubmitDTO dto) { Quiz quiz = quizMapper.selectById(dto.getQuizId()); if (quiz == null || quiz.getStatus() != 1) { throw new BizException("问卷不存在或不在填答期"); } Answer answer = new Answer(); answer.setQuizId(dto.getQuizId()); answer.setUserId(dto.getUserId()); answerMapper.insert(answer); for (AnswerItemDTO item : dto.getItems()) { AnswerItem record = new AnswerItem(); record.setAnswerId(answer.getId()); record.setQuestionId(item.getQuestionId()); if (item.getOptionIds() != null) { record.setOptionId(String.join(",", item.getOptionIds())); } record.setTextValue(item.getTextValue()); answerItemMapper.insert(record); } return answer; }

这里多选选项把List转换成逗号分隔的字符串,保证一个字段存完所有选项;单选时List里只有一个元素,效果与直接存ID相同。optionIds为空的填空题则走textValue。最终answer主表成功、明细表全部插入才是1次有效提交,任一步抛异常,数据库就回滚到提交前状态。

3.4 分页查询:问卷列表接口不做深分页

管理端需要列出问卷,用MyBatis-Plus分页插件最方便。引入PaginationInnerInterceptor后,在Service里直接做分页查询,列表带上状态筛选和按创建时间倒序。

Page<Quiz> page = new Page<>(current, size); LambdaQueryWrapper<Quiz> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Quiz::getStatus, status) .orderByDesc(Quiz::getCreateTime); IPage<Quiz> result = quizMapper.selectPage(page, wrapper);

在数据量只有几千行的课设项目里,这样足够了。但如果想写进简历,一定要注意深分页问题:这种selectPage底层会生成LIMIT offset, size,当offset达到百万级时性能骤降。更优做法是先把上一页最后一条ID传给SQL,用WHERE id < #{lastId} ORDER BY id DESC LIMIT #{size}来翻页。问卷表是低频表,十万条以内不需要太多优化,但面试官看到你能主动提这个边界,价值比把代码写成花大得多。

4. 源码里最容易被问倒的三个点:权限、防重、数据库初始化

4.1 管理员和填答者:一张用户表还是两张表

很多课设源码喜欢把用户分成管理员表和学生表,导致登录逻辑写两遍、用户信息同步全是坑。我接手过的SpringBoot问卷项目里,至少一半败在这个设计上。我的建议是:一张user表加上role字段,0表示管理员,1表示普通填答者。管理员能进问卷管理后台,普通用户只能填写和查看自己的答卷,接口层通过拦截器判断角色即可。

权限拦截器用SpringBoot拦截器实现,比引入Spring Security轻得多,也容易被老师讲清楚。核心代码如下:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } Long userId = JwtUtil.parseUserId(token); if (userId == null) { response.setStatus(401); return false; } UserContext.set(userId); return true; } }

拦截器里只做成功后把userId放入ThreadLocal,Controller中拿当前用户直接取UserContext.get(),不用每个接口都查一次用户表。角色校验就再写一个拦截器或注解,在admin开头的路径上拦截,逻辑是解析token后看role字段。为什么要绕开Spring Security?因为问卷管理系统的核心得分点是业务模型,安全框架写得太重,答辩时容易把问题引到你并不熟悉的过滤器链上。

4.2 防重复提交:数据库唯一键是底线,前端按钮锁只能打辅助

刚才在2.1的建表SQL里给answer表加了唯一键uk_quiz_user(quiz_id, user_id),这一步就是防重复提交的底线。前端在提交按钮被点击后disabled,确实能挡住大部分双击,但是用户刷新页面、连点回车、或者两个浏览器标签同时打开,前端锁就形同虚设。真正可靠的拦截发生在数据层。

提交接口里不需要先查一次是否已答,直接插入answer表:

try { answerMapper.insert(answer); } catch (DuplicateKeyException e) { throw new BizException("你已经提交过这份问卷,请勿重复提交"); }

因为唯一键冲突时MySQL会抛回DuplicateKeyException,SpringBoot的DataAccessException里已经映射好。这里必须注意一个细节:如果answer主表插入成功,唯一键已占用,后面的answer_item循环里又抛了异常,整个事务回滚,唯一键也随主表回滚而释放,所以不会出现“明明没答成却提示重复”的假象。这也是为什么提交接口必须和2.3节一样使用@Transactional。

如果项目里引入了Redis,可以在进入接口时执行setIfAbsent(key, 1, 5分钟)做互斥,效果更好,但课设里加不加都行。只是别把前端按钮disabled当作唯一的防重手段,面试官最爱追问“两个请求同时到达怎么办”,你回答唯一键,就过关了。

4.3 数据库初始化脚本:如何让“源码+数据库”开箱可跑

拿到一个课设项目,最影响第一印象的就是数据库能不能直接跑起来。SpringBoot 2.5以后提供了spring.sql.init,可以把schema.sql和data.sql放在classpath下,启动时自动建表、自动灌基础数据。前面3.1的application.yml里已经用到:

spring: sql: init: mode: always schema-locations: classpath:sql/schema.sql >SELECT question_id, option_id, COUNT(*) AS cnt FROM answer_item WHERE option_id NOT LIKE '%,%' GROUP BY question_id, option_id ORDER BY question_id, cnt DESC;

多选题如果要统计每个选项被选了多少次,正确的做法是把逗号串在Java里拆开,再按option_id累加;也可以把answer_item按逗号展开后做UNION,但SQL会复杂许多,演示时不划算。常见做法是在Service层这样处理:

Map<Long, Long> optionCount = new HashMap<>(); for (AnswerItem item : itemList) { if (item.getOptionId() != null) { for (String optId : item.getOptionId().split(",")) { optionCount.merge(Long.valueOf(optId), 1L, Long::sum); } } }

遍历时把每个选项ID拆分并累加,这既利用了数据库避免了一张中间表,也把多选统计的时间复杂度从O(n×m)降到O(n),其中n是答卷数,m是每道题的选项个数。

5.2 演示环境的3个关键参数:连接池、日志级别和MySQL连接数

演示现场最怕的是突然报Too many connections。SpringBoot默认的HikariCP连接池在2.x版本中默认最大连接数为10,六七个课程设计课代表同时打开页面就很容易打满。演示前把下面几个参数调成表格里的值:

参数建议值作用
spring.datasource.hikari.maximum-pool-size10控制应用侧连接上限
spring.datasource.hikari.minimum-idle3启动后保留空闲连接
logging.level.com.example.quiz.mapperDEBUG调试时打印SQL,演示时改回INFO
MySQL max_connections200避免多次重启后连接耗尽

日志这个参数特别有用:把mapper包日志级别临时调成DEBUG,肉眼能看到MyBatis执行了哪条SQL。如果你发现某个接口查了N次数据库,那多半就是N+1。演示前开DEBUG跑一遍,比背源码管用。

5.3 演示前必跑的3个冒烟场景

  1. 管理员登录后台,新建问卷,添加至少一道单选题和一道多选题,点击发布,再刷新看状态是否变成“进行中”。这一步验证的是事务和状态更新是否正确。
  2. 使用两个不同用户填答同一份问卷,提交时多选勾选不同组合。进入统计页,对比两个用户的数据是否都出现在结果里,尤其检查多选题的选项计数是否和你勾选的数量一致。
  3. 用同一个用户再次提交这份问卷,确认接口返回“你已经提交过这份问卷”。然后把问卷关闭,用新用户填答,确认接口返回“不在填答期”。

这三个场景分别覆盖了乐观锁、明细表写入、防重和状态校验。全部通过后,再把日志级别调到DEBUG执行一次提交,确认只出现了预期数量的INSERT语句,没有额外SELECT。就这样把SpringBoot的日志当成调试探针,你的问卷调查系统在演示时就不会被一句“你是怎么统计多选的”卡住。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 2:32:26

3个维度选中国做的手机系统下载网站哪家好

3个维度选中国做的手机系统下载网站哪家好 备案卡壳三天没动静,后台日志全是404,这种焦头烂额的感觉我太懂了。很多老板盯着【中国做的手机系统下载网站】这行字,心里其实没底:到底哪家技术栈稳?哪家SEO能落地?…

作者头像 李华
网站建设 2026/9/16 2:32:19

三维点云语义分割实战:从数据准备、模型选型到工程落地

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 2:32:11

骨传导耳机选购指南:解决耳道不适与环境安全刚需

1. 为什么骨传导耳机突然成了运动党、通勤族和办公族的“刚需”&#xff1f;最近三个月&#xff0c;我陆续收到二十多条私信&#xff0c;清一色问&#xff1a;“骨传导耳机到底值不值得买&#xff1f;”“南卡和韶音差在哪&#xff1f;”“戴眼镜的人能不能用&#xff1f;”——…

作者头像 李华
网站建设 2026/9/16 2:31:55

ABAP命名重构:从匈牙利前缀到意图驱动,让代码自解释

在ABAP代码评审会上&#xff0c;最常见的争执往往不来自业务逻辑&#xff0c;而来自命名方式。看着满屏的lv_value、mv_code、cv_flag&#xff0c;你总要回头翻赋值语句才明白它到底要表达什么。“类型前缀”并没有错&#xff0c;但在很多场景下它抢了主视觉&#xff0c;变量真…

作者头像 李华
网站建设 2026/9/16 2:31:48

链表设计精髓:从结构选型到面试高频题的完整解析

1. 为什么一个"老古董"结构至今还在面试里反复被考每次聊到数据结构&#xff0c;链表总是绕不开的话题。尤其是当你去刷面试题、看计算机基础八股文的时候&#xff0c;链表几乎场场都在。有人会觉得这玩意太基础了&#xff0c;不就是节点加指针吗&#xff0c;有什么好…

作者头像 李华
网站建设 2026/9/16 2:31:31

BCC不是Python库:eBPF内核观测框架深度解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华