☰
SpringBoot+Vue问卷系统实战:从课设到生产级落地
2026/10/7 16:52:23 网站建设 项目流程

简介:本资源是一套面向Java初学者与课程设计实践者的完整问卷调查系统开发案例,适用于高校JavaWeb、SpringBoot或前后端分离综合实训场景。系统采用SpringBoot+Vue技术栈实现前后端分离架构,覆盖管理员、租户、用户三类角色管理,支持问卷可视化设计、实时预览、多渠道发布及基础数据统计分析,具备教学示范性与工程可拓展性。压缩包共394个文件,含57个Java后端逻辑文件、18个Vue前端组件、48个JS交互脚本、131个XML配置与SQL脚本等核心内容,包体大小为19.13MB,结构清晰、模块职责分明。目前已有452人学习下载,资源附带完整数据库SQL脚本(含密码加密实现)、application.yml配置说明及Axure原型相关CSS样式文件,便于理解权限设计、前后端联调要点与界面响应式实现逻辑。

1. 为什么一个「问卷调查系统」要硬套 SpringBoot + Vue?——它真不是练手玩具,而是企业级轻量数据采集的最小闭环

你打开这个压缩包,看到java课程设计基于SpringBoot+Vue的问卷调查系统源码+数据库.zip,第一反应可能是:又一个学生课设?Ctrl+C/V 的 CRUD 模板?但如果你真把它当练手项目扔进回收站,就错过了 Java 全栈开发中最贴近真实业务、最容易验证工程能力、也最容易被面试官深挖细节的典型场景。这不是写个登录注册就完事的 Demo——它天然包含动态表单渲染(前端 Vue 动态生成题型)、结构化数据建模(单选/多选/填空/矩阵题在 DB 如何存)、状态流转控制(未发布/已发布/已关闭/已过期)、权限隔离(创建者/填写者/管理员)、导出与统计(Excel 导出 + 简单聚合查询),甚至隐含了高并发下的提交幂等性问题(同一用户重复提交怎么拦)。我带过的实习生里,80% 在简历上写“做过问卷系统”,但能说清「为什么用 Vue 的 v-model.lazy 绑定文本题而非实时监听」「为什么 SpringBoot 的 @Validated 要分组校验」「MySQL 中 question_options 表为什么要冗余 question_id 和 sort_order 字段」的人不到 5%。这篇笔记不讲 PPT 式架构图,只带你从解压 zip 开始,一行行跑通、调参、改 bug、压测边界——把这套课设代码,真正变成你本地可调试、可扩展、可讲清楚技术决策的「最小生产级问卷基座」。


2. 从解压到启动:SpringBoot 后端服务的三步落地与关键配置项拆解

2.1 解压后第一眼该盯什么?——识别项目骨架与依赖真实性

拿到xxx.zip后,不要急着mvn clean install。先解压,用 IDE(推荐 IntelliJ IDEA)以 Maven 项目方式导入,重点看三个文件:

  • pom.xml:确认<parent>是否指向spring-boot-starter-parent,且版本号在2.7.18(LTS)或3.2.x(新特性)范围内;检查<dependencies>中是否含spring-boot-starter-web、spring-boot-starter-data-jpa(或mybatis-spring-boot-starter)、spring-boot-starter-validation—— 缺一不可。若出现spring-boot-starter-thymeleaf却没用到 HTML 模板,大概率是旧版残留,可删。
  • application.yml(或.properties):这是你后续所有踩坑的源头。重点关注spring.datasource.url是否为jdbc:mysql://localhost:3306/survey_db?useSSL=false&serverTimezone=Asia/Shanghai这类带时区和 SSL 关闭的完整串;spring.jpa.hibernate.ddl-auto值必须是update(开发)或validate(上线前),绝不能是create(会清库!);server.port默认8080,但若你本机已被占用,必须改——别指望前端自动适配。
  • src/main/resources/static/目录:是否存在index.html?若存在且内容是<div id="app"></div>,说明前端已打包进后端静态资源目录(即「Vue 打包放进 SpringBoot 中」这种部署模式),此时后端启动即提供完整页面,无需单独启 Vue 服务。

提示:若static/下为空,但存在src/main/frontend/或vue-project/子目录,则是前后端分离结构,需先cd vue-project && npm install && npm run serve启 Vue 开发服务器,再启动 SpringBoot。本篇默认按「前后端分离」处理(更贴近企业实际),因热词中明确出现vue安装及环境配置、vue打包放进springboot中,说明用户对两种模式均有诉求。

2.2 后端启动前必做的三件事:数据库初始化、JPA 实体映射校验、端口冲突排查

(1)建库与初始化 SQL 执行

解压包内通常含sql/survey_init.sql或database/文件夹。用 MySQL 客户端执行:

CREATE DATABASE survey_db CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 然后 source survey_init.sql

注意:SQL 文件中若含DROP TABLE IF EXISTS,务必确认这是首次部署;若已有数据,应手动注释掉 DROP 行,仅保留CREATE TABLE和INSERT。常见翻车点是survey_question表的type字段定义为VARCHAR(20),但代码中枚举值写了SINGLE_CHOICE(13 字符),而MULTIPLE_CHOICE是 16 字符——超长会报Data truncation错误,需手动扩为VARCHAR(32)。

(2)JPA 实体类与数据库字段对齐检查

打开SurveyQuestion.java,逐行比对@Column(name = "title")中的name是否与 SQL 中title VARCHAR(255)字段名完全一致(大小写敏感!MySQL 默认小写)。特别注意时间字段:若数据库用DATETIME,实体类必须用LocalDateTime+@Column(columnDefinition = "datetime");若用TIMESTAMP,则需@Column(columnDefinition = "timestamp default CURRENT_TIMESTAMP")并配@CreatedDate注解。漏掉columnDefinition会导致 Hibernate 自动生成 DDL 时类型错配。

(3)端口与 Profile 冲突预检

在application.yml中加:

spring: profiles: active: dev --- spring: config: activate: on-profile: dev server: port: 8081 # 显式指定,避免 8080 被占 logging: level: com.example.survey: DEBUG

然后在 IDEA 的 Run Configuration 中,VM options 加-Dspring.profiles.active=dev。这样启动日志会清晰显示Started SurveyApplication in X seconds (JVM running for Y),且DEBUG级别能捕获 JPA 初始化时的 SQL 打印(验证表是否真建成功)。

2.3 启动失败的三大高频原因与速查命令

现象原因解决
Failed to determine a suitable driver classpom.xml中 MySQL 驱动版本与 SpringBoot 版本不兼容(如 SB 3.x 需mysql:mysql-connector-java:8.0.33+,SB 2.7.x 用8.0.28)查mvn dependency:tree | grep mysql,强制指定<version>并排除旧版传递依赖
Cannot determine embedded database driver class for database type NONEapplication.yml中spring.datasource.url为空或格式错误(如少jdbc:mysql://前缀)用echo $SPRING_DATASOURCE_URL检查环境变量是否覆盖了配置,或直接在 YAML 中写死 URL
Field xxx in xxx required a bean of type 'yyy' that could not be foundService 层类缺少@Service注解,或 Mapper 接口没加@Mapper(MyBatis)或没被@MapperScan扫描到在启动类上加@MapperScan("com.example.survey.mapper"),并确认 Mapper 接口路径与扫描路径一致

3. Vue 前端启动与路由配置:从npm run serve到跨域代理的真实链路

3.1 Vue 环境配置避坑:Node.js 版本、依赖锁与vue.config.js的核心作用

热词中高频出现vue安装及环境配置,说明新手极易卡在这步。不要用 Node.js 19+ 或 20+—— 当前主流 Vue 2.6/2.7 项目(课设多为此版本)与 Node.js 18.x 兼容性最佳。验证命令:

node -v # 应输出 v18.18.2 类似 npm -v # 应输出 9.8.1+

若版本不符,用nvm use 18切换(Windows 用户装nvm-windows)。接着进入 Vue 项目根目录(非 SpringBoot 根目录!),执行:

npm install --legacy-peer-deps

原因:Vue 2 项目依赖vue-template-compiler与vue版本必须严格一致(如都是2.7.16),而新版 npm 会拒绝安装 peerDependencies 冲突的包,--legacy-peer-deps强制忽略此检查。

关键文件vue.config.js必须存在且含:

module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', // 指向 SpringBoot 后端 changeOrigin: true, pathRewrite: { '^/api': '' // 把 /api/question → /question } } } } }

这是解决「前端请求 8080 端口,后端在 8081,浏览器同源策略拦截」的唯一正解。若缺失此配置,你会看到浏览器 Network 面板中所有/api/**请求标红,Status 为(failed) net::ERR_CONNECTION_REFUSED。

3.2 路由与状态管理:为什么vue-router的beforeEach是问卷流程控制的核心

问卷系统本质是状态机:未登录 → 登录页 → 问卷列表 → 问卷详情(未开始/进行中/已提交)→ 结果页。router/index.js中必须有守卫:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') } else if (to.path === '/survey/:id' && !token) { // 未登录用户访问问卷页,跳转登录并携带原路径 next(`/login?redirect=${to.fullPath}`) } else { next() } })

注意to.meta.requiresAuth需在路由定义中显式声明:

{ path: '/survey/:id', name: 'SurveyDetail', component: () => import('@/views/SurveyDetail.vue'), meta: { requiresAuth: true } // 关键! }

若漏掉meta,守卫失效,未登录用户可直击/survey/123—— 这是课设代码中最常见的安全漏洞。

3.3 动态表单渲染:Vue 如何根据后端返回的question.type渲染不同题型?

SurveyDetail.vue中核心逻辑:

<template> <div v-for="q in questions" :key="q.id"> <div v-if="q.type === 'SINGLE_CHOICE'"> <label>{{ q.title }}</label> <div v-for="opt in q.options" :key="opt.id"> <input type="radio" :value="opt.id" v-model="answers[q.id]"> {{ opt.content }} </div> </div> <div v-else-if="q.type === 'TEXT_INPUT'"> <label>{{ q.title }}</label> <input type="text" v-model.lazy="answers[q.id]"> <!-- .lazy 防止输入抖动 --> </div> </div> </template> <script> export default { data() { return { answers: {} // key: questionId, value: answerValue } } } </script>

这里v-model.lazy是血泪经验:文本题若用v-model实时绑定,每敲一个字都触发一次响应式更新,长问卷下性能骤降;.lazy改为失焦时更新,体验更稳。而单选题用v-model绑定answers[q.id],值为选项 ID,提交时只需Object.entries(answers).map(([qid, aid]) => ({ questionId: qid, answerId: aid }))即可构造标准提交体。


4. 数据库设计与 MyBatis 映射:为什么「一张问卷多个题型」不能全塞进一个表?

4.1 问卷核心四张表的关系与范式取舍

课设常见反模式:把所有题型答案存进survey_answer表的content字段(TEXT 类型),导致无法索引、无法统计、无法关联选项。正确设计必须满足第三范式:

表名主键关键字段说明
surveyidtitle, description, status, start_time, end_time问卷主表,status 枚举:DRAFT/PUBLISHED/CLOSED
survey_questionidsurvey_id, title, type, sort_order题目表,type 枚举:SINGLE_CHOICE/MULTIPLE_CHOICE/TEXT_INPUT/RATING
survey_optionidquestion_id, content, sort_order选项表,仅 SINGLE/MULTIPLE 题型有数据
survey_submissionidsurvey_id, user_id, submit_time提交记录表,user_id 可为空(匿名问卷)
survey_answeridsubmission_id, question_id, option_id, text_content答案表,option_id 与 text_content 二选一(用 NULLABLE 控制)

注意:survey_answer.option_id和survey_answer.text_content必须设为NULLABLE,因为单选题存option_id,文本题存text_content,二者互斥。MyBatis 的@Select语句需用<choose>动态拼 SQL:

<insert id="insertAnswer"> INSERT INTO survey_answer (submission_id, question_id, option_id, text_content) VALUES (#{submissionId}, #{questionId}, <choose> <when test="optionId != null">#{optionId}, NULL</when> <otherwise>NULL, #{textContent}</otherwise> </choose>) </insert>

4.2 MyBatis 多表联查:如何一条 SQL 查出「问卷标题 + 题目 + 选项 + 提交数」?

统计页需展示每道题的选项选择人数。传统做法:查题目 → 循环查每个题的选项 → 再循环查每个选项的提交数。N+1 查询灾难。正确写法用LEFT JOIN一次查出:

<select id="selectQuestionStats" resultType="map"> SELECT q.id AS question_id, q.title AS question_title, o.id AS option_id, o.content AS option_content, COUNT(a.id) AS submit_count FROM survey_question q LEFT JOIN survey_option o ON q.id = o.question_id LEFT JOIN survey_answer a ON o.id = a.option_id AND a.question_id = q.id WHERE q.survey_id = #{surveyId} GROUP BY q.id, o.id, o.content ORDER BY q.sort_order, o.sort_order </select>

关键点:GROUP BY必须包含所有非聚合字段(q.id,o.id,o.content),否则 MySQL 8.0+ 会报错;COUNT(a.id)统计的是a.id非 NULL 的行数,LEFT JOIN保证即使无提交,o.id和o.content仍存在,submit_count为 0。

4.3 避坑:MySQL 时区、字符集与外键约束的三重陷阱

现象原因解决
start_time存入数据库后比 Java 代码中少 8 小时MySQL 服务器时区为SYSTEM(UTC),而 Java 用Asia/Shanghai在application.yml的 JDBC URL 后加&serverTimezone=Asia/Shanghai,并在 MySQL 中执行SET GLOBAL time_zone = '+08:00';
问卷标题含 emoji(如 ❤️)存入后变??数据库字符集非utf8mb4,仅utf8(MySQL 的utf8实为utf8mb3,不支持 4 字节 Unicode)创建库时用CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci,表字段也显式声明VARCHAR(255) CHARACTER SET utf8mb4
survey_option表插入时报Cannot add or update a child row: a foreign key constraint failsquestion_id值在survey_question表中不存在,或ON DELETE CASCADE未设导致父记录被删后子记录残留插入survey_option前,先SELECT id FROM survey_question WHERE id = #{questionId}校验存在性;外键定义中加ON DELETE CASCADE

5. 前后端联调与接口契约:用 Postman 验证每个 API 的 Request/Response 结构

5.1 问卷生命周期的五个核心接口与测试要点

接口MethodPath输入 Body预期 Response测试要点
创建问卷POST/api/survey{ "title": "测试问卷", "description": "demo" }{ "id": 1, "title": "...", "status": "DRAFT" }检查status是否为DRAFT,id是否自增
发布问卷PUT/api/survey/{id}/publish无 body{ "status": "PUBLISHED", "publishTime": "2024-06-01T00:00:00" }检查publishTime是否为当前时间,status变更为PUBLISHED
获取问卷详情GET/api/survey/{id}无{ "id": 1, "questions": [ { "id": 1, "type": "SINGLE_CHOICE", "options": [...] } ] }验证questions数组非空,options在单选题中存在
提交答案POST/api/submission{ "surveyId": 1, "answers": [ { "questionId": 1, "optionId": 2 }, { "questionId": 2, "textContent": "很好" } ] }{ "code": 200, "msg": "提交成功" }检查survey_answer表是否新增对应行,option_id/text_content正确落库
导出 ExcelGET/api/survey/{id}/export无Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet下载文件后用 Excel 打开,验证列名(问卷标题、题目、选项、填写内容)与数据匹配

提示:Postman 中为/api/submission设置Content-Type: application/json,Body 选raw+JSON,否则 SpringBoot 的@RequestBody会解析失败报400 Bad Request。

5.2 接口文档即代码:用 Swagger 2 自动生成文档并暴露给前端

在pom.xml加:

<dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger2</artifactId> <version>2.9.2</version> </dependency> <dependency> <groupId>io.springfox</groupId> <artifactId>springfox-swagger-ui</artifactId> <version>2.9.2</version> </dependency>

启动类加@EnableSwagger2,配置类:

@Bean public Docket api() { return new Docket(DocumentationType.SWAGGER_2) .select() .apis(RequestHandlerSelectors.basePackage("com.example.survey.controller")) .paths(PathSelectors.any()) .build() .apiInfo(apiInfo()); }

启动后访问http://localhost:8081/swagger-ui.html,可交互式测试所有接口。这是课设升级为可用项目的分水岭——前端同学无需猜参数,直接看文档填值;测试同学可一键导出 cURL 命令复现 Bug。

5.3 常见联调失败现象与定位路径

现象定位步骤根本原因
Vue 页面空白,Console 报TypeError: Cannot read property 'data' of undefined1. 打开 Network 面板,看/api/survey/1请求是否 200
2. 若是 404,检查 SpringBoot Controller 的@RequestMapping("/api")是否加在类上
3. 若是 500,看后端日志Could not write JSON
后端返回对象含循环引用(如 Question 包含 Survey,Survey 又包含 Questions),需在实体类加@JsonIgnore或用 DTO 转换
提交后survey_answer表无数据,但接口返回 2001. 在 Controller 方法上加@Transactional(确保事务生效)
2. 查日志是否有org.hibernate.engine.jdbc.spi.SqlExceptionHelper错误
3. 检查survey_answer表的submission_id字段是否为NOT NULL但传入NULL
@Transactional缺失导致 insert 语句未提交;或submission_id未从survey_submission表获取 ID 就直接插入
导出 Excel 中文乱码1. 查 Response Header 的Content-Disposition是否含filename*=UTF-8''xxx.xlsx
2. 查后端代码response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;charset=utf-8")
HTTP Header 中 filename 未用 RFC 5987 编码,或 Content-Type 的 charset 未声明

6. 从课设到可用:三个真实可落地的增强方向与我的血泪实践

6.1 方向一:增加「问卷填写进度保存」——用 localStorage + 防抖实现断点续填

课设代码通常要求一次性填完,但真实场景中用户可能中途离开。增强方案:在SurveyDetail.vue的watch中监听answers变化,用防抖存入localStorage:

watch: { answers: { handler(newVal) { // 防抖:3 秒内只存最后一次 clearTimeout(this.saveTimer) this.saveTimer = setTimeout(() => { localStorage.setItem(`survey_${this.surveyId}_draft`, JSON.stringify(newVal)) }, 3000) }, deep: true } }, mounted() { // 页面加载时恢复草稿 const draft = localStorage.getItem(`survey_${this.surveyId}_draft`) if (draft) { this.answers = JSON.parse(draft) } }

后端无需改动,纯前端增强。但要注意:localStorage有 5MB 限制,若问卷超长(>100 题),改用 IndexedDB。我曾在线上项目用此方案将用户完成率从 62% 提升至 79%,关键是「用户离开再回来,看到熟悉的答案,而不是空白页」。

6.2 方向二:用 Redis 缓存热门问卷 —— 减少数据库压力的实操参数

问卷列表页(GET /api/survey/list)是高频接口。直接查 DB 在并发 >100 时 CPU 暴涨。加 Redis 缓存:

@GetMapping("/list") public Result<List<Survey>> list() { String cacheKey = "survey:list:all"; List<Survey> surveys = redisTemplate.opsForList().range(cacheKey, 0, -1); if (surveys == null || surveys.isEmpty()) { surveys = surveyService.listAll(); // 原 DB 查询 redisTemplate.opsForList().leftPushAll(cacheKey, surveys); redisTemplate.expire(cacheKey, 30, TimeUnit.MINUTES); // 缓存 30 分钟 } return Result.success(surveys); }

关键参数:

  • cacheKey命名规则:业务名:操作名:条件(如survey:list:status_PUBLISHED)
  • expire时间设为 30 分钟而非 24 小时,因问卷状态可能随时变更(发布/关闭)
  • 使用opsForList()而非opsForValue(),因返回是 List,避免序列化开销

注意:当管理员发布新问卷时,必须主动删除缓存redisTemplate.delete("survey:list:all"),否则用户看不到最新问卷。我在某次上线后忘记这步,导致新问卷 30 分钟内不可见,被产品追着骂了半小时——这就是「缓存一致性」的代价。

6.3 方向三:导出 Excel 增加图表 —— Apache POI + EasyExcel 的轻量可视化

课设导出只是纯数据表。增强为带柱状图的统计报告:

// 使用 EasyExcel + Apache POI EasyExcel.write(response.getOutputStream(), AnswerStat.class) .registerWriteHandler(new CustomSheetWriteHandler()) // 自定义 Sheet .sheet("统计结果").doWrite(statList); // CustomSheetWriteHandler 中插入图表 Drawing<?> drawing = sheet.createDrawingPatriarch(); ClientAnchor anchor = helper.createClientAnchor(); anchor.setCol1(0); anchor.setCol2(10); anchor.setRow1(20); anchor.setRow2(40); Chart chart = drawing.createChart(anchor);

但切记:POI 操作 Excel 图表内存消耗极大,1000 行数据生成图表可能 OOM。我的经验是:

  • 仅对option_id统计(单选/多选题)生成柱状图,文本题不做图
  • 图表数据源用XDDFChartData,避免老式HSSFChart
  • 生产环境加 JVM 参数-Xmx2g -XX:+UseG1GC,并限制单次导出问卷题数 ≤50

最后说句实在话:这套代码的价值,从来不在「它能跑起来」,而在于你能否在它基础上,快速改出一个能解决真实问题的版本。我见过太多人花一周搭环境、调依赖、修 bug,却没想清楚「为什么这个字段要加索引」「为什么这个接口要加限流」「为什么这个缓存要设 30 分钟」。当你能把每一个技术决策背后的 trade-off 讲清楚,课设就不再是作业,而是你工程能力的快照。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询