ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue+MySQL在线问卷调查系统完整落地实践

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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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 Result<Long> 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.url=jdbc:mysql://localhost:3306/survey_system?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai

不加serverTimezone时,在高版本的MySQL驱动下会直接报连接失败或时间错乱8小时的问题。这个坑准确率极高,几乎每个首次搭SpringBoot+MySQL的人都会遇到。

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-for="question in survey.questions" :key="question.id"> <single-choice v-if="question.questionType === 'SINGLE'" :question="question" v-model="answers[question.id]" /> <multi-choice v-else-if="question.questionType === 'MULTI'" :question="question" v-model="answers[question.id]" /> <text-input v-else-if="question.questionType === 'TEXT'" :question="question" v-model="answers[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_user'@'localhost' IDENTIFIED BY '你自己设的密码'; GRANT ALL PRIVILEGES ON survey_system.* TO 'survey_user'@'localhost'; 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] Description=survey-backend After=network.target [Service] User=www ExecStart=/usr/bin/java -jar /opt/survey/survey-backend.jar --spring.profiles.active=prod Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

配好后依次执行:

systemctl daemon-reload systemctl enable survey-backend systemctl start survey-backend

Restart=always很重要,当进程因为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 部署后的自检清单

部署完成不代表可以收工,建议按以下顺序做一轮自检:

  1. 访问登录页,确认前端资源正常加载(打开浏览器F12看有没有404)
  2. 登录后创建一个草稿问卷,添加两道题,保存后刷新页面确认数据还在
  3. 发布问卷,打开填写页,用一个无痕窗口匿名提交一份答卷(注意如果系统强制要求登录,则需要登录另一个账号)
  4. 回到管理端,进入统计页,确认回收数量为1、图表正常渲染
  5. 点击导出,确认Excel文件成功下载

5. 实战中容易踩坑的五个细节:都是我走到一半才发现的

这部分内容是这套系统开发过程中最值得记录的教训,全部来自真实调试经历。

5.1 MyBatis的mapUnderscoreToCamelCase必须开

Java属性用驼峰命名(questionType),数据库字段用下划线命名(question_type),如果不在application.yml里打开驼峰映射,查询回来所有字段都是null,程序却不会报错,排查起来非常痛苦。

mybatis.configuration.map-underscore-to-camel-case=true

这是新手最常问的"为什么查出来是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; filename=answers.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的实现在我看来是一道恰到好处的平衡线:既能覆盖绝大多数内部调研场景,又不会因为过度设计导致维护困难。

如果你正准备本地搭建一套问卷系统来学习,或者团队正需要一个可私有化部署的收集工具,照着这个思路跑通一遍,你会对前后端分工、数据建模和状态流转有一次挺完整的认识。我在开发过程中最大的体会是:把"状态"和"题型"这两个地基打牢,后面每一步都会走得顺畅很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进