1. 项目概览:这个问卷调查系统到底能做什么
做技术这么多年,我接触过不少“看起来很基础、做起来全是细节”的系统,网上问卷调查系统就是其中典型的一个。很多朋友一开始觉得它就是“问卷CRUD + 答题页面”,真上手之后才发现,从题目类型设计、动态表单渲染、答卷校验到统计报表,每一环都能踩出不同的坑。这篇文章我想围绕一个基于 SpringBoot 的网上问卷调查系统展开,把我从项目设计、数据库建模、核心代码实现到部署排查的完整链路都拆开讲讲,顺便把源码里值得关注的部分挑出来逐段说明。
这套系统的定位非常明确:面向中小规模调研场景,比如企业内部员工满意度调查、学校课程评价、产品上线前的用户反馈收集等。它需要支持管理员创建问卷、维护题库、发布问卷、查看回收数据,同时还要支持参与者打开链接答题、提交答卷、事后查看结果。从业务复杂度来看,它不像电商系统那样有复杂的交易链路,也不像后台管理系统那样全是表格和权限配置,它真正的难点在于“问卷是一种高度动态的数据结构”——每份问卷的题目数量不同、题型不同、选项不同,答卷数据又是一对多关联的,这就要求在设计阶段就把数据的灵活性和统计的可用性同时考虑进去。
这个项目适合谁来参考呢?如果你正在学习 SpringBoot 整合 MyBatis Plus,想找一个能覆盖“增删改查 + 多表关联 + 数据统计 + 前端渲染”的完整案例,那它很合适。如果你要做毕业设计或者个人作品集,那它同样值得一看,因为问卷系统的业务闭环非常完整,既能体现数据库设计能力,也能体现接口设计能力和业务建模能力。我这里会按实际的开发顺序来讲,不是那种只贴代码不解释的教程,每一步尽量说清楚“为什么这么做”。你哪怕之前没见过问卷系统的代码,照着我这个思路也能把整个项目的脉络捋明白。
2. 整体设计思路与技术选型背后的考量
2.1 为什么用 SpringBoot,而不是传统 SSM
很多老项目还在用 SpringMVC + MyBatis + XML 那一套,能在里面游刃有余的开发者确实基本功扎实,但是新项目再用 SSM 纯属自己给自己上强度。SpringBoot 的价值不在于“功能多”,而在于它把那些固定套路全部收敛成了约定:内嵌 Tomcat、自动装配数据源、Starter 一键引入依赖,再加上application.yml统一管理配置,开发阶段只需要关注业务本身。我见过不少朋友纠结“SpringBoot 是不是太傻瓜化了,学不到底层原理”,这个担心其实没必要——SpringBoot 只是把框架的装配过程自动化了,你用到的 Spring IOC、AOP、事务传播机制、MyBatis 映射原理一样不少,只是省掉了那堆web.xml和spring-mvc.xml而已。
还有一个容易被忽略的点是测试和部署成本。问卷调查系统的迭代速度不慢,经常要加题型、加统计口径,SpringBoot 的spring-boot-devtools支持热重启,配合spring-boot-maven-plugin打包出一个可执行 Jar,扔到服务器上java -jar就能跑。对于中小型团队来说,这种“低摩擦”的交付方式比传统 SSM 要顺滑得多。另外,后续如果要引入 Spring Security 做登录鉴权、引入 Redis 做答题防刷,SpringBoot 生态的整合成本也低得多。
2.2 数据库建模:问卷、题目、选项、答卷的关联设计
这一节是整个系统最核心的部分。我见过很多初学者把问卷设计成一张大表,题目和选项全塞在 JSON 字段里,这么做原型阶段确实爽,但到了“统计某道题有多少人选了某个选项”的时候就傻眼了,Java 代码里要解析 JSON、要手写聚合逻辑,性能差还容易出 bug。
所以在这套系统里,我采用了一套经典的“四层模型”:
- 问卷表(questionnaire):记录问卷基础信息,包括标题、描述、状态(草稿/发布/结束)、开始时间、结束时间、是否匿名、每日答题次数限制。
- 题目表(question):通过
questionnaire_id关联问卷,每个题目包含题型、题干、是否必填、排序号等字段。 - 选项表(option):通过
question_id关联题目,用于单选、多选、下拉等题型。填空和评分题没有选项,取决于题型标记。 - 答卷结果表(answer):主要记录用户对某道题目的作答内容,选项型题目存选项ID,填空型题目存文本内容。
这里有一个关键点值得展开:考试系统里的答题记录通常用“一次提交一行主记录 + 明细子表”的方式,但问卷系统我不建议套用同一套模型。因为问卷的题目是动态的,今天创建的问卷可能只有 5 道题,明天创建的问卷可能就有 30 道题,如果用固定字段去存每一题的答案,表结构就得频繁变更。把这层逻辑放在明细表里,让每一行记录都带着question_id,才能让系统具备“任何问卷都能存”的扩展能力。代价是查询时需要多一次 JOIN,但以问卷系统的数据量级来看,这个代价可以忽略不计。
2.3 业务模块划分:不要把所有代码堆在一个 Controller 里
我接手过一些所谓的“快速开发”项目,三个 Controller 搞定一切,Service 层几乎没有业务,SQL 全写在 Mapper 注解里,看起来代码量很少,但后续每加一个功能都要动老代码。这套问卷系统我把模块拆成五个部分:
- 问卷管理模块:负责问卷的 CRUD、发布、结束、复制。
- 题目管理模块:在问卷下维护题目和选项,支持拖拽排序(源码里用排序号实现,前端可选)。
- 答题模块:面向参与者,包括问卷可见性校验、题目渲染数据获取、答卷提交和校验。
- 统计模块:按问卷聚合回收数据,计算选项占比、答题人数、平均分(评分题型)。
- 用户模块:这里做了精简,采用基于 Session 的简单登录,管理员和普通参与者共用一张用户表,通过角色字段区分。
这五个模块拆完之后,每个 Mapper 都只对自己负责的数据表进行操作,Controller 层只做参数接收和结果封装,Service 层承载具体的业务流程。这样的结构不复杂,又给后续扩展留了余地。比如你要加一个“答卷导出 Excel”的功能,只需要在统计模块里加一个导出接口,完全不用动答题模块的代码。
3. 核心功能详解:从题目类型设计到数据统计
3.1 题目类型的抽象:让不同的题型共用一套渲染逻辑
问卷调查系统的“灵魂”在于题目类型。你可以把题型抽象成三类基础能力:
- 选项型:单选、多选、下拉,它们本质上都是“从预置选项集合中选一个或多个”,区别只在于选择数量和 UI 控件的不同。
- 文本型:单项填空、多项填空,它们需要用户输入文本,校验规则可能是长度限制或正则匹配(如邮箱格式)。
- 评分型:评分题(通常是 1~5 星)和矩阵题,前者的本质是一组单选题,后者的本质是多行单选,但在界面上要展示成表格。
在设计数据库的时候,我建议用question_type字段存数字枚举,而不是直接存“radio”、“checkbox”这类前端名词。原因是:后端关心的是“这个是单选还是多选,决定我如何校验答案”,前端关心的才是“我该渲染成单选按钮还是复选框”。如果数据库直接存前端名词,一旦前端 UI 层改名,数据结构就要跟着改,耦合度太高。合理的做法是后端用稳定枚举值,返回给前端时再做一次映射,给出前端认识的类型标识。
在实际开发中,我定义了一个 Light 枚举类,包含单选、多选、下拉、填空、评分、矩阵这几种类型。每种题型至少有两个核心方法:一个是“如何校验提交答案”,另一个是“如何格式化答案用于存储”。比如单选题,提交值必须是题目下某个选项的 ID 且不能重复;多选题则是一个数组,需要校验数组中的每个元素都是合法选项 ID,且不能有重复项。这些校验逻辑放在 Service 层统一处理,不要分散在 Controller 里。
3.2 答卷提交策略:校验、入库与防重复
答卷提交是整个系统里最容易出错的地方。我先说一个反例:有人把答案数组直接序列化成 JSON 字符串,存到一个叫answer_content的字段里,然后在统计的时候解析这个 JSON。这么做开发效率确实高,但属于“用代码复杂度换取表结构简单度”,等数据量上来之后,统计分析的效率会非常糟糕,而且一旦某个答案的格式变了,老数据的解析就可能出错。
这套系统的答题模块采用的是“明文结构化存储”策略。参与者提交时,后端收到的是[{questionId: 1, optionIds: [2,3]}, {questionId: 2, content: "不错"}]这样的结构体,Service 层会做以下几步:
- 根据
questionnaire_id加载问卷,校验状态是否为“发布中”,校验当前时间是否在开始时间和结束时间之间。 - 加载问卷下的全部题目,构建一个
Map<Long, Question>,便于后续按题校验。 - 逐题校验提交的答案。比如必填题没有提交值要报错;判断题干和题目 ID 是否匹配,防止使用者篡改参数提交不存在的题目。
- 通过校验后,以“一次提交生成一条总答卷记录 + 每题答案一条明细记录”的方式批量插入。总答卷记录里存问卷 ID、参与者 ID、提交时间、IP 地址(可选);明细记录里存题目 ID、题目类型、选项 ID 集合(逗号拼接)或文本内容。
关于“是否匿名”,这里有个设计细节:如果问卷开启了匿名模式,那么总答卷记录里的user_id置空,只保留一条由随机字符串生成的标识用于区分“同一份答卷中的多条明细记录”。这样既能做到匿名,又能在统计时区分不同的答卷。
防重复提交方面,我在源码里用了简单的“Session + 问卷 ID”标记:同一个 Session 对同一份问卷只能提交一次。如果要更严格一些,可以改成基于 IP 和问卷 ID 的 Redis 标记,但中小型项目用 Session 方案已经足够,毕竟问卷系统的并发量不太高。
3.3 统计报表:用 SQL 聚合,而不是在 Java 中循环
统计功能最能体现一个开发的 SQL 功底。以“单选题各选项占比”为例,最直观的思路是:查出所有答卷明细,然后在 Java 里循环计数。这个方案在数据量几百条的时候没问题,但如果问卷参与人数上万,就很容易引发性能问题——你会在内存里维护一个巨大的 List,然后循环、分组、计数,既浪费内存又拖慢响应速度。
正确的思路是让数据库去做聚合。比如统计“题目ID为 123 的单选题各选项被选择的次数”,可以用一条类似这样的 SQL:
SELECT answer_detail.option_value, COUNT(*) AS cnt FROM answer_detail WHERE answer_detail.question_id = 123 AND answer_detail.questionnaire_id = 456 GROUP BY answer_detail.option_value这里option_value字段存储的是逗号拼接的选项 ID 字符串,比如“2,5”。GROUP BY 之后就能得到每个选项组合的计数。对于单选题,一个选项 ID 不会出现两次,所以直接按它分组是没问题的;但对于多选题,一份答案里可能同时选了 2 和 5,这时候想要“选项 2 被选了多少次”就需要额外的处理。
我的做法是:给选项表增加一个“编号”的概念,在统计时将多选答案的字符串拆开后与选项表关联,再按选项 ID 分组。具体可以放在一个自定义 Mapper 方法里,写一小段 SQL 来完成。如果要统计评分题的平均分,就直接取对应题目明细里的数值字段做 AVG 聚合。统计结果最终封装成一个Map<Integer, Long>或List<OptionStat>对象返回给前端,前端用图表库渲染成饼图或条形图。我在本地调试的时候,用 5000 份模拟答卷跑过一遍,整条统计链路耗时在 200 毫秒以内,完全够用。
4. 配置、启动与部署:从零跑通整个项目
4.1 需要准备的环境清单
在动手跑这个项目之前,先检查一下本机环境。我建议的版本组合如下:
| 软件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | SpringBoot 2.7.x 对这两个版本兼容性最好 |
| Maven | 3.6+ | 用于依赖下载和项目打包 |
| MySQL | 5.7 或 8.0 | 注意驱动和时区配置差异 |
| Navicat / DataGrip | 任意 | 用于导入 SQL 脚本和查看数据 |
| 前端开发者工具 | 任意 | 便于调试接口返回结构 |
这里要重点提醒一个容易踩的坑:如果你用 MySQL 8.0,pom.xml里的mysql-connector-java版本别太老,否则会报Public Key Retrieval is not allowed的错误。解决方式是升级驱动版本,或者在数据库连接 URL 上加上allowPublicKeyRetrieval=true&useSSL=false。
4.2 核心配置文件解读
application.yml是 SpringBoot 项目的枢纽。我拿这个项目里最关键的几个配置来说:
server: port: 8080 servlet: context-path: / spring: datasource: url: jdbc:mysql://localhost:3306/survey_system?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: autoserverTimezone=Asia/Shanghai和time-zone: GMT+8这两个配置是一对的,少一个都可能导致日期时间字段差 8 小时。这个问题我在本地出现过,看起来是数据写入时间不对,实际上是连接时区没对齐。map-underscore-to-camel-case: true让数据库字段questionnaire_id自动映射为 Java 属性questionnaireId,写 Mapper XML 时就不用反复起别名。log-impl: StdOutImpl会在控制台打印 SQL 日志,开发阶段建议开着,方便排查问题;上线前记得改成org.apache.ibatis.logging.nologging.NoLoggingImpl或者删掉,避免日志刷屏。
4.3 数据初始化:一份 SQL 脚本能省多少事
源码里附带的sql文件夹下有建库建表脚本,包括用户表、问卷表、题目表、选项表、答卷表、答卷明细表,以及少量初始数据。我第一次拿到这个项目的时候,是直接在 MySQL 里手动建表的,结果因为字段类型和 MyBatis Plus 的映射对不上,花了不少时间排查。后来我建议的做法是:直接导入 SQL 脚本,把表结构和初始管理员账号一次性建好。
导入之后,先确认几件事:
- 表前缀是否和 MyBatis Plus 的全局配置一致。如果表名有前缀(如
t_questionnaire),需要在配置里指定table-prefix: t_或者让实体类加@TableName("t_questionnaire")注解。 - 检查初始密码的加密方式。如果源码里用的 MD5 加盐,确保你的登录逻辑和数据库里存的密文格式一致,否则会出现“账号密码都对但登录失败”的诡异问题。
- 确认问卷状态字段的默认值。数据库里建议给
status字段加默认值 0,并加上注释说明 0 草稿、1 发布、2 结束。这样开发期不容易误操作。
4.4 本地启动的完整步骤
整个启动流程其实非常标准,步骤如下:
- 用 Maven 执行
clean package或者直接在 IDEA 里Reload All Maven Projects,确保所有依赖都下载完毕。 - 本地 MySQL 中创建数据库
survey_system,设置字符集为utf8mb4,导入 SQL 脚本。 - 修改
application.yml中的数据库账号密码为你本机的实际配置。 - 启动
SurveyApplication.java的主方法。 - 浏览器访问
http://localhost:8080,用脚本里初始化的管理员账号登录后台。 - 在问卷管理页面创建一份问卷,添加几道题(单选、多选、填空),然后点击“发布”,复制答题链接,用无痕窗口打开答卷页面,完成一次完整答题。
第 6 步非常重要,我建议每位拿这套源码去改的朋友都完整走一遍“创建问卷→发布→答题→查看统计”的闭环,而不是直接改代码。只有先跑通主线流程,你才知道哪块是正常的、哪块是你改坏的。我之前带过一位实习生,一上来就改数据库字段,结果问卷发布后答题页面怎么都渲染不出来,最后发现是题目表的question_type字段值枚举对不上。先跑通一遍,就是给自己建立一个“正常基线”。
5. 前端交互与渲染:动态问卷表单是怎么做到的
5.1 接口设计和数据协议约定
很多朋友在学后端的时候会忽略接口设计,但问卷系统的接口设计直接决定了前端渲染的复杂度。在这套系统里,问卷答题页面的核心接口就一个:根据问卷 ID 获取完整的问卷内容。返回的 JSON 结构大致如下:
{ "code": 200, "data": { "questionnaireId": 19, "title": "2024年度员工满意度调查", "description": "本问卷共10题,预计耗时2分钟", "status": 1, "deadline": "2024-12-31 23:59:59", "questions": [ { "questionId": 101, "questionType": 1, "required": true, "stem": "你所在部门是?", "options": [ {"optionId": 1001, "text": "技术部"}, {"optionId": 1002, "text": "市场部"} ] } ] } }这个结构的特点是:问卷、题目、选项一次性全量返回,前端拿到后直接遍历渲染即可。对于题库量最大的问卷,这种方式的响应体积也就在几十 KB 左右,完全没必要做成分页加载。如果后续问卷题目数量特别多(比如超过 100 题),再考虑分步加载或者按页加载,初期不必过度设计。
5.2 动态渲染的关键代码思路
前端在拿到数据结构后,需要根据questionType字段渲染不同的控件。核心是一个条件判断映射:单选题渲染成 radio 组,多选题渲染成 checkbox 组,下拉题渲染成 select,填空题渲染成 input/textarea,评分题渲染成一排星形按钮,矩阵题渲染成表格。
以 Vue 为例,大致的渲染逻辑是这样的:
<div v-for="question in questions" :key="question.questionId"> <div class="question-title"> {{ question.required ? '*' : '' }}{{ question.stem }} </div> <!-- 单选题 --> <el-radio-group v-if="question.questionType === 1" v-model="answerMap[question.questionId]"> <el-radio v-for="opt in question.options" :key="opt.optionId" :label="opt.optionId">{{ opt.text }}</el-radio> </el-radio-group> <!-- 多选题 --> <el-checkbox-group v-else-if="question.questionType === 2" v-model="answerMap[question.questionId]"> <el-checkbox v-for="opt in question.options" :key="opt.optionId" :label="opt.optionId">{{ opt.text }}</el-checkbox> </el-checkbox-group> <!-- 下拉题 --> <el-select v-else-if="question.questionType === 3" v-model="answerMap[question.questionId]"> <el-option v-for="opt in question.options" :key="opt.optionId" :label="opt.text" :value="opt.optionId"></el-option> </el-select> <!-- 填空题 --> <el-input v-else-if="question.questionType === 4" v-model="answerMap[question.questionId]" type="textarea" placeholder="请输入你的答案"></el-input> </div>提交时,前端的answerMap已经是一个“题目 ID → 答案”的映射,比如{101: 1001, 102: [1003, 1005], 103: "挺好的"}。把它转成后端需要的列表格式后 POST 给提交接口即可。这个环节有一个容易被忽略的点:填空题如果用户什么都没填,但题目标了必填,后端一定要再次校验,不能只依赖前端。因为前端校验被绕过实在太容易了,这是安全性的基本功。
5.3 后端 XSS 防护和参数合法性校验
问卷系统允许用户输入文本,那就必然要考虑 XSS(跨站脚本攻击)。如果有人在填空题里输入一段<script>alert('xss')</script>,不处理的话,这段内容很可能在管理员查看答卷时被浏览器执行。虽然答题入口是内部的,但这种教育成本很低的问题没必要赌。
我的建议是分两层处理:
- 后端入库前,对文本类字段做 HTML 标签过滤,把
<和>转义为<和>。 - 前端显示时,使用 Vue/React 默认的文本插值(而不是
v-html)来渲染用户提交的内容。这一步是最重要的,只要不用v-html渲染用户输入,XSS 的风险就会大幅降低。
参数合法性校验方面,除了前面提到的题目 ID 和选项 ID 匹配之外,还要注意:
- 文本型答案的长度限制,比如单题答案不能超过 500 字。
- 评分题的范围限制,必须是 1 到 5 之间的整数。
- 邮箱题(如果有)要校验邮箱格式。
这些校验写起来不复杂,但要养成习惯。别觉得“内部系统不用管安全”,等到有人拿着你系统的链接刷问卷、灌垃圾数据的时候,再回头补这些校验就晚了。
6. 统计模块实战:数据可视化与导出
6.1 统计页面的数据和图表对接
问卷管理的后台通常会有一个“查看统计”的入口。点击之后,页面会展示这个问卷的回收概况:总答卷数、有效答卷数、各题作答率(即有多少人答了这道题),以及每个选项的选择人数和占比。
统计接口的设计思路是按问卷聚合:
{ "questionnaireId": 19, "totalCount": 1280, "questionStats": [ { "questionId": 101, "stem": "你所在部门是?", "type": 1, "answerCount": 1275, "optionStats": [ {"optionId": 1001, "text": "技术部", "count": 520, "rate": 40.8}, {"optionId": 1002, "text": "市场部", "count": 755, "rate": 59.2} ] } ] }前端拿到这个结构后,用 ECharts 画饼图或者条形图非常直接。一个值得推荐的小技巧是:不要在前端计算占比,让后端直接返回 rate 字段。这样前端做展示更简单,同时后端可以在取数时统一处理精度和格式化逻辑,避免不同页面算出来的比例对不上。
6.2 统计性能优化:索引、缓存、异步导出
问卷系统在统计层面的数据量通常不会大到需要引入 Elasticsearch,但有几条优化策略是很实用的:
第一,给外键字段建索引。answer_detail表的questionnaire_id和question_id字段在多表 JOIN 时是高频过滤条件,建立普通索引就能大幅度加快 GROUP BY 和 WHERE 的查询效率。我建议在初始化 SQL 脚本里就加上这层索引,而不是等数据量大了再补。
第二,统计接口开启 Spring Cache 缓存。同一份问卷在短时间内被多次查看统计,结果其实是一样的。用@Cacheable注解把统计结果缓存 5 分钟,能显著降低数据库压力。注意:问卷一旦有新答卷提交,需要主动调用@CacheEvict清掉对应问卷的缓存,否则统计结果会一直不变。
第三,Excel 导出采用异步生成。当后台需要导出全部答卷明细时,同步导出可能会导致请求超时。更好的做法是:提交导出任务后返回一个任务 ID,后台线程生成 Excel 文件,再通过前端轮询或 WebSocket 通知下载完成。这一步在初期可以不做,但如果你的系统面向的问卷经常有几千人参与,导出功能几乎是必须的。
6.3 一道题教会你:多选题统计的拆分逻辑
多选题的统计是新手最容易懵的地方。假设一道题有 5 个选项,用户选了“A、C”,我们在answer_detail.option_value字段里存储的是"1,3"。现在想统计“选项 A(ID=1)被选了多少次”,如果用GROUP BY option_value只能统计出“同时选 A 和 C 的人有多少”,得不出“选 A 的人有多少”。
拆分的方式有几种。一种是在 Java 中循环读取后再统计,适合数据量小的场景;另一种是直接在 SQL 层面用FIND_IN_SET或LIKE处理,比如:
SELECT COUNT(*) FROM answer_detail WHERE question_id = 102 AND FIND_IN_SET(1, option_value) > 0如果统计所有选项的分布,可以用一条 JOIN 把选项表和答题明细表按 FIND_IN_SET 关联起来,再分组计数。不过需要留意的是,FIND_IN_SET写法简单,但当数据量很大时无法走索引。所以在实际项目中,我更推荐“冗余统计表”的策略:每次答卷提交后,异步更新一张question_option_stat统计表,每个问卷、每个题目、每个选项一行,直接累加计数。这样统计接口永远只查这张小表,性能又快又稳定,代价仅仅是提交时多一点更新开销。对于中小型问卷系统,这是性价比最高的方案。
7. 常见问题排查与避坑实录
7.1 问题速查表
这套系统在开发和部署过程中,有几类问题出现的频率特别高,我整理了一个速查表,方便你直接定位:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
启动时报Unknown database | 还没有创建数据库或库名拼写错误 | 在 MySQL 中执行CREATE DATABASE survey_system DEFAULT CHARACTER SET utf8mb4; |
| 登录失败,账号密码正确但进不去 | 密码加密方式和数据库密文不一致 | 检查登录 Service 中使用的加密算法(MD5/SHA),与初始数据的密文格式对齐 |
| 问卷发布后答题页 404 | 问卷状态字段未设置为“发布”,或接口路径错误 | 确认status=1,并检查后端 Controller 的@RequestMapping路径 |
| 时间字段差 8 小时 | 数据库连接 URL 缺少时区配置 | 在url中加入serverTimezone=Asia/Shanghai |
| 统计接口响应很慢 | 没有对questionnaire_id建索引 | 给answer_detail表的questionnaire_id字段添加普通索引 |
| 提交答卷后提示参数错误 | 题型枚举值和实际不符 | 检查前端渲染用的题型映射,和后端枚举定义保持一致 |
| 多选答案统计不准 | 没有处理多选拆分 | 改用“统计表冗余”或FIND_IN_SET拆分逻辑 |
7.2 踩过的坑:从一个拼接 SQL 导致的 Bug 说起
有一次我在统计模块里为了省事,直接拼接了选项 ID 的字符串去查询数据库,结果某个选项 ID 是两位数时,LIKE '%1%'把 ID 为 11、21 的选项也匹配进去了,统计结果直接翻倍。从那以后我给自己定了一条规矩:能用参数化的写法和FIND_IN_SET,就不要用字符串 LIKE 匹配数字 ID。这条原则不只适用于问卷系统,任何涉及 ID 过滤的场景都一样,数据格式规范化永远比“写起来快”更重要。
还有个常见的坑是在“复制问卷”功能上。很多系统支持一键复制已有问卷,实现时如果只是把问卷表记录复制一份,而忘记同时复制题目和选项,就会出现“问卷存在但打开编辑时一片空白”的诡异问题。正确做法是在事务里完成三步:复制问卷主记录,遍历原问卷下的所有题目,为新问卷创建新题目和新选项,同时维护好新旧 ID 的映射关系。这个逻辑不难,但容易漏。
7.3 关于并发提交的思考
问卷系统有一个特殊场景:同一个用户、同一份问卷,在多个标签页同时提交。如果系统不做任何限制,就可能产生重复答卷。处理方案我在前面提过 Session + Redis 的标记方式,但更彻底的做法是在数据库层面加一层唯一约束。比如在总答卷表里增加一个unique_key字段,存储user_id + questionnaire_id + 某次会话的随机标识,在数据库层面设置唯一索引。这样即使前端重复提交、后端并发处理,数据库最终也只会保留一条有效记录。这个方案比单纯依赖后端逻辑要可靠得多,因为它是最后一道物理防线。
8. 个人体会与几点额外建议
回到这个 SpringBoot 网上问卷调查系统本身,我个人的体会是:它的代码量不算大,但覆盖的知识点非常全面。从 SpringBoot 自动配置到 MyBatis Plus 的 ActiveRecord 模式,从 REST 接口设计到前端动态渲染,从 SQL 聚合统计到部署排查,每一个环节都能在真实项目中找到对应场景。对正处于学习阶段的朋友来说,把它“吃透”比急着上手微服务更有价值。
最后分享一个小技巧:做完这套问卷系统后,你可以自己给自己提需求,比如“支持删除问卷前二次确认”“支持统计结果导出 PNG”“支持答题进度条”,然后逐个实现。这些需求单独看都不难,但把它们一个个加进去的过程,就是对这套代码最有效的巩固方式。你在动手的过程中会遇到“如何改表结构而不影响老数据”“如何加接口而不破坏原有协议”这些真实问题,而这些经验恰恰是只读一遍代码学不到的。希望这篇文章能帮你少走一些弯路。