做一个线上问卷类系统,最初听起来真不算难——无非是创建问卷、填问卷、看统计结果,三个大块。但等你真正动手做,就会发现"简单"背后全是细节。这套基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0的在线问卷调查系统,我前后跟下来跑了完整一轮,从表结构设计到前端动态渲染再到最后的统计聚合,恰好把这些问题都踩了个遍。目前整套源码已经整理成可以直接参考的项目,配套有文档,适合刚接触Java Web全栈开发的读者、准备做毕业设计的同学,以及想在公司内部快速搭建一套轻量调研工具的团队来用。下面我按自己的理解把整个系统的设计思路和实现细节完整拆一遍。1. 项目背景与技术选型1.1 问卷系统的核心业务是什么做在线问卷调查系统源码之前,首先要搞清楚它到底要解决什么问题。拿线下纸质问卷对比着看就行:创建一份卷子,分发出去,等人填完,最后回收统计。线上系统本质上就是把这四步搬进浏览器,帮助运营人员减少人工收发和汇总的时间成本。但如果仅停留在"搬到浏览器"这个层面,这套系统是撑不起真实使用场景的。实际项目中还有几个隐性需求:问卷模板要可复用。同样的题型、同样的选项布局,下次换批人填写,不应该从头再做一份卷子。题目类型要灵活。单选、多选、下拉、填空、评分、矩阵题,不同业务方对题目形态的要求差异很大。问卷状态要可控。草稿、已发布、已关闭,三态必须能互相流转,还要防止已关闭的问卷继续被提交。结果数据要能快速出统计。按题目维度聚合选项占比,跨维度看分布,甚至要能导出明细做二次分析。这些需求叠加起来,就不是简单的CRUD了。它涉及实体建模、状态管理、动态表单渲染、聚合查询多个模块。开发这套系统时,我始终盯着"让一份问卷从创建到出结论的完整链路跑通"这个目标,没有把它写成一个传统的"增删改查后台"。1.2 为什么是这套技术栈技术选型在这类项目中往往不是选"最炫的",而是选"组合起来省心、坑少的"。SpringBoot2:这个版本在Java Web生态中处于一个很成熟的阶段。自动装配、Starter机制让项目初始化成本降得很低。拿这个项目来说,集成Web、MyBatis、参数校验、定时任务,基本上配好依赖就好,不需要手工维护大量XML配置。SpringBoot2相比SpringBoot1在异步事件、数据源绑定、配置处理器层面已经打磨得比较稳定,选它做服务端底座是稳妥的。Vue3:问卷作答页是典型的动态表单场景,题目种类多、分支逻辑杂,Vue3的组合式API(Composition API)能把这些交互逻辑按功能组织,避免选项很多时把一个组件撑成几百行。加上响应式系统重写后,大批量题目渲染时的性能也比Vue2更稳。问卷编辑页里拖拽排序、题型切换这类交互,在Vue3生态里也容易找到现成方案。MyBatis-Plus:面向单表操作的常规CRUD完全不用手写SQL,BaseMapper的通用方法、LambdaQueryWrapper的链式查询,让代码量大幅下降。这个项目的答题明细、问卷主表操作频繁,MyBatis-Plus的自动填充、逻辑删除、分页插件都是直接能用的能力,比原生MyBatis需要写一堆重复SQL要舒服很多。MySQL8.0:问卷类系统的数据量级通常在中低水平,MySQL足够承担。8.0在窗口函数、CTE(Common Table Expression)方面的增强,让"每道题选择某个选项的人数"这类统计查询可以用更清晰的方式写出来,而不是嵌套一堆子查询。这套组合还有一个附带优势——学习资料多、社区提问容易搜到答案。对第一次做完整项目的新人来说,遇到报错能快速查到解决思路,这一点对推进效率的影响其实很大。1.3 前后端分离的整体架构系统架构我没有搞花活,就是标准的前后端分离:前端独立部署,通过HTTP接口跟后端通信。后端按模块分包,大致如下:com.example.survey ├── common // 通用返回体、异常处理、常量 ├── config // 跨域、拦截器、MyBatis-Plus配置 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 数据表实体 ├── dto // 接口入参出参对象 └── util // 工具类(如Excel导出、JWT工具)前端Vue3项目按页面和组件进行组织:src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 通用组件(题目组件、图表组件) ├── router // 路由 ├── store // 全局状态管理 ├── views // 页面(问卷管理、问卷编辑、问卷作答、统计结果) └── utils // 前端工具函数接口风格上没有追求极端的RESTful,而是更注重语义清晰。比如创建问卷用POST /survey/create,获取问卷详情用GET /survey/detail/{id},提交答卷用POST /survey/submit。接口路径直接表达操作,前端对接时不容易猜错。跨域问题在开发环境通过前端Vite代理解决,生产环境用Nginx把/api前缀反向代理到后端服务,这样就避免了前后端分离下最容易遇到的浏览器跨域拦截。2. 核心业务模块拆解2.1 问卷管理和题库结构设计问卷管理是整个系统的"发动机",所有下游的作答、统计都依赖这里的数据质量。我在设计时把问卷拆分成了几个层级:问卷主表、题目表、选项表。主表保存问卷的基本信息:标题、副标题、状态、创建人、发布时间、截止时间。状态我用整数来存,0草稿、1已发布、2已关闭,这样后续做状态流转判断比较方便。题目表保存每个题目属于哪份问卷、题型、题干、是否必填、排序号。这里有一个关键点:题目是"属于某一份问卷"而不是"属于题库"。虽然之前提过模板复用,但在第一版源码里,我更倾向于直接复制题目数据来实现复用,而不是做一套独立的题库表。原因很简单:问卷发布后题目内容就固定了,如果题库里的题目被修改,已经发布的问卷不该跟着变动。用复制的方式,每份问卷拥有自己的题目快照,逻辑上更干净,还能避免写复杂的版本管理代码。选项表保存每个选项属于哪道题、选项内容、排序号。这层设计相对直接,真正要在意的是排序稳定性和删除时的级联处理。我用MyBatis-Plus的逻辑删除来标记题目和选项的删除状态,避免物理删除后统计历史数据时出现外键悬空的问题。创建问卷核心流程大概是:前端先保存主表信息,拿到问卷ID后再逐条上传题目和选项。后端在service层把它们包在一个事务里,只要有一条题目保存失败,整份问卷就不会落库,前端看到的始终是完整的问卷数据。这个事务边界必须守住,不然出半截数据,后面答题页和统计页全都会跟着崩。2.2 问卷作答与校验链路作答模块是用户量最大、最容易出问题的部分。我把这一块拆成前端渲染和提交校验两个环节。前端拿到问卷详情后,根据每道题的questionType字段,动态渲染对应的题目组件。单选渲染radio组,多选渲染checkbox组,下拉渲染select组,填空渲染input,评分题渲染星级或滑块。Vue3提供了一个很实用的component :is动态组件语法,可以在循环题目的同时,按题型动态切换组件实现。数据绑定是这里最容易踩坑的地方。答案不能直接绑定到题目对象上,得单独维护一个答案Map,键是题目ID,值是用户选择的内容。用reactive({})创建响应式对象后,每次选项变化就同步写入Map,提交时直接把这个Map传给后端。这样做的另一个好处是,切换题目顺序或者筛选未答题目时,答案不会跟题目对象绑死而出现错位。后端收到的数据格式是[{ questionId: 1, answer: "A" }, ...]。校验逻辑分三层:必填校验,标记为必填的题目必须提供答案;答案合法性校验,比如单选答案必须是问卷存在且属于该单选题的选项,防止前端被篡改后提交非法数据;幂等校验,同一用户(或同一设备标识)不能重复提交同一份问卷。第三层幂等校验是在开发后期才补上的。原版接口在并发快速点击提交时,会出现两条相同的答卷记录,统计模块的数据准确性直接从根上被打穿。处理方式是在问卷参与记录表增加了(surveyId, userKey)的唯一索引,同时在插入前先查询是否存在记录,双保险。这个改动代码量不大,但对系统可靠性的提升是决定性的。2.3 统计结果与数据可视化统计模块是这个系统最直观展示价值的地方。问卷发布后收集到的数据,如果只会导出Excel而不提供界面聚合视图,使用方还是得手动算,那线上化的意义就大打折扣。我实现的统计分两级:第一级是问卷汇总,展示总回收量、有效答卷数、平均答题时长。这几个指标通过答题记录表聚合出来,SQL非常简单。第二级是单题统计。对单选题,统计每个选项被选择的次数和占比,用环形图展示;对多选题,按每个选项的选择次数做柱状图;对评分题,算平均分和分数段分布。这里最核心的SQL是聚合查询:SELECT option_id, COUNT(*) AS cnt FROM answer_detail WHERE question_id = #{questionId} GROUP BY option_id后端把统计结果返回给前端,前端用ECharts懒加载渲染图表组件。有几个细节值得提:图表数据不要直接请求题目本身的接口,单独开一个统计接口更好,避免每次打开问卷详情时拖一堆用不上的数字;多选题统计的分子是所有选择该选项的人数,分母是总答题人数,计算比例时别用错了基数;填空题不提供图表统计,管理端直接展示答案明细列表,做筛选搜索更实际。数据导出我做了Excel格式。用Hutool的Excel工具类,把问卷标题作为表头第一行,题目作为列名,每条答卷作为一行。导出时最让人头大的问题是多选题的单元格格式,我选择多选答案用顿号拼接后放在一个单元格里,这样Excel展示最直观,也方便二次筛选。3. 关键技术点实操3.1 鉴权设计与接口权限控制后台管理端的接口不能裸奔,这是基本要求。我采用JWT做无状态鉴权,登录成功后签发Token,前端存在localStorage里,请求时在Authorization头里带上。服务端用一个拦截器统一处理Token校验,放行登录接口和获取问卷详情、提交答卷的公开接口。这里有个区分:管理端的问卷编辑、统计查看、用户管理必须登录,而普通用户填写问卷的接口是要公开的,因为填问卷的人不是注册用户。所以我在后端给接口分了两类权限标识,用自定义注解方式控制,比如@RequireAdmin、@RequireLogin,拦截器里根据注解决定是否校验。JWT的过期策略也简单处理,有效期设为24小时。考虑到管理端使用者通常是内部员工,这个有效期在实际使用中基本够用,不用过度设计刷新机制。3.2 Vue3动态表单的实现细节动态表单是前端复杂度最集中的地方,我把每道题的UI绑定抽成了一个单独的题目组件目录,组件的输入是题目数据和当前答案,输出是答案变更事件。核心的结构如下:<template> <div v-for="question in questionList" :key="question.id"> <component :is="componentMap[question.type]" :question="question" :modelValue="answers[question.id]" @update:modelValue="handleAnswerChange(question.id, $event)" /> </div> </template>这里最需要注意的是v-model与动态props的配合。如果直接写v-model="answers[question.id]",有些场景下会因为响应式对象尚未初始化对应属性而丢失更新。稳妥做法是提前在reactive中初始化所有题目的key,或者在handleAnswerChange里手动调用set方法更新。我实际上采用了set(answers, question.id, value)的写法,确保新增属性也是响应式的。单选、多选、评分题的组件实现上,还需要考虑一个"未完成态":用户填到一半离开页面再回来,前面填过的答案不能丢。因此答案状态我放在路由层级的state里管理,而不是组件内部维护,这样切页面再回来数据还在。3.3 MyBatis-Plus在项目里的落地方式MyBatis-Plus的使用要克制。简单场景直接继承BaseMapper,复杂查询还是手写XML。这套系统的代码就明显地分成这两类。普通CRUD直接用框架方法:LambdaQueryWrapper<Survey> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Survey::getStatus, 1); wrapper.orderByDesc(Survey::getCreateTime); List<Survey> surveys = surveyMapper.selectList(wrapper);复杂的统计查询,尤其是多条件动态拼接的场景,我选择写原生SQL。比如按照时间范围和题目类型筛选答卷时,用动态<where>标签组装条件,MyBatis的标签体系虽然写起来啰嗦,但可控性最强。分页统一用MyBatis-Plus的分页插件。这个插件配置很简单,但有个坑要注意:分页会和自定义的聚合SQL冲突,简单的分页查询它自动搞定,涉及GROUP BY的统计查询反而不要用它的分页,直接查全部结果后自行处理展示,避免分页插件生成的count SQL不正确。3.4 数据库表设计与索引规划表设计的质量决定了后面迭代到底费不费力。我梳理项目用到的核心表:表名作用关键字段survey问卷主表id, title, status, create_timesurvey_question问卷题目表id, survey_id, type, content, required, sortsurvey_option题目选项表id, question_id, content, sortanswer_record答卷记录id, survey_id, user_key, submit_timeanswer_detail答卷明细id, record_id, question_id, option_id, answer_text索引规划有两个重点:answer_detail表的record_id必须建索引,因为统计单个答卷要快速关联明细;survey_question表的survey_id和sort要建联合索引,保证按问卷查询题目时排序快。answer_record表的(survey_id, user_key)加了唯一索引,这个在上文提过,是幂等保障的兜底措施。值得注意的是,答题明细表是典型的"写入多、查询多"表,但每次写入的量级不大,索引太多反而拖慢插入。我只保留必要索引,避免头脑一热把没用的查询路径也建上索引。等到真出现慢查询,再用EXPLAIN分析补索引,这是更合理的路线。4. 实操过程遇到的那些坑4.1 事务失效的坑第一次开发时,我把题目批量保存的循环放在controller里,每道题单独调一次service的保存方法,想着反正业务简单,没包事务。结果测试时模拟网络超时,发现问卷主表保存成功,但题目只插入了一半,脏数据直接废了一份问卷。后来把整套保存逻辑收敛到一个service方法里,加上@Transactional注解。这里有个细节:事务要加在public方法上,并且从外部Bean调用才生效。把操作包在事务里后,任何一步抛异常都会整体回滚,主表数据也不会留下。扣掉这个细节的人,大概率会在上线后某个深夜被脏数据敲门。4.2 动态SQL的与或逻辑混乱做多条件筛选查询时,用LambdaQueryWrapper拼接了一堆and、or,结果发现生成的SQL逻辑和目标完全不一致。排查半天终于明白:MyBatis-Plus的or是作用于整个条件的,不是默认把字段连接成a AND b OR c。要表达(A AND B) OR (C AND D)这种复杂组条件,得用wrapper.and(w -> w.eq(...).eq(...)).or(w -> w.eq(...).eq(...))嵌套写法。这个问题在写代码时不容易察觉,因为普通场景下拼出的SQL能跑、结果也对,一旦条件混杂起来,就会出莫名其妙的筛选结果。给大家一个建议:动态条件超过三个时,先自己在纸上把SQL草稿写出来,再用wrapper去复刻逻辑,别直接上手写代码。4.3 前端数据回显的数量与字符串问题问卷编辑页打开历史问卷,需要把后端存的题目和选项回显到表单组件里。这里发生了一个很低级但很隐蔽的错误:选项ID在数据库里是Long类型,但前端动态生成的临时ID是数字字符串,某些场景下比较选项是否选中时,前端会拿字符串和数字做===严格比较,导致明明已经选中的选项显示不出来。排查这种问题不必怀疑框架,先把值打印出来看类型。后来我做了一层数字格式化,确保从后端拿到的ID在前端统一转为Number类型,回显立即恢复正常。4.4 高并发下的重复提交问卷系统上线后遇到的最实际的问题是重复提交。用户双击提交按钮,后端同一秒内收到两条请求,如果没有幂等处理,统计结果直接翻倍。我用的方案分前端和后端两层:前端在按钮提交后立即置灰禁用,防止多次点击;后端用唯一索引兜底,(surveyId, userKey)冲突时捕获异常并向用户返回"已提交过"。两层配合后,重复提交的问题在线上基本绝迹了。值得提醒的是,前端置灰只是体验优化,它不能替代后端校验,任何只在前端做的限制都能被绕过。4.5 时间字段时区问题MySQL8.0默认时区是UTC,而服务器通常设置的是东八区时间,连接参数忘了指定时区的话,部署到Linux服务器后会发现数据库里的时间和本地时间差了8小时。配置里加一句serverTimezone=Asia/Shanghai就能解决。前端再配合时间格式化工具统一展示格式,这个坑完全可以在开发阶段提前规避,不用等到上线后对账才发现。5. 关于这套源码的体会和扩展思路整个系统从搭建骨架到跑通完整流程,我对SpringBoot2+Vue3+MyBatis-Plus这套组合的信心又增加了几分。坦率说,这套在线问卷调查系统的代码量不算特别大,但它把动态表单、事务边界、幂等设计、聚合统计这些Java Web面试里经常被问到的高频知识点都串了起来,做一遍和看一遍的理解深度完全不同。如果是有余力的朋友,拿到源码后可以再往这几个方向扩展:问卷的题目逻辑跳批(根据上一题答案跳转不同题组)、答卷导出PDF、面向管理端的报表定时推送,以及把统计结果按地区和渠道维度交叉展示。这些扩展点代码上都能在现有结构上自然延伸,源码的模块划分应该撑得住。最后再分享一个小技巧:运行这套项目时,先把数据库初始化脚本执行完,确保MySQL字符集设为utf8mb4,然后用文档里配好的账号登录后台,先把一份示例问卷完整创建、发布、填写一遍,让整条链路先转起来,再开始改代码。先把主干跑通,心里有底后再动枝叶,这是我看任何源码项目都推荐的做法。
做一个线上问卷类系统,最初听起来真不算难——无非是创建问卷、填问卷、看统计结果,三个大块。但等你真正动手做,就会发现"简单"背后全是细节。这套基于SpringBoot2+Vue3+MyBatis-Plus+MySQL8.0的在线问卷调查系统,我前后跟下来跑了完整一轮,从表结构设计到前端动态渲染再到最后的统计聚合,恰好把这些问题都踩了个遍。目前整套源码已经整理成可以直接参考的项目,配套有文档,适合刚接触Java Web全栈开发的读者、准备做毕业设计的同学,以及想在公司内部快速搭建一套轻量调研工具的团队来用。下面我按自己的理解把整个系统的设计思路和实现细节完整拆一遍。1. 项目背景与技术选型1.1 问卷系统的核心业务是什么做在线问卷调查系统源码之前,首先要搞清楚它到底要解决什么问题。拿线下纸质问卷对比着看就行:创建一份卷子,分发出去,等人填完,最后回收统计。线上系统本质上就是把这四步搬进浏览器,帮助运营人员减少人工收发和汇总的时间成本。但如果仅停留在"搬到浏览器"这个层面,这套系统是撑不起真实使用场景的。实际项目中还有几个隐性需求:问卷模板要可复用。同样的题型、同样的选项布局,下次换批人填写,不应该从头再做一份卷子。题目类型要灵活。单选、多选、下拉、填空、评分、矩阵题,不同业务方对题目形态的要求差异很大。问卷状态要可控。草稿、已发布、已关闭,三态必须能互相流转,还要防止已关闭的问卷继续被提交。结果数据要能快速出统计。按题目维度聚合选项占比,跨维度看分布,甚至要能导出明细做二次分析。这些需求叠加起来,就不是简单的CRUD了。它涉及实体建模、状态管理、动态表单渲染、聚合查询多个模块。开发这套系统时,我始终盯着"让一份问卷从创建到出结论的完整链路跑通"这个目标,没有把它写成一个传统的"增删改查后台"。1.2 为什么是这套技术栈技术选型在这类项目中往往不是选"最炫的",而是选"组合起来省心、坑少的"。SpringBoot2:这个版本在Java Web生态中处于一个很成熟的阶段。自动装配、Starter机制让项目初始化成本降得很低。拿这个项目来说,集成Web、MyBatis、参数校验、定时任务,基本上配好依赖就好,不需要手工维护大量XML配置。SpringBoot2相比SpringBoot1在异步事件、数据源绑定、配置处理器层面已经打磨得比较稳定,选它做服务端底座是稳妥的。Vue3:问卷作答页是典型的动态表单场景,题目种类多、分支逻辑杂,Vue3的组合式API(Composition API)能把这些交互逻辑按功能组织,避免选项很多时把一个组件撑成几百行。加上响应式系统重写后,大批量题目渲染时的性能也比Vue2更稳。问卷编辑页里拖拽排序、题型切换这类交互,在Vue3生态里也容易找到现成方案。MyBatis-Plus:面向单表操作的常规CRUD完全不用手写SQL,BaseMapper的通用方法、LambdaQueryWrapper的链式查询,让代码量大幅下降。这个项目的答题明细、问卷主表操作频繁,MyBatis-Plus的自动填充、逻辑删除、分页插件都是直接能用的能力,比原生MyBatis需要写一堆重复SQL要舒服很多。MySQL8.0:问卷类系统的数据量级通常在中低水平,MySQL足够承担。8.0在窗口函数、CTE(Common Table Expression)方面的增强,让"每道题选择某个选项的人数"这类统计查询可以用更清晰的方式写出来,而不是嵌套一堆子查询。这套组合还有一个附带优势——学习资料多、社区提问容易搜到答案。对第一次做完整项目的新人来说,遇到报错能快速查到解决思路,这一点对推进效率的影响其实很大。1.3 前后端分离的整体架构系统架构我没有搞花活,就是标准的前后端分离:前端独立部署,通过HTTP接口跟后端通信。后端按模块分包,大致如下:com.example.survey ├── common // 通用返回体、异常处理、常量 ├── config // 跨域、拦截器、MyBatis-Plus配置 ├── controller // 接口层 ├── service // 业务逻辑层 ├── mapper // 数据访问层 ├── entity // 数据表实体 ├── dto // 接口入参出参对象 └── util // 工具类(如Excel导出、JWT工具)前端Vue3项目按页面和组件进行组织:src ├── api // 接口请求封装 ├── assets // 静态资源 ├── components // 通用组件(题目组件、图表组件) ├── router // 路由 ├── store // 全局状态管理 ├── views // 页面(问卷管理、问卷编辑、问卷作答、统计结果) └── utils // 前端工具函数接口风格上没有追求极端的RESTful,而是更注重语义清晰。比如创建问卷用POST /survey/create,获取问卷详情用GET /survey/detail/{id},提交答卷用POST /survey/submit。接口路径直接表达操作,前端对接时不容易猜错。跨域问题在开发环境通过前端Vite代理解决,生产环境用Nginx把/api前缀反向代理到后端服务,这样就避免了前后端分离下最容易遇到的浏览器跨域拦截。2. 核心业务模块拆解2.1 问卷管理和题库结构设计问卷管理是整个系统的"发动机",所有下游的作答、统计都依赖这里的数据质量。我在设计时把问卷拆分成了几个层级:问卷主表、题目表、选项表。主表保存问卷的基本信息:标题、副标题、状态、创建人、发布时间、截止时间。状态我用整数来存,0草稿、1已发布、2已关闭,这样后续做状态流转判断比较方便。题目表保存每个题目属于哪份问卷、题型、题干、是否必填、排序号。这里有一个关键点:题目是"属于某一份问卷"而不是"属于题库"。虽然之前提过模板复用,但在第一版源码里,我更倾向于直接复制题目数据来实现复用,而不是做一套独立的题库表。原因很简单:问卷发布后题目内容就固定了,如果题库里的题目被修改,已经发布的问卷不该跟着变动。用复制的方式,每份问卷拥有自己的题目快照,逻辑上更干净,还能避免写复杂的版本管理代码。选项表保存每个选项属于哪道题、选项内容、排序号。这层设计相对直接,真正要在意的是排序稳定性和删除时的级联处理。我用MyBatis-Plus的逻辑删除来标记题目和选项的删除状态,避免物理删除后统计历史数据时出现外键悬空的问题。创建问卷核心流程大概是:前端先保存主表信息,拿到问卷ID后再逐条上传题目和选项。后端在service层把它们包在一个事务里,只要有一条题目保存失败,整份问卷就不会落库,前端看到的始终是完整的问卷数据。这个事务边界必须守住,不然出半截数据,后面答题页和统计页全都会跟着崩。2.2 问卷作答与校验链路作答模块是用户量最大、最容易出问题的部分。我把这一块拆成前端渲染和提交校验两个环节。前端拿到问卷详情后,根据每道题的questionType字段,动态渲染对应的题目组件。单选渲染radio组,多选渲染checkbox组,下拉渲染select组,填空渲染input,评分题渲染星级或滑块。Vue3提供了一个很实用的component :is动态组件语法,可以在循环题目的同时,按题型动态切换组件实现。数据绑定是这里最容易踩坑的地方。答案不能直接绑定到题目对象上,得单独维护一个答案Map,键是题目ID,值是用户选择的内容。用reactive({})创建响应式对象后,每次选项变化就同步写入Map,提交时直接把这个Map传给后端。这样做的另一个好处是,切换题目顺序或者筛选未答题目时,答案不会跟题目对象绑死而出现错位。后端收到的数据格式是[{ questionId: 1, answer: "A" }, ...]。校验逻辑分三层:必填校验,标记为必填的题目必须提供答案;答案合法性校验,比如单选答案必须是问卷存在且属于该单选题的选项,防止前端被篡改后提交非法数据;幂等校验,同一用户(或同一设备标识)不能重复提交同一份问卷。第三层幂等校验是在开发后期才补上的。原版接口在并发快速点击提交时,会出现两条相同的答卷记录,统计模块的数据准确性直接从根上被打穿。处理方式是在问卷参与记录表增加了(surveyId, userKey)的唯一索引,同时在插入前先查询是否存在记录,双保险。这个改动代码量不大,但对系统可靠性的提升是决定性的。2.3 统计结果与数据可视化统计模块是这个系统最直观展示价值的地方。问卷发布后收集到的数据,如果只会导出Excel而不提供界面聚合视图,使用方还是得手动算,那线上化的意义就大打折扣。我实现的统计分两级:第一级是问卷汇总,展示总回收量、有效答卷数、平均答题时长。这几个指标通过答题记录表聚合出来,SQL非常简单。第二级是单题统计。对单选题,统计每个选项被选择的次数和占比,用环形图展示;对多选题,按每个选项的选择次数做柱状图;对评分题,算平均分和分数段分布。这里最核心的SQL是聚合查询:SELECT option_id, COUNT(*) AS cnt FROM answer_detail WHERE question_id = #{questionId} GROUP BY option_id后端把统计结果返回给前端,前端用ECharts懒加载渲染图表组件。有几个细节值得提:图表数据不要直接请求题目本身的接口,单独开一个统计接口更好,避免每次打开问卷详情时拖一堆用不上的数字;多选题统计的分子是所有选择该选项的人数,分母是总答题人数,计算比例时别用错了基数;填空题不提供图表统计,管理端直接展示答案明细列表,做筛选搜索更实际。数据导出我做了Excel格式。用Hutool的Excel工具类,把问卷标题作为表头第一行,题目作为列名,每条答卷作为一行。导出时最让人头大的问题是多选题的单元格格式,我选择多选答案用顿号拼接后放在一个单元格里,这样Excel展示最直观,也方便二次筛选。3. 关键技术点实操3.1 鉴权设计与接口权限控制后台管理端的接口不能裸奔,这是基本要求。我采用JWT做无状态鉴权,登录成功后签发Token,前端存在localStorage里,请求时在Authorization头里带上。服务端用一个拦截器统一处理Token校验,放行登录接口和获取问卷详情、提交答卷的公开接口。这里有个区分:管理端的问卷编辑、统计查看、用户管理必须登录,而普通用户填写问卷的接口是要公开的,因为填问卷的人不是注册用户。所以我在后端给接口分了两类权限标识,用自定义注解方式控制,比如@RequireAdmin、@RequireLogin,拦截器里根据注解决定是否校验。JWT的过期策略也简单处理,有效期设为24小时。考虑到管理端使用者通常是内部员工,这个有效期在实际使用中基本够用,不用过度设计刷新机制。3.2 Vue3动态表单的实现细节动态表单是前端复杂度最集中的地方,我把每道题的UI绑定抽成了一个单独的题目组件目录,组件的输入是题目数据和当前答案,输出是答案变更事件。核心的结构如下:<template> <div v-for="question in questionList" :key="question.id"> <component :is="componentMap[question.type]" :question="question" :modelValue="answers[question.id]" @update:modelValue="handleAnswerChange(question.id, $event)" /> </div> </template>这里最需要注意的是v-model与动态props的配合。如果直接写v-model="answers[question.id]",有些场景下会因为响应式对象尚未初始化对应属性而丢失更新。稳妥做法是提前在reactive中初始化所有题目的key,或者在handleAnswerChange里手动调用set方法更新。我实际上采用了set(answers, question.id, value)的写法,确保新增属性也是响应式的。单选、多选、评分题的组件实现上,还需要考虑一个"未完成态":用户填到一半离开页面再回来,前面填过的答案不能丢。因此答案状态我放在路由层级的state里管理,而不是组件内部维护,这样切页面再回来数据还在。3.3 MyBatis-Plus在项目里的落地方式MyBatis-Plus的使用要克制。简单场景直接继承BaseMapper,复杂查询还是手写XML。这套系统的代码就明显地分成这两类。普通CRUD直接用框架方法:LambdaQueryWrapper<Survey> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Survey::getStatus, 1); wrapper.orderByDesc(Survey::getCreateTime); List<Survey> surveys = surveyMapper.selectList(wrapper);复杂的统计查询,尤其是多条件动态拼接的场景,我选择写原生SQL。比如按照时间范围和题目类型筛选答卷时,用动态<where>标签组装条件,MyBatis的标签体系虽然写起来啰嗦,但可控性最强。分页统一用MyBatis-Plus的分页插件。这个插件配置很简单,但有个坑要注意:分页会和自定义的聚合SQL冲突,简单的分页查询它自动搞定,涉及GROUP BY的统计查询反而不要用它的分页,直接查全部结果后自行处理展示,避免分页插件生成的count SQL不正确。3.4 数据库表设计与索引规划表设计的质量决定了后面迭代到底费不费力。我梳理项目用到的核心表:表名作用关键字段survey问卷主表id, title, status, create_timesurvey_question问卷题目表id, survey_id, type, content, required, sortsurvey_option题目选项表id, question_id, content, sortanswer_record答卷记录id, survey_id, user_key, submit_timeanswer_detail答卷明细id, record_id, question_id, option_id, answer_text索引规划有两个重点:answer_detail表的record_id必须建索引,因为统计单个答卷要快速关联明细;survey_question表的survey_id和sort要建联合索引,保证按问卷查询题目时排序快。answer_record表的(survey_id, user_key)加了唯一索引,这个在上文提过,是幂等保障的兜底措施。值得注意的是,答题明细表是典型的"写入多、查询多"表,但每次写入的量级不大,索引太多反而拖慢插入。我只保留必要索引,避免头脑一热把没用的查询路径也建上索引。等到真出现慢查询,再用EXPLAIN分析补索引,这是更合理的路线。4. 实操过程遇到的那些坑4.1 事务失效的坑第一次开发时,我把题目批量保存的循环放在controller里,每道题单独调一次service的保存方法,想着反正业务简单,没包事务。结果测试时模拟网络超时,发现问卷主表保存成功,但题目只插入了一半,脏数据直接废了一份问卷。后来把整套保存逻辑收敛到一个service方法里,加上@Transactional注解。这里有个细节:事务要加在public方法上,并且从外部Bean调用才生效。把操作包在事务里后,任何一步抛异常都会整体回滚,主表数据也不会留下。扣掉这个细节的人,大概率会在上线后某个深夜被脏数据敲门。4.2 动态SQL的与或逻辑混乱做多条件筛选查询时,用LambdaQueryWrapper拼接了一堆and、or,结果发现生成的SQL逻辑和目标完全不一致。排查半天终于明白:MyBatis-Plus的or是作用于整个条件的,不是默认把字段连接成a AND b OR c。要表达(A AND B) OR (C AND D)这种复杂组条件,得用wrapper.and(w -> w.eq(...).eq(...)).or(w -> w.eq(...).eq(...))嵌套写法。这个问题在写代码时不容易察觉,因为普通场景下拼出的SQL能跑、结果也对,一旦条件混杂起来,就会出莫名其妙的筛选结果。给大家一个建议:动态条件超过三个时,先自己在纸上把SQL草稿写出来,再用wrapper去复刻逻辑,别直接上手写代码。4.3 前端数据回显的数量与字符串问题问卷编辑页打开历史问卷,需要把后端存的题目和选项回显到表单组件里。这里发生了一个很低级但很隐蔽的错误:选项ID在数据库里是Long类型,但前端动态生成的临时ID是数字字符串,某些场景下比较选项是否选中时,前端会拿字符串和数字做===严格比较,导致明明已经选中的选项显示不出来。排查这种问题不必怀疑框架,先把值打印出来看类型。后来我做了一层数字格式化,确保从后端拿到的ID在前端统一转为Number类型,回显立即恢复正常。4.4 高并发下的重复提交问卷系统上线后遇到的最实际的问题是重复提交。用户双击提交按钮,后端同一秒内收到两条请求,如果没有幂等处理,统计结果直接翻倍。我用的方案分前端和后端两层:前端在按钮提交后立即置灰禁用,防止多次点击;后端用唯一索引兜底,(surveyId, userKey)冲突时捕获异常并向用户返回"已提交过"。两层配合后,重复提交的问题在线上基本绝迹了。值得提醒的是,前端置灰只是体验优化,它不能替代后端校验,任何只在前端做的限制都能被绕过。4.5 时间字段时区问题MySQL8.0默认时区是UTC,而服务器通常设置的是东八区时间,连接参数忘了指定时区的话,部署到Linux服务器后会发现数据库里的时间和本地时间差了8小时。配置里加一句serverTimezone=Asia/Shanghai就能解决。前端再配合时间格式化工具统一展示格式,这个坑完全可以在开发阶段提前规避,不用等到上线后对账才发现。5. 关于这套源码的体会和扩展思路整个系统从搭建骨架到跑通完整流程,我对SpringBoot2+Vue3+MyBatis-Plus这套组合的信心又增加了几分。坦率说,这套在线问卷调查系统的代码量不算特别大,但它把动态表单、事务边界、幂等设计、聚合统计这些Java Web面试里经常被问到的高频知识点都串了起来,做一遍和看一遍的理解深度完全不同。如果是有余力的朋友,拿到源码后可以再往这几个方向扩展:问卷的题目逻辑跳批(根据上一题答案跳转不同题组)、答卷导出PDF、面向管理端的报表定时推送,以及把统计结果按地区和渠道维度交叉展示。这些扩展点代码上都能在现有结构上自然延伸,源码的模块划分应该撑得住。最后再分享一个小技巧:运行这套项目时,先把数据库初始化脚本执行完,确保MySQL字符集设为utf8mb4,然后用文档里配好的账号登录后台,先把一份示例问卷完整创建、发布、填写一遍,让整条链路先转起来,再开始改代码。先把主干跑通,心里有底后再动枝叶,这是我看任何源码项目都推荐的做法。