基于SpringBoot的问卷调查管理系统:从数据库设计到防重提交全解析
发布时间:2026/9/16 2:33:29 作者:尧图编辑部 阅读量:1,286

简介基于SpringBoot框架的问卷调查管理系统源码与数据库属于高分开源毕业设计项目评审获得九十九分。面向计算机相关专业正在准备毕业设计、课程设计或期末大作业的学生也适合需要项目实战练习的学习者。压缩包共三百七十五个文件包含两百六十七个交互脚本、三十七个业务逻辑类、三十四个样式表、十二个页面以及SQL数据库脚本等整体仅2.25MB目录结构清晰便于按模块查阅。项目代码完整、可直接运行覆盖问卷创建、发布、填写、统计等核心流程数据库表设计规范并附初始化脚本方便快速部署。前端使用响应式组件构建页面后端提供清晰的业务接口能帮助读者理解SpringBoot开发模式与前后端协作方式。已有一百零一人学习下载对于需要快速搭建问卷系统或借鉴项目结构的学习者具有较高参考价值。1. 基于SpringBoot的问卷调查管理系统真正的分水岭在答卷存储问卷调查管理系统在课程设计里出现频率极高绝大多数人把SpringBoot源码跑起来、数据库导进去就算交差。但答辩时真正拉开差距的往往是两个问题多选答案怎么统计、同一人重复提交怎么拦。这两个问题都绕开SpringBoot本身落在数据库模型和事务边界上。你当然可以把整份答卷塞进一个大JSON字段写代码时很痛快可一旦要按选项分组统计就得把解析逻辑写进Java数据库索引也用不上。这篇文章以“基于SpringBoot的问卷调查管理系统源码数据库”这种典型项目为对象按我搭课设的习惯拆解先设计问卷、题目、选项、答卷四层表再写SpringBootMyBatis的发布与提交接口最后给出防重、初始化和演示验证。适合正在找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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 同一人一份问卷只允许答一次 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷明细表;表建完后几个关键点要理解。quiz 表里的 status 和 start_time 做了联合索引后台按状态筛选时不会全表扫描answer 表上的 uk_quiz_user 是防重复提交的第一道物理屏障answer_item.option_id 我没有用整数类型而是故意设计成 varchar(255)因为多选题需要保存多个选项ID用逗号拼接后查询更快也比再拆一张映射表少两次关联。这里需要提醒一下多选存逗号串会让数据库第二范式选择退让但对一套演示性质的管理系统来说读写性能收益远大于理论代价。2.2 问卷状态字段与发布流程的数据库约束状态字段如果只有status发布流程很容易出现并发问题。比如两个管理员同时点击“发布”两个请求都读到status0然后都执行更新最后把同一份问卷发了两遍。常见做法是给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.xJDK版本8或1117包名javax.servletjakarta.servletMyBatis-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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue 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 BaseMapperQuiz { 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 LambdaQueryWrapperQuestion().eq(Question::getQuizId, quizId)); if (questionCount null || questionCount 0) { throw new BizException(问卷没有题目不能发布); } int rows quizMapper.update(null, new LambdaUpdateWrapperQuiz() .eq(Quiz::getId, quizId) .eq(Quiz::getStatus, 0) .set(Quiz::getStatus, 1)); return rows 1; }先查题目数再用status0作为乐观条件执行更新。如果两个请求同时进来数据库的行锁会保证只有一个请求的更新影响一行另一个影响0行于是后一个请求拿到的rows为0接口返回发布失败。这里的Transactional注解必须写rollbackForException.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里直接做分页查询列表带上状态筛选和按创建时间倒序。PageQuiz page new Page(current, size); LambdaQueryWrapperQuiz wrapper new LambdaQueryWrapper(); wrapper.eq(Quiz::getStatus, status) .orderByDesc(Quiz::getCreateTime); IPageQuiz 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放入ThreadLocalController中拿当前用户直接取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会抛回DuplicateKeyExceptionSpringBoot的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层这样处理MapLong, 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演示时改回INFOMySQL max_connections200避免多次重启后连接耗尽日志这个参数特别有用把mapper包日志级别临时调成DEBUG肉眼能看到MyBatis执行了哪条SQL。如果你发现某个接口查了N次数据库那多半就是N1。演示前开DEBUG跑一遍比背源码管用。5.3 演示前必跑的3个冒烟场景管理员登录后台新建问卷添加至少一道单选题和一道多选题点击发布再刷新看状态是否变成“进行中”。这一步验证的是事务和状态更新是否正确。使用两个不同用户填答同一份问卷提交时多选勾选不同组合。进入统计页对比两个用户的数据是否都出现在结果里尤其检查多选题的选项计数是否和你勾选的数量一致。用同一个用户再次提交这份问卷确认接口返回“你已经提交过这份问卷”。然后把问卷关闭用新用户填答确认接口返回“不在填答期”。这三个场景分别覆盖了乐观锁、明细表写入、防重和状态校验。全部通过后再把日志级别调到DEBUG执行一次提交确认只出现了预期数量的INSERT语句没有额外SELECT。就这样把SpringBoot的日志当成调试探针你的问卷调查系统在演示时就不会被一句“你是怎么统计多选的”卡住。本文还有配套的精品资源点击获取