恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot2+Vue3在线问卷系统:从数据库设计到统计报表实战
首页
资讯中心
/
SpringBoot2+Vue3在线问卷系统:从数据库设计到统计报表实战
SpringBoot2+Vue3在线问卷系统:从数据库设计到统计报表实战
发布时间:2026/10/22 9:45:35
在线问卷调查系统光听名字像是个平平无奇的管理系统但真把它从前端做到后端、从数据库设计做到统计报表你会发现这个“小项目”的颗粒度一点都不小。我见过不少人拿它练手、做作品集或者直接当成一个学期项目来交付结果卡在三个地方题型怎么扩展、答卷怎么防重复提交、统计数据怎么算得准。这套基于SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的方案算是我觉得性价比比较高的一套组合。SpringBoot2生态成熟、跟JDK8配合很稳Vue3组合式API写复杂交互比Vue2舒服得多MyBatis-Plus把单表CRUD和条件构造省了一大半代码MySQL8在统计查询和字符集支持上又比5.7好用不少。这篇文章我会按照“需求拆解 - 数据库设计 - 后端核心实现 - 前端动态表单 - 部署上线 - 避坑实录”这条线完整讲一遍适合正在做类似系统、想真正跑通前后端全流程的朋友参考。1. 先拆需求这个系统到底在解决什么问题1.1 核心业务链路与角色划分先别急着建工程把业务链路画清楚。这个系统其实只有两个核心角色管理端用户和答题用户。管理端的动作是创建问卷、编辑题目、发布、关闭、查看统计答题端的动作是打开链接、填写、提交、看到成功页面。就这么几件事但展开后每一件都有细节。我习惯先把业务链路串成一条线管理员创建空白问卷 - 添加不同题型 - 设置题目是否必填 - 保存草稿 - 发布生成答题链接 - 答题人打开链接 - 动态渲染题目 - 提交答卷 - 校验并落库 - 管理端实时刷新统计。这里最容易忽略的是“草稿”和“已发布”的边界草稿可以随便改题目但已发布之后再改题会直接影响正在填写或者已经提交的答卷所以这个边界一定要在设计阶段想清楚。1.2 技术选型不是拍脑袋是场景倒逼这套技术栈不是随便拼出来的。SpringBoot2.7选它的原因很直接稳定、资料多、跟MyBatis生态配合成熟而且很多服务器上的JDK还停留在8SpringBoot3强制要求JDK17迁移成本一下子变高。Vue3 Vite Element Plus用来做后台管理界面和答题页都够用组合式API在写动态表单这类逻辑时比选项式API更清爽。MyBatis-Plus的价值在于单表CRUD和条件构造器像问卷的翻页列表、按状态查询这种操作几乎不用手写SQL。但它不是万能药多表统计和复杂查询还是需要自己写SQL。MySQL8.0主要看中三个能力JSON类型、窗口函数、更完善的索引优化。问卷系统的题目选项结构天然适合JSON存储但为了统计方便我最终还是把题目拆成了题目和选项两张标准表这个取舍后面会专门聊。1.3 功能模块地图别一上来就写代码以这套项目为参照功能模块可以分成四块用户认证模块、问卷管理模块、答题模块、统计模块。认证模块负责登录注册、JWT发放与拦截问卷管理模块负责问卷、题目、选项的增删改查、发布和关闭答题模块负责动态渲染、答案收集、防重复提交统计模块负责按单选、多选、填空做聚合输出表格和图表数据。我建议开发顺序是数据库表结构先行然后做认证再做问卷管理和答题最后做统计。统计排最后不是因为不重要而是它依赖前面的数据沉淀先跑通完整录入链路后面统计才能验证结果对不对。文档里一般会配套数据库脚本和接口清单按这个顺序来推进多数情况能少走弯路。2. 数据库设计问卷系统的地基怎么打2.1 核心表结构与字段设计数据库设计决定了后面开发的舒适度。我按最小可用方案给出六张核心表用户表sys_user、问卷表questionnaire、题目表question、选项表question_option、答卷主表answer_record、答卷明细表answer_detail。CREATE TABLE questionnaire ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL, description text, status tinyint NOT NULL DEFAULT 0 COMMENT 0草稿 1已发布 2已关闭, start_time datetime DEFAULT NULL, end_time datetime DEFAULT NULL, creator_id bigint NOT NULL, is_deleted tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), KEY idx_creator_status (creator_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;题目表里存题型类型比如single_choice、multiple_choice、fill_blank再加一个sort_order控制排序is_required控制是否必填。选项表只关联question_id按sort_order展示顺序。answer_record表存一次提交的总体信息包括问卷ID、答题人标识、提交时间、IP地址answer_detail表逐题存答案选择题记录option_id填空题记录answer_text。这样设计的好处是统计维度非常清晰坏处是插入时要把一次答卷拆成多条明细所以事务得包好。MySQL8这边建议统一utf8mb4否则用户填写的emoji入库后会变成问号。这个问题在表单类项目里很常见处理成本也很低造库时顺手指定字符集就能避免。2.2 状态机设计为什么发布和关闭要用状态字段大部分新手会把删除问卷设计成直接DELETE发布就设置一个is_published布尔值。这些做法在演示项目里没问题但到真实场景会很别扭。我推荐用status字段管理生命周期0草稿、1已发布、2已关闭配合逻辑删除is_deleted控制显示。为什么要多一个“已关闭”因为问卷发出去之后答题链接可能已经散布到很多地方直接删除会损害已答用户的数据真实性也拿不到完整统计。关闭是“停止回收答卷”而不是“销毁问卷”。发布时应校验题目不能为空、至少有一道必答题关闭时应校验它当前不是草稿否则状态改来改去就乱了。状态流转最好只允许草稿到发布、发布到关闭关闭不可逆除非重新复制一份新问卷。2.3 统计查询的SQL思路先想清楚怎么算再做表数据统计看起来简单实际最容易算错。单选题统计可以用一条GROUP BY搞定多选题由于一个题对应多个选项统计时需要先拆开再聚合填空题基本只能做数量汇总和文本导出不适合做图。我的设计约定是答题明细表里每个选择都存一行记录多选就会产生多条明细。这样多选统计也能用GROUP BY做前端拿到的就是“每个选项被选了多少次”的分布数据。-- 单选统计 SELECT qo.option_text, COUNT(ad.option_id) AS cnt FROM answer_detail ad JOIN question q ON ad.question_id q.id JOIN question_option qo ON ad.option_id qo.id WHERE q.id #{questionId} GROUP BY qo.id, qo.option_text ORDER BY cnt DESC;千万不要把多个选项ID拼到一个字符串字段里再统计那不是做不到而是每次统计都要拆字符串、去重、聚合写出来的SQL又慢又难维护。3. 后端核心实现从接口到业务的完整链路3.1 认证授权JWT 拦截器这套组合怎么落地管理端必须登录答题端可以匿名所以认证设计要分开处理。我建议采用JWT 自定义HandlerInterceptor的方案。登录接口校验用户名密码时密码用BCrypt加密存储而不是MD5登录成功发放JWT前端存在localStorage里axios拦截器统一加Authorization头。拦截器需要排除登录、注册、公开问卷详情、公开提交答卷、公开统计结果这些路径。JWT实现有几点要注意密钥放配置文件不要硬编码过期时间不要太长2小时到24小时之间比较合理配合前端401重定向处理拦截器配置时排除清单最好集中管理方便后续加白名单。Configuration public class WebMvcConfig implements WebMvcConfigurer { private final JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/register, /api/questionnaire/public/**, /api/answer/submit ); } }这样配置后管理端的问卷增删改查接口自动受限答题页的公开接口保持可访问不需要在业务代码里到处写权限判断。3.2 问卷管理接口CRUD不难难在状态流转后端核心接口无非是创建问卷、分页查询、修改、发布、关闭。用MyBatis-Plus之后单表操作很省事page(new Page(), wrapper)就能得到分页列表。但要注意条件构造器的越权问题查询列表时必须传入当前登录用户ID组装进creator_id条件否则别人创建的问卷也会被查出来。发布动作建议单独写一个接口比如PUT /api/questionnaire/{id}/publishService里先查状态如果是草稿才允许发布同时校验题目数量和必填题存在性关闭接口同理。这些状态判断放Service层统一管理不要散落在Controller里。另外修改已发布问卷时要考虑是否同步影响历史答卷我建议已发布问卷默认锁定题目编辑只允许改标题、描述、截止时间避免数据口径错乱。3.3 答卷提交事务、唯一索引和严谨校验答卷提交是并发和一致性的重灾区。我的方案是在answer_record表增加“同一问卷 同一答题人标识”的唯一索引答题人标识可以是UUID或用户ID。用户重复提交同一链接时第二次插入直接触发DuplicateKeyException业务层捕获后返回“您已提交过该问卷”。这个方案比纯Redis限流简单可靠对单体应用完全够用。提交流程用Transactional包住三件事插入answer_record主表循环插入answer_detail明细更新questionnaire表的answer_count。事务解决了一个隐藏问题如果明细插到一半失败主表也不会被留下统计里不会出现脏数据。校验方面选择题答案不能存NULL填空题要trim超长文本要截断这些细节点做不好后面统计和导出都会出问题。3.4 数据统计接口如何用聚合查询生成报表统计接口返回三类数据总答卷数、单选题选项分布、多选题选项分布。用MyBatis-Plus的QueryWrapper处理单表统计非常方便例如总答卷数selectCount(new QueryWrapper ().eq(questionnaire_id, id))。单选分布就写在Mapper里用上面那段JOIN SQL直接查询。多选统计如果按“每行一个选项”存储聚合方式跟单选项基本一样只是前端展示时把多选改成横向组合图。页面拿到分布数据后用ECharts画饼图、柱状图和表格一个接口就能支撑整个统计页面。接口返回结构建议直接设计成[{name: 选项A, value: 30}]这种格式省得前端再转换一次。4. Vue3前端动态表单和答题体验的实现重点4.1 动态表单组件一种题型一个组件Vue3做问卷系统最大的挑战是题目是动态的你不能在页面里写死十个输入框而是要根据后端返回的问卷JSON动态渲染。我的做法是定义一个QuestionRenderer.vue接收一个question对象内部用component :is动态切换SingleChoice、MultipleChoice、FillBlank三个子组件。template component :iscomponentByType(question.type) :questionquestion :model-valuemodelValue update:model-value$emit(update:modelValue, $event) / /template script setup import SingleChoice from ./SingleChoice.vue import MultipleChoice from ./MultipleChoice.vue import FillBlank from ./FillBlank.vue const map { single_choice: SingleChoice, multiple_choice: MultipleChoice, fill_blank: FillBlank } function componentByType(type) { return map[type] || null } /script每个子组件只关心自身的值通过props和emit向上传递。父组件维护一个answers响应式对象key是questionIdvalue是选项ID数组或文本。这种结构的好处是将来要加评分题、排序题只要增加一个新组件并注册到映射表里原有渲染逻辑完全不用动。4.2 答题数据的收集、校验与进度保存答题页除了渲染还要处理校验和进度保存。提交前遍历question列表如果是必填题且答案为空要提示用户并定位到对应题号多选题要校验至少选一个。进度保存我用localStorage每当answers发生变化就存一份带问卷ID的缓存。用户意外刷新页面后重新进入答题页可以恢复上次未提交的答案。这里有一个细微但影响体验的点恢复进度前要确认问卷没有更新版本否则旧进度可能跟新题型对不上。简单做法是在localStorage里同时存问卷ID和更新时间进页面时拉取问卷信息比对如果版本不一致就清掉旧缓存。这个细节很多现成系统都没做但实际问卷推广时用户真的会放半天再回来填。4.3 管理端统计页用ECharts把数据可视化管理端统计页不算复杂但要注意数据格式对齐。后端统计接口返回的字段结构要提前想好否则前端ECharts转换数据时要写一堆逻辑。页面加载时还要处理“问卷还没有任何答卷”的空状态不然图表组件会直接报错。封装一个useStatistics(questionnaireId)组合式函数负责拉数据、loading、错误处理多个图表组件复用同一份数据源避免每个图表各自发请求造成重复加载。实测下来把请求合并成一个接口远远优于每个图表一个接口后端压力小前端状态也干净。5. 部署实战从本地到服务器全过程5.1 环境准备与配置要点部署这套项目需要四样东西JDK8、Maven、Node16、MySQL8.0。JDK版本和SpringBoot版本要匹配SpringBoot2.7配合JDK8最稳别轻易上JDK17。MySQL8.0注意数据库驱动版本现在Maven坐标是com.mysql:mysql-connector-j注意别再用老坐标了。application.yml里最容易出错的是数据源URL。MySQL8建议写成这样spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/survey?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 你的密码allowPublicKeyRetrievaltrue这个参数很多人不写用MySQL8连接时偶尔会报Public Key Retrieval is not allowed尤其首次连接或密码变更之后。serverTimezone不写会默认用服务器本地时区跟应用容器时间对不上统计就会出现8小时偏差。5.2 前后端联调与跨域开发代理和生产反向代理开发阶段我用Vite代理解决跨域生产环境用Nginx反向代理这样前端不用写死后端IP后端也不用临时打开CORS。Vite配置大致是server.proxy把/api转发到http://localhost:8080答题页面地址是前端自己的路由后端真正处理数据时再接MySQL。如果非要后端开CORS建议只配置允许的源头不要用allowedOrigins(*)加allowCredentials(true)这种组合浏览器会直接拒绝而且有安全隐患。我的标准做法是定义一个CorsFilter配置类允许的域名从配置文件读取换环境不用改代码。开发阶段则用代理最简单最干净。5.3 打包构建与Nginx部署一次跑通全流程后端执行mvn clean package -DskipTests生成jar后直接用java -jar app.jar --spring.profiles.activeprod运行。前端执行npm run build生成dist目录。Nginx配置里有两个重点location /指向dist目录并且try_files $uri $uri/ /index.html这是为了适配Vue Router的history模式location /api/里做反向代理推荐写proxy_pass http://127.0.0.1:8080;不带末尾斜杠确保/api前缀原样传给后端。如果服务器上同时跑Java进程和Nginx记得放行8080端口和前端80/443端口。线上环境我还会用systemd守护Java进程进程崩了自动重启。[Unit] Descriptionsurvey-backend Afternetwork.target [Service] ExecStart/usr/bin/java -jar /opt/survey/app.jar --spring.profiles.activeprod Restartalways Userwww-data [Install] WantedBymulti-user.target部署完以后一定要做一个冒烟测试创建问卷、发布、填写、提交、查看统计五个动作走一遍比看十遍日志都有用。6. 避坑指南我亲手踩过的几个典型坑6.1 MySQL8连不上多半是时区或SSL问题用MySQL8最常报的错就是Server returns invalid timezone和SSL connection error。前者在JDBC URL加serverTimezoneAsia/Shanghai解决后者加useSSLfalse解决。如果连的是需要公钥验证的账号还要加allowPublicKeyRetrievaltrue。这三个参数建议直接写进标准配置里省得后面莫名报错。字符集问题也要提前处理建库时指定CREATE DATABASE survey DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci能避免中文乱码和emoji变问号。这个问题在Windows本机和Linux服务器之间特别容易爆发因为两边默认字符集可能不同。6.2 MyBatis-Plus逻辑删除容易让唯一索引失效MyBatis-Plus开启逻辑删除后默认会在查询时带上is_deleted0但数据库唯一索引不会因为逻辑删除而自动失效。举例answer_record表给questionnaire_id respondent_id建了唯一索引逻辑删除一条答卷后再插入同一个人对同一问卷的新答卷唯一索引依然会冲突。这个坑在做“撤销答卷再重填”功能时尤其致命。我的建议是防重复提交场景不要用逻辑删除唯一索引的组合要么物理删除要么给答卷增加response_status状态字段只允许撤回标记统计时过滤掉无效记录。逻辑删除适合管理端的素材类数据不适合高频写入的答卷流水数据。6.3 大批量题目和选项插入太慢问卷题目很多时最容易出现逐条insert进数据库整体慢到爆炸。我的做法是用MyBatis-Plus提供的批量插入能力或者自己写XML的foreach批量insert。批量插入时还需要在JDBC URL里加上rewriteBatchedStatementstrue否则MySQL驱动默认不会真正批量执行性能提升有限。还有一个隐藏问题编辑问卷时先删掉旧题目再插入新题目如果没在同一个事务里中途失败后题目会剩一半。我的方案是整个问卷编辑保存接口用Transactional先逻辑删除旧题目再重新插入查询时只查is_deleted0既能保存历史答卷关联又能保持界面数据干净。6.4 富文本描述和XSS处理问卷描述、题目内容如果用富文本编辑器前端展示时会埋下XSS风险。后端入库时要做HTML白名单过滤只保留段落、加粗、图片标签移除script、iframe等危险标签。更简单粗暴一点答题页直接不渲染富文本用纯文本展示把编辑器功能砍掉风险最低。如果确实需要富文本我见过比较实用的方案是引入HTML清洗工具类在保存接口统一过滤。记住不要相信前端传过来的任何内容标题长度限制在200字符内描述限制在2000字符内防止超长文本拖垮页面或数据库。6.5 统计结果能缓存就别实时硬算问卷一旦被大量转发统计接口会被频繁点击。每次统计都去做GROUP BY和JOIN聚合数据库压力不小尤其问卷有几十道题、答卷有几万条时页面可能白屏好几秒。我的做法是提交答卷事务里顺手更新问卷表的answer_count统计页的分布数据加一个5分钟级别的本地缓存。要保证缓存和数据库一致性最简单的方式是用更新时间戳作为缓存key只有questionnaire.update_time变化时才重新查库。这个方案比引入Redis更轻单体应用完全够用。等数据量真的大到扛不住了再上异步统计也不迟。6.6 常见问题速查表问题现象可能原因解决方案后端启动报找不到数据源JDBC URL参数配置错误或驱动版本不匹配检查driver-class-name和URL确认mysql-connector-j版本前端页面有数据但接口401JWT过期或请求未带token检查axios拦截器请求头登录后刷新token保存问卷后题目顺序乱查询时没有按sort_order排序接口查询统一ORDER BY sort_order, id多选题统计数量翻倍answer_detail多选存多条明细直接COUNT按question_id option_id去重或使用DISTINCTVue3打包后刷新404前端router使用history模式但Nginx没有try_files配置location / try_files $uri $uri/ /index.html插入数据库的时间少了8小时服务器时区与JDBC URL不一致统一serverTimezoneAsia/Shanghai并校准服务器时区最后说点个人体会。做问卷系统其实不缺技术难点真正决定项目完成度的是那些边界情况状态能不能严谨流转、重复提交能不能防住、统计数据口径能不能对得上。这套SpringBoot2 Vue3 MyBatis-Plus MySQL8.0的组合算是我做完几个类似项目后觉得最适合练手和交付的搭配。如果你正在调这套代码建议先把线上统计口径和防重复提交搞扎实这两个地方稳定了整个项目的基本盘就不会差。