
简介基于SpringbootVue的在线问卷调查系统设计与实现源码包面向计算机专业毕业设计学生及Java学习者适合作为课程设计、期末大作业或毕设项目。系统涵盖用户登录认证、问卷设计编辑、发布填写、数据收集分析与结果展示等完整功能前后端分离架构便于理解Springboot后端与Vue前端协作方式。压缩包共755个文件整体大小约18.28MB以java后端源码、vue前端组件、js逻辑、css样式、html页面为主另含sql数据库脚本、bat安装运行脚本、开发说明文档、部署视频与代码讲解视频等。项目经严格调试可开箱即用并提供全套配套材料方便快速部署与二次开发。目前已吸引446人学习下载。借助源码、数据库脚本及视频讲解读者既能掌握问卷调查系统的业务设计与实现细节也能积累前后端分离项目的实战经验是完成课设或毕设的有力参考。1. 在线问卷调查系统的设计与实现SpringBootVue 这套组合值不值得照抄基于 SpringbootVue 的在线问卷调查系统的设计与实现这个名字你在课程设计、毕业设计和中小团队内部工具里都能见到。它要解决的问题不复杂做一份问卷、发出去、收回答、看统计。但真正动起手来你会发现项目的大头根本不在 CRUD而在动态表单——题型不同、选项不同、答案格式不同这决定了数据库设计、接口设计和前端组件设计都要围着「动态」二字转。这篇文章里我会按数据模型、后端接口、前端页面、联调部署、避坑验证这条线把一套可复现的方案讲清楚。适合已经会 SpringBoot 和 Vue 基础语法、想完整做一个小系统的人。2. 想清楚这四张表和两种存储问卷系统的地基才算打牢先别急着写代码。做在线问卷第一步永远是问自己一份问卷有哪些数据答案其实就四类——问卷本身、问卷里的题目、用户交上来的答卷、答卷里的具体答案。很多半路翻车的项目都是急着写接口结果做到统计功能时发现多选题的答案取不出来、题目改了老答卷对不上这就是数据模型没立住。2.1 从问卷到答卷四张核心表的关系与字段设计问卷系统的表结构我一般固定分成四张表不贪多也不硬省表名作用关键字段survey问卷主表id, title, description, status, created_at, updated_atquestion题目表id, survey_id, type, content, required, sortanswer答卷表id, survey_id, client_token, submit_timeanswer_item答案明细表id, answer_id, question_id, content关系很简单survey 一对多 questionsurvey 一对多 answeranswer 一对多 answer_item。这里有两个关键设计。第一survey 表的 status 字段用 draft / published / closed 三个状态管理问卷生命周期已发布的问卷不允许再编辑题目这是后面所有一致性问题的总开关。第二question 表里没有独立的选项表选项以 JSON 数组形式存在 question 表的一个列里。为什么问卷调查系统的题型再多本质就是单选、多选、填空、评分这四种选项只是题目的附属描述不参与跨问卷的复杂查询。独立选项表意味着多一张表、多一层关联、多一堆 id 映射收益却几乎没有。answer_item 的 content 字段是另一个容易想歪的地方。单选存 option_id多选存逗号分隔的 option_id 列表填空直接存文本。这个字段统一用 VARCHAR 或 TEXT入库前按题型序列化。我见过有人把整份答卷的所有答案塞进 answer 表的一个大 JSON 里当时觉得很省事后来要做「某道题的选项分布」时写 SQL 取不出来只能在 Java 里全量查出来再拆字符串性能惨不忍睹。把答案明细拆出来等于给统计留了一条后路。还有一个细节答案明细里的 question_id如果题目后来被删了或者改了历史统计就全乱。我的习惯是 question 做逻辑删除而不是物理删除再配合「已发布问卷锁编辑」这个约束统计基本不会背锅。2.2 题型再灵活也是四种两种存储方案的取舍边界上一节我说不建独立选项表但这句话有边界。如果你要做的不是单份问卷而是一个问卷平台需要跨问卷、跨题目统计「选项 A 在所有问卷里被选了多少次」JSON 字段就在 SQL 层面使不上劲这时候才值得换成独立 option 表。判断标准很简单统计分析和选项是否解耦。方案 Aquestion 表加 options_json 列写接口简单前端拿到的结构天然就是嵌套的适合单机小系统。 方案 Bquestion_option 独立表每行存 option_id、content、sort统计能用 group by 直接算适合报表需求重的场景。我倾向于选方案 A但会在 answer_item 上多加一个冗余字段把题目当时的题干和选项文本也存进去。这样统计的时候不看 question 表只看 answer_item 的快照数据。这个做法有一个非常实际的好处题目后来被改了历史答卷的展示和统计不受任何影响。相当于给数据上了个后悔药后面第 6 章我还会再展开讲。2.3 接口按角色拆管理端和填写端分开少踩一半的坑接口设计上我坚持把管理端接口和填写端接口分开URL 前缀/api/admin和/api/user区分。这不是装样子而是权限边界的问题管理端能拿到问卷的所有字段填写端只能拿到发布状态的题目列表。如果用同一套 DTO 复用很容易出现「给用户返回了 survey 表全部字段」的尴尬事。方法路径说明POST/api/admin/survey创建问卷GET/api/admin/survey问卷列表GET/api/admin/survey/{id}问卷详情含题目PUT/api/admin/survey/{id}更新问卷仅 DRAFT 状态POST/api/admin/survey/{id}/publish发布问卷POST/api/admin/survey/{id}/close关闭问卷GET/api/user/survey/{id}填写端获取题目POST/api/user/survey/{id}/submit提交答卷GET/api/user/survey/{id}/stats统计结果填写端接口在 Service 层就要校验问卷状态未发布或已关闭的问卷直接抛业务异常不要等到 Controller 层再处理。这个逻辑写在 Service 而不是 Controller是因为创建、发布、关闭、提交这几个接口都要判断状态集中在 Service 层可以避免每个接口各写一遍也方便写单元测试。3. SpringBoot 后端核心实现从建表到跑通一次答题数据模型立住之后后端就顺手了。我一直觉得 SpringBoot 做这类系统最舒服的一点是开箱即用的 Web 容器加 Spring Data JPA实体映射和事务都省心。如果你用 MyBatis下面代码里的 Repository 换成 Mapper 即可核心逻辑不变。3.1 最小骨架依赖坐标、application.yml 与实体映射依赖只需要五个spring-boot-starter-web、spring-boot-starter-data-jpa、mysql-connector-j、spring-boot-starter-validation、lombok可选。注意一个版本坑如果你搜到的教程是 SpringBoot 2.x 的把代码抄到 SpringBoot 3.x 项目里第一件事就是改包名javax.persistence 全部换成 jakarta.persistence。这个报错看着玄学其实就是版本代沟。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/survey_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: root jpa: hibernate: ddl-auto: update open-in-view: false properties: hibernate: format_sql: true jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghaidatasource 连接串里的 serverTimezoneAsia/Shanghai 必须显式写否则 JDBC 驱动会拿服务器默认时区云服务器通常是 UTC入库时间直接差 8 小时。jackson 的 time-zone 解决的是另一个 8 小时问题时间从数据库正确读出来了但 JSON 序列化时按 UTC 格式化输出。两个地方都要配配一个漏一个都会出事。open-in-view: false 是我强烈建议加的SpringBoot 默认会打开 OSIV在 Controller 层也持有数据库连接列表接口慢的时候很难排查。关掉之后懒加载问题会提前暴露在接口层反而好处理。对应的 Survey 实体类Entity Table(name survey) public class Survey { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String title; private String description; Enumerated(EnumType.STRING) private SurveyStatus status SurveyStatus.DRAFT; OneToMany(mappedBy survey, cascade CascadeType.ALL, orphanRemoval true) OrderBy(sort ASC) private ListQuestion questions new ArrayList(); private LocalDateTime createdAt; private LocalDateTime updatedAt; PrePersist void onCreate() { createdAt LocalDateTime.now(); } PreUpdate void onUpdate() { updatedAt LocalDateTime.now(); } }cascade CascadeType.ALL 加上 orphanRemoval true 是「先删后插」写法的关键先把旧题目从集合里移除ORM 会自动生成 DELETE 语句不用手写删除集合的 SQL。OrderBy(sort ASC) 保证查出来的题目顺序始终和前端保存的顺序一致注意这个 sort 字段要由前端在编辑时维护好。时间字段用 LocalDateTime别用 java.util.Date配合 3.1 的 jackson 配置可以少踩很多格式化坑。3.2 问卷创建与更新先清关联再插入的事务写法问卷更新的两种常见方案是「增量比对」和「全量覆盖」。小项目我选全量覆盖前端一次性提交整个问卷结构后端在事务里先清空旧题目再逐条插入新的。这个方案看起来简单但有一个前提——只有 DRAFT 状态才能走这个逻辑已发布问卷的题目是锁定的否则答案明细里的 question_id 会悬空。Transactional public Survey updateSurvey(Long id, SurveyRequest request) { Survey survey surveyRepository.findById(id) .orElseThrow(() - new BusinessException(问卷不存在)); if (survey.getStatus() ! SurveyStatus.DRAFT) { throw new BusinessException(只有草稿状态的问卷才能编辑); } survey.setTitle(request.getTitle()); survey.setDescription(request.getDescription()); survey.getQuestions().clear(); for (QuestionRequest q : request.getQuestions()) { Question question new Question(); question.setType(q.getType()); question.setContent(q.getContent()); question.setRequired(q.getRequired()); question.setSort(q.getSort()); question.setSurvey(survey); survey.getQuestions().add(question); } return surveyRepository.save(survey); }Transactional 必须加在 Service 方法上保证 clear 和重新 add 在同一个事务里中途任何一条插入失败都会整体回滚不会出现「旧题目没了新题目没插上」的半截数据。清空的是持久化上下文里的集合配合 orphanRemovalHibernate 会自动删除孤儿记录。为什么不直接 deleteBySurveyId 再重新插因为双向关联不清理的话会出现外键中断顺序一错 SQL 就报错。这段代码里最容易被忽略的是「另存为副本」场景把这个逻辑复用一下把 survey 换成新对象重新 save就是一套完整的问卷复制功能成本几乎为零。3.3 提交答卷状态校验与幂等防重答题提交是写接口里并发压力最大的。我见过最典型的翻车是前端点了两次提交数据库里出现两条一模一样的数据。防重三件套前端提交后按钮置灰后端事务里先查一次数据库加唯一索引兜底。匿名问卷没有 userId前端生成一个 clientTokenUUID提交时带上answer 表对 survey_id client_token 建唯一索引同一浏览器重复提交会被数据库直接拦死。Transactional public Long submit(AnswerRequest request) { Survey survey surveyRepository.findById(request.getSurveyId()) .orElseThrow(() - new BusinessException(问卷不存在)); if (survey.getStatus() ! SurveyStatus.PUBLISHED) { throw new BusinessException(问卷未发布或已关闭); } Answer answer new Answer(); answer.setSurvey(survey); answer.setClientToken(request.getClientToken()); answer.setSubmitTime(LocalDateTime.now()); ListAnswerItem items request.getItems().stream().map(item - { AnswerItem ai new AnswerItem(); ai.setAnswer(answer); ai.setQuestionId(item.getQuestionId()); ai.setContent(item.getContent()); return ai; }).collect(Collectors.toList()); answer.setItems(items); return answerRepository.save(answer).getId(); }必答校验放在 Controller 层还是 Service 层这类系统里题目是动态的用 Validated 很难覆盖「第 3 题必答而不是第 4 题必答」这种规则。我一般是让提交请求带上题目的 required 标记在 Service 里循环校验required 且 content 为空就抛业务异常前端再把 message 展示出来。注意这里先查 exists 再插入只能挡住同一时刻的普通重复真正防并发重复的是 survey_id client_token 的唯一索引。如果并发测试时唯一索引抛了 DataIntegrityViolationException要在 Service 层捕获并翻译成友好提示否则用户看到的是 500。4. Vue 前端实现问卷编辑器与填写页怎么拆组件、管状态Vue 这边的核心不是会写 .vue 文件而是怎么把一个动态表单拆成可维护的组件树。如果用一个巨型组件实现所有逻辑后面加题型、加属性会非常痛苦。4.1 问卷编辑器题型、题面、选项拆成独立组件编辑器页面我拆成三层SurveyEditor.vue 在最上面管标题、添加题目按钮、保存中间通过 v-for 渲染 QuestionItem.vue每个 QuestionItem 内部再嵌套一个 OptionEditor.vue 管选项列表。题型切换、选项增删、必答开关都收敛在 QuestionItem 里父组件不需要关心这些细节。!-- SurveyEditor.vue -- template div classsurvey-editor div classtoolbar input v-modelform.title placeholder问卷标题 / button clickaddQuestion(single)添加单选题/button button clickaddQuestion(multiple)添加多选题/button button clickaddQuestion(blank)添加填空题/button button :disabledsaving clicksave保存/button /div QuestionItem v-for(q, index) in form.questions :keyq.clientId :questionq :indexindex removeform.questions.splice(index, 1) / /div /template script setup import { reactive, ref } from vue import QuestionItem from ./QuestionItem.vue const form reactive({ title: , questions: [] }) const saving ref(false) let seq 0 function addQuestion(type) { form.questions.push({ clientId: q_tmp_ (seq), type, content: , required: false, sort: form.questions.length 1, options: type blank ? [] : [, ] }) } /script这里有两个细节。第一每个新增题目都要生成一个 clientId 而不是用数据库 id 做 key因为新加的题还没有 id用 null 做 key 会导致 DOM 复用错乱典型表现是删掉第一题第二题的输入框内容跟着变了。第二添加题时按 type 初始化 options填空没有选项单选多选默认两个空字符串。切换题型时也要处理这个逻辑比如从单选切到填空要清空选项数组。4.2 填写页动态校验v-model 与必答规则的配合填写页拿到后端返回的题目 JSON要按 type 渲染不同控件。单选用 radio 组、多选用 checkbox 组、填空用 input、评分题用 input-number 限制 1 到 10。我的做法是写一个映射函数按 type 返回组件不在模板里堆一堆 v-if。// 按题型生成空答案 function createEmptyValue(type) { switch (type) { case single: return case multiple: return [] case blank: return case score: return null } } // 必答校验多选是数组不能判空字符串 function validate(formData, questions) { for (const q of questions) { const val formData[q.id] if (!q.required) continue const isEmpty (typeof val string val.trim() ) || (Array.isArray(val) val.length 0) || val null || val undefined if (isEmpty) { return { ok: false, message: 第 ${q.sort} 题为必答题 } } } return { ok: true } }validate 放在提交前统一跑而不是在每个控件上单独配 rules。原因很简单题目是动态的逐个配规则等于把后端逻辑在前端复制一遍而且后端还得再校验一次。这里的关键点是多选必须判数组长度填空要 trim 之后再判断否则用户输入三个空格也算必答通过了。还有一个容易忽略的问题formData 的 key 必须用后端返回的 questionId不能用 clientId因为提交时要传给后端后端 answer_item 只认数据库里的 questionId。4.3 统计页图表接入ECharts 零数据与口径问题统计页从后端拿每个题目的统计结果单选按选项分组 count多选按选项 count填空只能看文本列表。图表我用 ECharts接入成本低效果够用。import * as echarts from echarts function renderBarStat(el, stat) { const chart echarts.init(el) chart.setOption({ tooltip: { trigger: axis }, xAxis: { type: category, data: stat.map(s s.optionText) }, yAxis: { type: value, minInterval: 1 }, series: [{ type: bar, data: stat.map(s s.count), barMaxWidth: 40 }] }) }yAxis 里 minInterval 设为 1 是个容易被忽略的小参数统计单选题时数据量小坐标轴刻度可能变成 0.5柱状图看起来像连续型数据很别扭。另一个经常翻车的是空数据处理接口返回空数组时ECharts 不会报错只会画一个空坐标轴用户看到以为页面坏了。所以我一般渲染前先判断 stat.length 0这种情况直接显示「暂无回答」。多选题的统计口径也要想清楚展示「选了这个选项的人占比」时分母按提交人数而不是勾选次数这两个数字在某些问卷里差得很大。5. 联调与部署避坑跨域、404、时区、N1 四个翻车点这个系统我做过不止一遍踩过的坑基本都集中在联调和部署阶段。下面每一条都按「现象 → 原因 → 解决」写按顺序排查能省大半天。5.1 开发环境跨域被拦现象、原因与 Vite 代理解决现象前端跑在 5173后端跑在 8080浏览器 console 报 CORS error接口看起来通了但前端拿不到数据。原因浏览器同源策略。前端页面和接口不在同一个 origin后端没开 CORS 时跨域请求会被浏览器拦截。解决开发环境不要依赖后端开 CORS而是用 Vite 的 proxy 把请求代理到后端浏览器看到的请求始终是同源的CORS 压根不会触发。// vite.config.js export default { server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }changeOrigin 设为 true 会把请求头里的 Host 改成目标地址某些后端框架会根据 Host 做处理这个参数必填。配套的是 axios 的 baseURL 直接写 /api不要写死 http://localhost:8080。这样代码里没有环境相关的配置后面不管部署到哪只需要改代理配置JS 代码不用动。如果非要在后端配 CrossOrigin 硬闯也有一个大坑预检请求 OPTIONS 必须放行allowedHeaders 没配好会看到 Response to preflight request doesnt pass access control check 之类的报错这几乎是后端跨域最典型的翻车点。能在开发环境用代理解决就别去碰后端 CORS。5.2 前端打包放进 SpringBoot刷新 404 与资源路径错乱现象vue 构建产物放进 src/main/resources/static启动后首页能打开但直接访问 /admin 刷新页面就 404或者页面打开了但样式、脚本全部加载失败。原因Vue Router 默认 history 模式下前端路由是虚的后端没有 /admin 这个路径直接返回 404。资源加载失败则是因为 vite 打包默认使用绝对路径 /部署到非根路径时 JS 和 CSS 的 URL 全部错位。解决分两半。资源路径vite.config.js 里把 base 设为 ./打包后的资源路径变成相对路径不管部署在根目录还是子目录都不会挂。路由回退在 SpringBoot 里写一个 WebMvcConfig把非 API 路径统一转发到 index.html让 Vue Router 接管路由。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addViewControllers(ViewControllerRegistry registry) { registry.addViewController(/{path:[^\\.]*}) .setViewName(forward:/index.html); } }这个配置有个坑它会匹配所有不带点号的路径包括 /api 开头的接口。所以我的做法是给所有后端接口统一加 /api 前缀第 2 章已经这样设计了然后在这个转发配置里排除 /api。如果没加前缀需要在拦截器里放过真实接口路径。部署到服务器时常见做法是用宝塔的 Nginx 站点直接指向前端 dist 目录同时把 /api 反向代理到 SpringBoot 的 8080 端口我现在的习惯是写一个简单的 docker-compose把 Nginx 和 SpringBoot 放两个容器。不管用哪种方式记得前端构建时 base 和 Nginx 的 location 根路径要对上。5.3 Jackson 时区差 8 小时一个配置两处设置现象数据库里的 updated_at 是 14:00前端显示 06:00。原因两个时区问题叠在一起。第一JDBC 连接串没指定 serverTimezone驱动用默认 UTC 读时间。第二Jackson 序列化 LocalDateTime 时也按 UTC 输出。任何一个没配都会出现 8 小时偏差。解决第 3 章的 application.yml 里已经把两个配置写全了——datasource url 加 serverTimezoneAsia/Shanghaijackson 配 time-zone: Asia/Shanghai。遇到时间差问题时先检查这两项而不是去改 MySQL 的全局时区变量。改 MySQL 时区会影响所有连接而且重启可能被初始化脚本覆盖属于治标不治本。5.4 懒加载 N1 查询列表接口卡顿的真凶现象问卷列表接口数据量只有几十份却要好几秒才返回。原因Survey 的 questions 是 OneToMany 懒加载遍历每个 survey 访问 getQuestions() 时触发一次 SELECT 查询这就是 N1。列表页根本不需要题目详情但实体关联在 JSON 序列化时被 Jackson 访问了懒加载被强行触发。解决列表接口返回 DTO不返回 questions 字段详情接口再用 join fetch 一次查出。Query(select distinct s from Survey s left join fetch s.questions q order by s.createdAt desc) ListSurvey findListWithQuestions();distinct 是必须的否则 join fetch 一对多会产生重复行同一个问卷出现多次。这个写法在没有分页时没问题但一旦搭配 Pageable 就会出错——join fetch 和分页不能同时用于一对多查询。如果要做分页正确做法是先分页查出 survey id 列表再用 in 查询批量组装题目绕开一对多分页的限制。把 open-in-view 设为 false 之后这类问题会提前暴露在接口层虽然报错看着烦但比线上问题好修得多。6. 一小时验收与两个进阶技巧让系统从能跑变成可靠6.1 最小验收用例我做这类系统验收从不安心于「能打开」三个字而是固定走一条完整链路新建问卷 → 添加两道单选、一道多选、一道填空 → 保存 → 发布 → 换一个浏览器打开填写页 → 故意漏掉必答题提交看前端提示 → 正常填写提交 → 后端查 answer 记录 → 统计页看单选和多选的 count 是否正确。这条链路能覆盖掉 80% 的功能性问题比写一堆单元测试快很多尤其适合没有测试环境的项目。6.2 进阶技巧给答卷加一个快照字段问卷发布后题目被改或者被逻辑删除老答卷还留在数据库里。最稳的做法是答题提交时把题目当时的题干和选项文本冗余存进 answer_item统计和展示都直接读快照不查 question 表。这个字段加进去成本很低但能让统计逻辑彻底不依赖实时表算是这个项目里最有用的后悔药。没有它每次改题都要战战兢兢怕历史数据对不上。6.3 我的验证习惯最后分享一个教训有一版我把 clientToken 唯一索引建好了但 Service 层没有捕获 DataIntegrityViolationException并发重复提交时数据库确实拦住了前端看到的却是一个 500。所以防重逻辑不能只有索引异常处理必须跟上。从那以后我每做完一个写接口都会用 JMeter 跑一遍 50 个并发提交确认重复请求能被优雅地拦下来而不是抛一堆堆栈给用户。做到这一步这套 SpringBootVue 的在线问卷调查系统才算真的可以交出去。希望帮到你。本文还有配套的精品资源点击获取