
在线问卷调查看着像是个烂大街的功能模块可真正从零到一把它跑通踩过的坑绝对比想象中多。标题里这个组合——SpringBoot后端、Vue前端、MySQL存储——目前中小型管理系统里最主流的三件套也是做课程设计、公司内部调研工具、产品MVP阶段被问得最多的一套方案。这篇文章我不讲虚的就拿这个“可直接运行”的问卷调查系统当例子把项目从需求拆解、数据库设计到后端接口、前端页面再到最后的联调部署一条线撸清楚。先说这个系统能干什么支持用户注册登录、问卷的创建编辑与发布回收、单选多选填空等多种题型、用户答卷提交、后台数据统计展示。说白了就是一个完整的发问卷-填问卷-看结果闭环。适合谁参考正在用SpringBoot和Vue做课程设计或毕设的同学想在公司内部快速搭一个调研工具的Java后端开发以及准备从前端转全栈、想找一个完整项目练手的朋友。这篇文章会尽量写得直白数据库建表SQL、后端核心接口、前端页面逻辑都会拆开讲你可以直接照着复现。1. 项目整体构思与核心需求拿到一个管理系统第一步永远不是写代码而是把到底要解决什么问题想清楚。问卷调查系统要解决的痛点其实很明确传统的纸质问卷和Excel收集方式效率低、统计麻烦、没法实时回收。而在线问卷要提供的能力核心就三条问卷的创建和发布、答题人的提交和填写、创建人对结果数据的查看和分析。基于这三条核心需求角色模型自然就分成了两类管理员或者叫问卷创建者和普通用户答题者。有些系统还会再细分出超级管理员但大多数场景下两个角色足够用。从功能清单上看这个项目包含的模块大致如下用户模块注册、登录、个人信息查看问卷模块创建问卷、编辑题目、设置问卷标题和说明、保存草稿、发布、关闭问卷题目模块支持单选、多选、单项填空、下拉选择等常见题型答卷模块答题人提交答卷、问卷创建人查看回收状态统计模块按题目维度统计选项占比、生成图表这里我特别想强调一个设计上的取舍问卷和题目的关系一定要设计成问卷-题目-选项三级结构而不是把题目直接塞在问卷字段里。可能有人觉得问卷通常题目不多直接用JSON字段存题目数组更省事但当你后面想做单题级的统计、想做某个题目的复用、想给不同题目设置不同逻辑跳转时JSON方案会让你改到怀疑人生。规范的表结构短期内看着繁琐长期维护成本反而最低。这种问卷-题目-选项的结构对应到实物上可以类比成一本练习册练习册本身是问卷每一道题是练习册里的题目题目的选项就是选项表里的记录。练习册可以有很多道题每道题可以有很多个选项但选项只属于某一道题。理解了这个关系后面数据库表怎么建就清楚了。在做技术选型时我坚持用SpringBoot而不是SSHStrutsSpringHibernate原因是SpringBoot的自动配置和嵌入式容器让项目启动成本降到几乎为零尤其在前后端分离的架构下后端只需要专注于提供JSON接口完全不需要关心页面渲染。Vue这边也同理用它做单页应用页面切换不需要刷新答题这种交互密集型的场景体验会好很多。MySQL则属于稳定可靠的常用选择生态成熟三段式功能存储数据、索引查询、聚合统计都能支撑得住。2. 数据库设计核心表就五张别贪多我见过不少同学在做这类项目时一上来就设计出十几张表很多表根本没用上。在线问卷调查系统的表核心其实就五张用户表、问卷表、题目表、选项表、答卷表。最多根据统计需要再加一张答卷明细表。这里我把每张表的字段逻辑和作用讲透你拿到之后可以按自己的业务做增删。先看用户表。字段无非是id、username、password、nickname、create_time这些需要注意的点是密码字段千万别存明文用BCrypt或者MD5加盐处理。如果是课程项目MD5加盐已经够交差但放在生产环境我会强烈建议Spring Security自带的BCryptPasswordEncoder因为彩虹表攻击对MD5是降维打击BCrypt的随机盐能有效对抗这类风险。问卷表是第二张核心表。字段包括id、title、description、status、creator_id、create_time、publish_time、end_time。status字段我建议用tinyint存0表示草稿1表示发布中2表示已关闭。这里不要用字符串存状态看似可读性高了但后续写JPQL或MyBatis的SQL判断非常啰嗦而且字符串容易写错数字枚举反而更可靠。creator_id关联用户表用于标识这份问卷归属哪个创建人。题目表设计得稍微讲究一点。字段有id、questionnaire_id、subject、question_type、sort_order。question_type用int存我一般这样约定1表示单选2表示多选3表示填空4表示下拉。sort_order这个字段很多新手会忽略但其实至关重要因为问卷的题目顺序必须由字段显式控制而不是靠数据库默认查询顺序否则后插入的题跑前面去整个问卷逻辑就乱了。选项表相对简单id、question_id、option_text、option_order。只有单选题、多选题、下拉题才需要选项表填空题不需要这个可以在接口层做校验。答卷表是整个系统里业务逻辑最重的一张表。如果问卷和题目是一对多那问卷和答卷就是一对多题目和答卷中的具体回答又是多对多。为了不让多对多关系驾驭复杂度我额外加一张答卷明细表来存每一道题的答案。主表就叫answer_record记录id、questionnaire_id、user_id注意这里允许为空因为匿名问卷也应支持填写、submit_time。明细表叫answer_detail字段有id、record_id、question_id、answer_content。单选多选统一把选项id或选项id列表拼成字符串存进answer_content填空就存用户输入的文本这样一张明细表就能兼容所有题型。有些项目还会在明细表里加一个answer_option关联表来存多选的选项关系但我觉得对于这种规模的项目来说过度设计了一个answer_content字段以逗号分隔选项id绰绰有余做统计时在代码里split一下即可。建表的SQL我直接贴一下核心部分CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL, password varchar(100) NOT NULL, nickname varchar(50) DEFAULT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE questionnaire ( id bigint NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL, description varchar(500) DEFAULT NULL, status tinyint NOT NULL DEFAULT 0, creator_id bigint NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, publish_time datetime DEFAULT NULL, end_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关于字符集我强烈建议统一使用utf8mb4而不是utf8。原因很简单MySQL的utf8最多存3个字节像emoji这类4字节字符根本存不进去而问卷填答场景中用户输入法表情是很常见的事。用utf8mb4就不会有这种困扰。数据库表设计这块核心原则就一句话能用简单关联表达清楚的关系绝不用冗余字段和额外中间表去增加复杂度。3. 后端SpringBoot核心接口逐个拆解3.1 项目结构与依赖选型先聊工程结构。我用的是标准的Maven多模块或者单模块分层结构后端按Controller、Service、Mapper、Entity四层来分。实体类不要直接在代码里写一堆setter/getter用Lombok的Data注解省事但有一点必须注意实体类里的日期字段建议用LocalDateTime而不是java.util.DateLocalDateTime配合Jackson序列化时更可控也更容易处理时区问题。Maven依赖这块核心就几个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency个人习惯用MyBatis-Plus做持久层主要是图它自带条件构造器和分页插件简单的CRUD不用手写XML。但如果是复杂多表联查我还是会手写SQL放在Mapper.xml里因为多表关联用代码拼条件会拼到怀疑人生直接用SQL一了百了。3.2 用户认证与权限控制登录认证这块我没有引入Spring Security原因很现实问卷调查系统权限模型很简单用Spring Security配置Security Filter Chain的时间成本反而高于它的收益。我直接用JWT令牌加Spring MVC拦截器就足够了。JWT用jjwt这个库依赖很小API也没有那么繁琐。写一个拦截器做token校验核心逻辑如下public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims Jwts.parser() .setSigningKey(your-secret-key.getBytes()) .parseClaimsJws(token) .getBody(); request.setAttribute(userId, claims.get(userId)); return true; } catch (Exception e) { // token无效继续走拦截逻辑 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录或登录已过期\}); return false; } }这里踩过一个坑JWT密钥硬编码在代码里虽然是课程项目常见的做法但如果项目要上生产密钥一定要挪到配置文件或环境变量里同时用io.jsonwebtoken的Keys.hmacShaKeyFor()生成密钥而不是像我上面示例里简单转字节数组。还要记得给JWT设置过期时间我一般是7天太长不安全太短用户烦。3.3 问卷CRUD与题目管理问卷接口的CURD本质上是三张表联动创建问卷时先插入问卷表拿到问卷id再循环插入题目表题目又循环插入选项表。这个操作必须在同一个事务里否则中途报错会出现孤儿的题目——问卷没建成功题目却留在数据库里了。Transactional不是随便加的要明确知道加了它之后数据库层面发生了什么事务开始、执行多条SQL、全部成功则提交、任何一个失败则全量回滚。这个机制保证的是数据一致性而不是接口性能。这里我提一个性能误区别为了让创建更快就去掉事务问卷创建大概率是低频操作一天可能只创建几十份问卷一致性比那几毫秒重要得多。题目管理接口要支持对已有问卷的增删改也就是编辑问卷。这里有个实现细节大多数成熟的做法是先删后插——编辑保存时把这份问卷下的所有题目和选项先删除再按前端传过来的最新数据重新插入。这样可以避免逐条比对新旧差异这种极繁琐的逻辑。代价是题目的id会变化但因为答题记录关联的是题目id编辑已回收问卷的题目时会导致历史统计对不上所以实际业务中要限制问卷一旦发布就禁止编辑题目。我在系统里是这样处理的只有草稿状态的问卷允许编辑发布中需要改的话先关闭问卷再复制一份并编辑。这套逻辑看起来保守但能避免大量统计异常。3.4 答卷提交与统计查询答卷提交接口是后端里事务边界最清晰的一个。逻辑是前端把所有题目的答案拼成一个数组传过来后端先插入answer_record主记录拿到主键后循环插入answer_detail明细记录。这里很多人易犯的错是循环插入N条明细还使用INSERT INTO...VALUES(...)一条条执行数据量一大性能就很差。正确做法是拼成批量插入的SQL一次性执行INSERT INTO answer_detail (record_id, question_id, answer_content) VALUES (1, 1, A), (1, 2, A,C), (1, 3, 这是一段填空内容);MyBatis的foreach标签可以很轻松拼这种批量语句通常几十道题的问卷一次批量插入几十毫秒就搞定了。统计接口是问卷系统里SQL最有讲究的地方。比如某单选题个选项被选了多少次本质上是查询answer_detail表里question_id等于某个值把answer_content按逗号拆分再分组统计。在MySQL里可以用SUBSTRING_INDEX配合GROUP_CONCAT也可以用FIND_IN_SET来做多选包含判断。我的经验是统计量不大时直接在内存里用Java的HashMap分组计数更灵活代码更好阅读统计量大到百万级再考虑SQL GROUP BY甚至引入OLAP。这里最忌讳一上来就写很复杂的一段SQL后期维护会痛苦。4. Vue前端页面拆解与交互处理4.1 前端工程骨架前端工具链我用Vue CLI虽然Vite现在更火但Vue CLI胜在零配置、生态兼容性好作为课程项目和内网工具绰绰有余。工程结构按views、components、router、store、api分目录规规矩矩不搞幺蛾子。这里想特别说一下状态管理。我用Vuex但不会把什么都塞进去——只有登录状态和用户信息放Vuex问卷数据一律通过API拉取后放在组件内部。为什么问卷数据是典型的请求一次就不再全局交流的数据放进Vuex反而导致组件刷新后缓存不一致调试时非常容易混淆。路由守卫是个关键点。问卷管理页、统计页必须登录才能访问答题页则允许匿名。这个在前端路由配置的beforeEach守卫里拦一下就够了但要提醒一点前端路由守卫只是用户体验层面的控制真正的安全必须靠后端接口鉴权因为任何前端路由都能被绕过直接调接口才是攻击者真正会走的路。4.2 问卷编辑器的核心交互问卷编辑器是前端体验最复杂的部分。页面左边是已经添加的题目列表右边是编辑区域上方是问卷标题和说明。添加题型时我用的方式是弹出一个选择面板点击单选就在题目列表末尾追加一条题目记录然后自动滚动到该题目并聚焦到输入框。动态表单项渲染这里有个经典坑就是v-for里绑定v-model时千万不能用题目数组的下标直接当key。原因很简单题目删除后数组下标改变Vue会误判同一组件被移动而不是删除导致输入框的内容错乱。所以我坚持用后端生成的题目id或者前端的时间戳作为key。答题页相比之下简单很多核心是遍历问卷的题目列表根据题型渲染不同的表单组件。单选用el-radio-group、多选用el-checkbox-group、填空用el-input。提交前做一次整体校验必填题目有没有填完。这里的交互细节是校验不通过时自动定位到第一道未填写的题目那里并滚动到对应位置这个体验优化很加好感。4.3 答题页与数据可视化数据可视化我引入ECharts。统计页面对每个题目渲染对应的图表单选和下拉题用饼图多选因为一人可多选用柱状图更直观填空则直接列出文本反馈列表。ECharts在Vue里用的正确姿势是v-if控制容器渲染完成后再初始化图表不要直接在数据还没渲染完时new ECharts。我经历过很多次图表初始化报dom not found的错误排查到最后基本都是DOM还没有挂载就执行了初始化逻辑。用nextTick或者在setTimeout里延迟初始化是常见解法。图表数据格式要在前端组装好还是后端返回组装好的我建议后端只返回原始统计结果每个选项对应的计数或百分比饼图需要的{name, value}数组让前端做一次map映射就行。后端不要试图去迎合前端图表的数据结构否则接口很难复用。5. 前后端联调与部署就在这一步最容易翻车5.1 跨域配置前后端分离第一个拦路虎必然是跨域。本地开发时前端跑在9528端口后端跑在8080端口直接fetch请求会被浏览器拦截。解决方式有很多我在项目里用的方式是后端加一个CORS全局配置因为这个系统本身就是前后端分离部署前端通过Nginx反向代理时配置一次跨域是最规范的方案但本地开发还是后端直接开跨域最省事。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOrigins(http://localhost:9528) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }千万别图省事用allowedOrigins(*)因为设置allowCredentials(true)后浏览器会拒绝通配符来源。这个坑我进过很多次每次都是把后端日志翻烂才发现CORS配置被浏览器直接给拦了。5.2 环境配置与打包配置文件我建了三份application-dev.yml、application-prod.yml和主配置application.yml。dev环境用本地的root账号连MySQLprod环境改成独立账号和密码并且开启show-sql: false。生产环境千万不要开着SQL日志一是性能二是SQL里的数据都打到日志里有信息泄露风险。前端打包命令npm run build会生成dist目录把这个目录下的文件扔到Nginx的html目录下再配一个反向代理把/api开头的请求转发到后端8080server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /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; } }生产环境的前端和后端部署在同一台服务器用同一域名靠Nginx的location区分这是我最推荐的小型项目部署方式。省去HTTPS证书配置不说还天然避免了跨域问题浏览器看到的请求全部是同源的CORS配置甚至都可以关掉。6. 常见问题与排坑实录这部分全是我在真实开发过程中踩过的坑拿命换来的经验。第一个坑MyBatis-Plus的字段自动填充不生效。创建时间createTime字段我原本想在插入时自动填当前时间但发现数据库里一直是null。排查了很久发现是实体类字段名加了TableField(fill FieldFill.INSERT)注解但缺少对应的MetaObjectHandler实现类。这属于MyBatis-Plus框架特性不了解造成的问题。解决办法很简单写一个类实现MetaObjectHandler接口在insertFill方法里设置创建时间字段。第二个坑事务不生效。创建问卷后同时插入题目选项的Service方法明明标了Transactional但测试时报错后数据照样残留在数据库里。原因非常典型在同一个类的另一个方法里直接调用了这个带事务的public方法Spring事务通过AOP代理生效而同类内部调用绕过了代理事务自然不生效。解决办法要么把事务方法移到另一个Service类中调用要么自己注入自身实例来调用。第三个坑MySQL时区问题。数据库连接配置里没有设置serverTimezone时启动SpringBoot连接数据库会报The server time zone value...的错。第一次遇到的同学大概率一脸懵。解决方式是在jdbc连接URL上加serverTimezoneAsia/Shanghai顺便把useSSLfalse也写上避免MySQL8的SSL警告信息干扰日志排错。第四个坑前端表单校验不触发的迷之Bug。用Element UI的el-form做数据校验时明明rules配好了却死活不提示校验错误。最后发现是el-form-item的prop属性没有和表单项里的v-model字段名对上。这个两个名字必须完全一致找不到对应数据字段时校验规则会自动跳过不报任何错误。第五个坑匿名问卷如何识别用户是否已填。如果同一个浏览器可以无限提交会造成恶意刷票。我的方案是生成匿名token存在LocalStorage里每次提交时带上后端按questionnaire_id submit_token做唯一约束。这个方案比用IP识别更靠谱因为用户换网络环境、开代理后IP会变而浏览器token在正常情况下不会丢。最后还想强调一个和可直接运行相关的体会所谓可直接运行不是一个静态承诺而是一套约定的环境要求。JDK版本至少8MySQL版本建议5.7以上前端Node版本不要太老所有的密码和数据源地址要和本地对齐。我给别人配环境时每次都要看三样东西Java版本、MySQL的账号密码、前端npm镜像源是否可用。这三样对齐了直接运行才算真的成立。如果你正准备拿这个项目做二次开发我建议你可以往这几个方向扩展给问卷增加逻辑跳转也就是根据上一题答案决定是否展示下一题增加问卷分享链接的短链功能后台增加导出Excel答卷数据。这三块功能加起来整个系统可以从课程作业档次直接跳跃到商用工具档次。你实际做的时候也一定还会有新坑但每个坑踩过去这套技术栈才算真正长在你身上了。