SpringBoot+Vue+MySQL在线问卷调查系统完整落地实践
直接跑起来的在线问卷调查系统SpringBoot后端、Vue前端与MySQL的完整落地记录很多人做问卷调查系统第一反应是去网上找现成的表单工具填个问卷链接、导出个Excel就算完事。可一旦需求变成我需要自己维护一套系统、要对接内部账号体系、要自定义题型、要把数据沉淀到自己的数据库里那些第三方平台就不够用了。我在实际项目中就遇到过这种情况——业务方要的从来不只是能发问卷而是能管理问卷、能控制投放范围、能随时导出并分析回收数据。这套在线问卷调查系统正好就是在这样的背景下沉淀下来的。技术栈是SpringBoot Vue MySQL前后端分离本地跑起来就能用不需要杂七杂八的外部依赖。本文我会从整体设计、核心模块、关键接口、前端实现、部署细节和踩坑经验几个方面展开尽量做到拿过去就能照着重现而不是只丢给你一个Git仓库的链接。1. 这套问卷系统的核心需求拆解别急着写代码先看清问卷到底要管理什么问卷系统的第一直觉是创建问卷、填问卷、看结果三步但真实业务场景里这三步背后还藏着大量细节。我见过不少项目做到一半返工原因几乎都是前期没把需求边界划清楚。1.1 问卷的生命周期比想象中长一份问卷从创建到归档至少要经历以下状态草稿编辑中、发布可填写、暂停临时停止回收、结束不可填写、归档数据封存。这几个状态不是随意定的它们直接对应业务上的真实操作——问卷员会先配题、再预览、然后小范围试填、确认无误后正式投放投放过程中如果发现问题还要能暂停修改。对应到后端问卷实体上就需要一个status字段并配一组状态流转接口。很多新手喜欢直接在数据库里改状态但正规做法应该是通过专门的接口去流转比如publish、suspend、close这样可以在流转前后插入校验逻辑——比如草稿状态下不允许发布空问卷、结束状态下不允许再修改题目。这套系统里我用了一个简单的状态枚举来处理public enum SurveyStatus { DRAFT(0, 草稿), PUBLISHED(1, 发布中), SUSPENDED(2, 已暂停), CLOSED(3, 已结束), ARCHIVED(4, 已归档); private final int code; private final String desc; SurveyStatus(int code, String desc) { this.code code; this.desc desc; } }状态字段的变更操作统一收敛到服务层的SurveyService里前端只负责调接口不直接改数据库。这个设计在后来的联调中帮了大忙因为状态变更往往伴随着问卷题目的锁定、统计逻辑的触发如果前端绕过服务层直接操作很容易出现数据不一致。1.2 题型设计决定了系统的成败问卷调查的核心是题型自由度。我见过只做单选多选就上线的系统结果被矩阵题评分题填空排序题打回重做。一个合格的问卷系统至少要覆盖以下题型单选、多选最基础必须有填空单行文本、多行文本几乎必考下拉选择常用于地区、职业等固定选项评分题用于满意度调研矩阵单选用于多维度评价比如对以下服务逐项打分排序题让用户按优先级排列选项日期、数字题用于限定格式的输入这道题的难点在于题目和选项的数据结构必须足够灵活但又不能灵活到失去约束。我采用的方式是题目表 选项表分离。题目表存题型编码、标题、是否必填、排序号等基础信息选项表存每个选项的内容和排序。对于特殊题型比如矩阵题用question_type字段区分选项表里通过parent_id来组织父子关系。结构上大致是这样CREATE TABLE survey_question ( id bigint NOT NULL AUTO_INCREMENT, survey_id bigint NOT NULL COMMENT 所属问卷ID, question_type varchar(32) NOT NULL COMMENT 题型编码, title varchar(500) NOT NULL COMMENT 题干, is_required tinyint(1) DEFAULT 0 COMMENT 是否必填, sort_no int DEFAULT 0 COMMENT 排序号, parent_id bigint DEFAULT NULL COMMENT 父题ID用于矩阵题等, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT问卷题目表;选项表类似核心就是question_id外键和content字段。这里有一个容易被忽略的点选项表和题目表都用sort_no控制顺序不要依赖主键自增的顺序。实际运营中题型调整、选项改序是常态如果靠id排序插入一条数据后顺序就全乱了。1.3 回收数据的存储从用起来没问题到统计时不骂娘问卷系统的高潮是数据回收也是技术负债最容易爆发的环节。最简单的方案是每份答卷存一行JSON查询时解析即可——开发快但后续做统计和导出时会特别痛苦。我在这个项目里采用的是答题明细表方案一份问卷的答卷由多条明细记录组成CREATE TABLE survey_answer ( id bigint NOT NULL AUTO_INCREMENT, survey_id bigint NOT NULL, respondent_id varchar(64) DEFAULT NULL COMMENT 答卷人标识可为空, submit_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷主表; CREATE TABLE survey_answer_detail ( id bigint NOT NULL AUTO_INCREMENT, answer_id bigint NOT NULL COMMENT 所属答卷ID, question_id bigint NOT NULL COMMENT 题目ID, answer_content text COMMENT 答案内容, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答题明细表;这样做的收益在统计阶段体现得非常充分。比如统计第3题选A的人数一条SQL就能完成不需要把JSON字段拿出来在内存里解析。而且后续如果要扩展筛选维度——比如只看最近一周提交的、且第5题填了特定内容的答卷——SQL表达起来也完全不成问题。做一个简单统计接口时MyBatis的映射配置配合起来非常顺畅SELECT q.id AS question_id, q.title AS question_title, d.answer_content, COUNT(*) AS answer_count FROM survey_answer_detail d JOIN survey_question q ON d.question_id q.id JOIN survey_answer a ON d.answer_id a.id WHERE q.survey_id #{surveyId} AND q.id #{questionId} GROUP BY d.answer_content这套结构虽然写起来比一把JSON麻烦一些但属于典型的用一周的麻烦换一年的省心。2. 后端架构与核心接口设计先把登录和权限稳住再谈业务没有用户体系的问卷系统在内部场景里几乎活不过一周。因为问卷投放往往需要控制范围——比如只让某个部门的人填写、或者只允许登录用户提交。SpringBoot在这块有非常成熟的生态我用的是Spring Security JWT的组合简单、轻量、前后端分离友好。2.1 登陆鉴权与接口权限控制JWT的流程大家都熟登录成功后签发token前端存在本地每次请求带上Authorization: Bearer token后端用一个过滤器解析token并把用户信息放入上下文。这个方案的无状态特性特别适合配合Vue前端使用。权限控制这边我定义了三种角色普通用户可填写问卷、问卷管理员可创建和管理问卷、系统管理员可管理所有用户和问卷。在接口上通过注解控制PreAuthorize(hasAnyRole(ADMIN, SURVEY_MANAGER)) PostMapping(/api/surveys) public ResultLong createSurvey(RequestBody SurveyCreateRequest request) { // 创建问卷 }这样一个注解就把创建问卷的权限限制住了。填写问卷的接口则允许普通用户调用因为登录用户在填写问卷时也会自动带上身份信息方便后续追溯。比较隐蔽的坑是刷新token的时机。如果token过期时间太短用户填一份长问卷填到一半token失效提交时直接被踢下线如果时间太长又有安全风险。我的做法是token有效期设为2小时前端在请求拦截器里捕获401响应后自动调refresh接口换新token保留用户当前填写的问卷内容不丢失。实测下来这个体验要比重新登录顺畅得多。2.2 问卷核心接口清单前后端分离的项目接口设计直接影响联调效率。下面这个表是我在实际开发中反复调整后的接口清单基本覆盖了问卷系统的全部操作功能模块请求方式路径说明登录认证POST/api/auth/login用户名密码登录返回token用户信息GET/api/auth/me获取当前登录用户信息创建问卷POST/api/surveys创建空白问卷问卷列表GET/api/surveys分页查询当前用户问卷问卷详情GET/api/surveys/{id}返回问卷含题目结构更新问卷PUT/api/surveys/{id}更新基本信息删除问卷DELETE/api/surveys/{id}逻辑删除发布问卷POST/api/surveys/{id}/publish状态流转到发布暂停问卷POST/api/surveys/{id}/suspend状态流转到暂停获取填写页GET/api/surveys/{id}/fill匿名/登录用户均可访问提交答卷POST/api/surveys/{id}/answers提交答卷答卷列表GET/api/surveys/{id}/answers分页查看答卷数据导出GET/api/surveys/{id}/export导出Excel统计结果GET/api/surveys/{id}/stats统计每道题选项分布接口设计时需要注意一个问题获取填写页的接口/fill和获取管理详情的接口/manage必须分开。前者面向填答者只返回题目结构不返回任何统计信息后者面向管理员包含问卷状态、回收数量、创建时间等管理数据。如果共用一个接口要么填答者会看到多余的字段要么管理端还得做数据裁剪两条路都很别扭。2.3 数据库层面的经验逻辑删除要克制、字段类型要讲究这个系统里我大量使用了逻辑删除——deleted字段标记删除而不是物理DELETE。好处是误删后还能恢复坏处是每个查询都要带上deleted 0条件。我在MyBatis里统一配置了一个全局拦截器自动在SELECT语句中加入过滤条件这才没有把自己逼疯。另一个值得提醒的是使用utf8mb4字符集。问卷内容中经常出现生僻字或特殊符号UTF-8的utf8字符集在MySQL里存4字节字体会报错而utf8mb4可以安全存储包括emoji在内的所有字符。这个是我的老朋友了——早期一个字符集问题会导致整张表的数据无法写入。数据库连接参数也要注意加上时区配置spring.datasource.urljdbc:mysql://localhost:3306/survey_system?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai不加serverTimezone时在高版本的MySQL驱动下会直接报连接失败或时间错乱8小时的问题。这个坑准确率极高几乎每个首次搭SpringBootMySQL的人都会遇到。3. Vue前端实现与权限集成从能跑到好用的分水岭后端接口定了前端更多是体力活但也有几个关键决策会影响整个系统的使用感受。3.1 前端技术栈与目录组织前端用的是Vue 3 Composition API Vue Router Pinia Element Plus。选择Composition API而非Options API是因为问卷编辑器的状态交互比较复杂逻辑按功能拆分后setup函数里能更直观地组织代码。Pinia作为状态管理主要用来存储当前登录用户信息和问卷编辑中的临时状态。目录结构方面我按页面 组件 API三层组织三个核心页面是SurveyList.vue问卷列表页展示状态、回收数量支持新建、复制、删除操作SurveyEdit.vue问卷编辑器拖拽或点击添加题目实时预览SurveyFill.vue问卷填写页渲染题目并校验必填项每个页面配一个API模块比如src/api/survey.js里集中封装所有后端接口的调用这样后端接口路径调整时只需改一个文件。3.2 问卷填写页渲染器是核心其他都是外围填写页的技术含量在于动态渲染。后端返回的题目结构是数组题型的question_type字段决定了前端用哪个组件去渲染template div v-forquestion in survey.questions :keyquestion.id single-choice v-ifquestion.questionType SINGLE :questionquestion v-modelanswers[question.id] / multi-choice v-else-ifquestion.questionType MULTI :questionquestion v-modelanswers[question.id] / text-input v-else-ifquestion.questionType TEXT :questionquestion v-modelanswers[question.id] / !-- 其他题型分支 -- /div /template这里有一个非常实用的小技巧v-model绑定的是一个整体对象answers以question.id为键。这样在提交时整个题目答案对象直接组装成后端需要的JSON格式一步到位。不用担心字段顺序问题后端已经按sort_no排好序了。3.3 前端的必填校验与题型联动必填校验是问卷填写页的核心交互逻辑。Element Plus本身有Form的校验功能但对动态生成的题目列表手写校验规则更灵活。我的做法是在点击提交按钮时遍历survey.questions若isRequired为true且对应答案为空则弹出提示并滚动到第一道未填写的题目处。有些问卷还会做题目联动——比如第3题选择是才显示第4题。这个功能放在前端做会比较顺手因为纯后端需要重新组织题目树而前端监听选项变化后直接控制v-show即可。但要注意联动会带来被隐藏题目的答案为空的情况后端校验时必须能区分题目不可见不校验与题目可见但未填写两种状态。解决方案是在提交前前端把不可见题目的questionId从答案对象里剔除后端只校验提交上来的题目。这样简单直接不会因为联动逻辑把后端搞得一团糟。3.4 数据可视化统计页别用表格怼脸问卷回收后的统计展示如果只用表格就会显得很后端审美。我在这个系统里引入了ECharts用饼图展示单选题的选项分布、柱状图展示多选题的选择频次、条形图展示评分题的平均分分布。接口的/stats返回结构这样设计[ { questionId: 1, questionTitle: 您对本平台的满意度, type: SINGLE, optionStats: [ { optionContent: 非常满意, count: 120 }, { optionContent: 一般, count: 34 }, { optionContent: 不满意, count: 8 } ] } ]前端拿到这个结构后直接喂给ECharts几乎不需要二次加工。这里有个思路值得分享统计接口的返回结构最好是前端无须再聚合的结构因为聚合逻辑放数据库里用SQL做效率最高放前端做则又慢又容易出错。4. 从本机到公网部署数据库初始化、构建与进程守护本地跑通和真正可用之间还差着部署这一大步。很多人在本地一切正常一到服务器就各种起不来大多是环境问题而非代码问题。4.1 MySQL初始化与账号配置首次部署要先建库、建账号。不要用root账号去连业务数据库这是一个基本但总有人忽略的习惯CREATE DATABASE survey_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE USER survey_userlocalhost IDENTIFIED BY 你自己设的密码; GRANT ALL PRIVILEGES ON survey_system.* TO survey_userlocalhost; FLUSH PRIVILEGES;系统里内置的建表脚本和初始化数据比如默认管理员账号都在schema.sql里。导入时如果使用的是较新版本的MySQL客户端直接执行mysql -u survey_user -p survey_system schema.sql提示如果执行时报Unknown collation: utf8mb4_0900_ai_ci错误说明你的MySQL版本低于8.0把建表语句里的utf8mb4_0900_ai_ci改成utf8mb4_general_ci即可。4.2 后端打包与启动SpringBoot项目用Maven打包很容易mvn clean package -DskipTests打包会生成target/survey-backend.jar。放到服务器上后最不建议的做法是直接java -jar裸奔一旦终端关闭进程就没了。生产环境我习惯用systemd托管[Unit] Descriptionsurvey-backend Afternetwork.target [Service] Userwww ExecStart/usr/bin/java -jar /opt/survey/survey-backend.jar --spring.profiles.activeprod Restartalways RestartSec10 [Install] WantedBymulti-user.target配好后依次执行systemctl daemon-reload systemctl enable survey-backend systemctl start survey-backendRestartalways很重要当进程因为OOM或意外异常崩溃时systemd会自动拉起服务不用半夜被报警电话吵醒。4.3 前端构建与Nginx配置前端构建npm install npm run build产物在dist/目录下。把这个目录部署到Nginx即可。关键是反向代理配置要同时处理两件事一是把/api前缀的请求转发到后端二是前端路由使用history模式时的try_files回退server { listen 80; server_name survey.example.com; root /opt/survey/dist; index index.html; location /api/ { 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; proxy_set_header X-Forwarded-Proto $scheme; } location / { try_files $uri $uri/ /index.html; } }try_files $uri $uri/ /index.html这个配置是Vue Router用history模式部署时的经典配置。没有它刷新页面或直接访问子路径就会出现404很多人在这里卡了很久。4.4 部署后的自检清单部署完成不代表可以收工建议按以下顺序做一轮自检访问登录页确认前端资源正常加载打开浏览器F12看有没有404登录后创建一个草稿问卷添加两道题保存后刷新页面确认数据还在发布问卷打开填写页用一个无痕窗口匿名提交一份答卷注意如果系统强制要求登录则需要登录另一个账号回到管理端进入统计页确认回收数量为1、图表正常渲染点击导出确认Excel文件成功下载5. 实战中容易踩坑的五个细节都是我走到一半才发现的这部分内容是这套系统开发过程中最值得记录的教训全部来自真实调试经历。5.1 MyBatis的mapUnderscoreToCamelCase必须开Java属性用驼峰命名questionType数据库字段用下划线命名question_type如果不在application.yml里打开驼峰映射查询回来所有字段都是null程序却不会报错排查起来非常痛苦。mybatis.configuration.map-underscore-to-camel-casetrue这是新手最常问的为什么查出来是null类问题之一。5.2 跨域问题本地联调必配前后端分离开发时前端跑在localhost:5173后端跑在localhost:8080端口不同就会触发跨域。我在后端做了全局CORS配置允许本地开发地址访问Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOrigin(http://localhost:5173); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }注意setAllowCredentials(true)时addAllowedOrigin不能使用*通配必须写明确地址否则前端请求会直接被浏览器拦截。5.3 答卷防重幂等设计问卷系统的并发量虽然远低于电商秒杀但重复提交的风险是真实存在的。用户在提交答卷时手一抖连点两次数据库就可能出现两份一模一样的答卷。我在提交接口上做了一个幂等处理前端生成一个requestId随提交请求一起发送后端以(surveyId, respondentId, requestId)作为唯一索引插入冲突时直接返回成功。这个方案不依赖Redis纯数据库层面就能解决问卷这个低并发场景完全够用。5.4 导出的Excel别用老掉牙的格式导出模块我最初用了HSSFWorkbook后来发现它只支持Excel 97-2003格式上限是65536行。虽然问卷数据很少到这个量级但总归不体面。后来换成了SXSSFWorkbook流式处理数据量大的时候内存占用也不高导出的还是.xlsx新格式。核心代码大概是SXSSFWorkbook workbook new SXSSFWorkbook(100); Sheet sheet workbook.createSheet(答卷数据); // 填充表头与行数据 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenameanswers.xlsx); workbook.write(response.getOutputStream()); workbook.dispose();注意用完之后一定要调用dispose()方法释放临时文件否则在大量导出时会产生很多临时文件占满磁盘。5.5 问卷复制功能的价值被低估实际运营中问卷员经常需要做几乎一样的问卷——只是改了标题或增删几个选项。最初我没有做复制功能结果运营人员每次都要从头搭建一份效率极低。后期我加了一个复制接口传入源问卷ID后端深拷贝问卷的题目和选项结构生成一份新的草稿。这个功能写起来不复杂因为题目和选项都是结构化数据无非是循环INSERT但带来的满意度提升确实非常明显。6. 这套系统后续还能怎么长几个我认为值得继续投入的方向问卷系统做到现在这个程度基础能力已经齐全但距离一个真正好用的问卷产品还有差距。如果后续要继续迭代我个人会优先考虑以下三个方向。6.1 问卷逻辑分支引擎目前系统支持最简单的题目联动即选择题的某个选项触发显示/隐藏其他题目。但真正的问卷编排经常需要更复杂的逻辑根据上一题答案跳转到指定题目、多条件组合判断A选择是且B填空包含某关键词时才出现C题、根据答案分支计分等。这些逻辑如果仍然靠前端v-show控制一旦分支条件复杂起来渲染器会变成一团乱麻。更合理的做法是在后端设计一个SurveyLogic表存条件表达式与目标题目前端只负责翻译执行。这是一个较大工程但也是问卷系统从能用走向专业的分水岭。6.2 答卷进度草稿与断点续填长问卷在移动端的填写场景很常见用户填了一半切到微信回消息回来以后发现页面被关闭之前的答案全部丢失——这种体验基本等于劝退。后续可以考虑给每个回答者分配一个作答会话前端定时把答案草稿POST到后端用户再次进入填写页时自动恢复上次填写进度。技术上并不复杂一张草稿表就能搞定但需要处理好并发覆盖策略。6.3 简单的答卷质量筛查数据回收多了以后清洗会变成一个真需求。比如所有题目都选第一个选项的刷单答卷、填答时长小于30秒的急速答卷、同一IP多次提交的答卷往往是低质量数据。虽然做复杂的质控算法没有必要但在答卷列表里给这类数据打一个可疑标签让问卷管理员在导出时剔除会是一个非常实用的功能。问卷系统看似是一个增删改查的CRUD项目但真正走进业务场景后会意识到它的复杂度不来自技术而来自对问卷这种信息收集方式的理解。题型之间的组合、投放的生命周期、答卷数据的可信度、统计分析的准确度每一处细节都代表着实际业务中的真实诉求。上面这套基于SpringBoot Vue MySQL的实现在我看来是一道恰到好处的平衡线既能覆盖绝大多数内部调研场景又不会因为过度设计导致维护困难。如果你正准备本地搭建一套问卷系统来学习或者团队正需要一个可私有化部署的收集工具照着这个思路跑通一遍你会对前后端分工、数据建模和状态流转有一次挺完整的认识。我在开发过程中最大的体会是把状态和题型这两个地基打牢后面每一步都会走得顺畅很多。