
1. 被第三方问卷平台逼出来的自研需求用第三方问卷平台做员工满意度调研一年前我还能忍。直到我们发现历史问卷数据全存在别人的服务器上导出格式对接不上内部 BI 系统审计又要求问卷数据保留五年并且按部门隔离访问权限——从那一刻起自己搭一套企业级在线问卷调查系统就从可选项变成了硬需求。这篇要聊的就是我最近完整落地的一套问卷系统技术栈是 SpringBoot Vue MyBatis MySQL前后端分离架构覆盖问卷创建、题目编排、发布回收、在线作答、自动统计、报表导出全流程。整套源码已经整理成完整版工程适合正在做同类系统的团队直接参考也适合想系统学习前后端分离项目的开发者拿来当实战范本。1.1 企业自建问卷系统的三个真实理由先说为什么放着现成的工具不用。很多人觉得问卷不就是出题、发链接、看结果吗第三方平台免费版完全够用。但放到企业级场景里三个问题绕不过去。第一个是数据主权。员工满意度、客户 NPS、内部流程满意度这些数据属于企业经营数据的一部分存在外部平台上一旦平台政策调整、账号异常历史数据可能说没就没。我们当时就遇到过导出数据格式被平台改版的状况几张老问卷的原始明细几乎没法还原。第二个是定制能力。企业问卷往往不是出一套题发出去这么简单要对接内部单点登录、要根据组织架构控制谁能看谁的答卷、要在问卷里带预置的员工编号、要支持特定题型的自定义校验。第三方平台的通用产品很难为单个企业做这种深度定制。第三个是集成成本。问卷数据要进数仓做长期分析作答结果要触发内部工单流程这些都需要开放接口。第三方平台要么没有开放接口要么开放能力有限。自己建系统数据库在自己手里想怎么接都行。1.2 为什么选了这套技术组合技术选型上我没做太多花哨的尝试直接选了这套组合原因是它足够稳。后端用 SpringBoot理由很直接生态成熟、招人容易、文档全。企业级应用要的权限框架、事务管理、定时任务、消息通知这套生态都能覆盖不需要造轮子。持久层用 MyBatis 而不是 JPA是因为问卷这种业务天然适合写动态 SQL——题型千变万化查询条件随需求变化MyBatis 能把 SQL 的控制权完全交给开发者排查问题时也直观。前端用 Vue配合成熟的中后台组件库做管理端界面开发效率高组件生态完善。问卷编辑器这类动态表单场景Vue 的响应式机制和动态组件特性正好对路。数据库选 MySQL主要考虑是绝大多数团队都在用运维经验丰富单表千万级以内的数据量配合合理索引完全够支撑企业内部的问卷量级。真要到了亿级答卷再考虑分库分表或者迁移分析型数据库也不迟那是后话。这套组合最大的好处是不管是你自己看源码学习还是团队接手维护认知成本都低。比起引入一堆新型框架把精力花在业务逻辑本身对企业项目来说才是划算的。2. 需求没理清之前别动手模块划分与数据库设计很多新手拿到问卷系统这个需求第一反应是赶紧建表、写接口。我做这套系统时先花了两天把模块和数据模型理清楚后面开发基本没返工。问卷系统看着简单但它的数据模型有个显著特点题目结构不确定、答案结构也不确定这恰恰是设计的难点。2.1 功能模块怎么拆我把整个系统拆成了五个核心模块。第一个是问卷管理模块负责问卷的创建、编辑、保存草稿、发布、结束回收、复制和删除。第二个是题目管理模块内置单选、多选、下拉、填空、矩阵单选、量表评分、日期、手机号等常用题型支持题目排序、必答设置、选项随机和简单的逻辑跳转。第三个是答卷管理模块包含作答入口、答卷提交、答卷详情查看、按条件筛选和人工剔除无效答卷。第四个是用户与权限模块对接企业内部的账号体系角色分为系统管理员、问卷管理员、普通作答用户三类数据权限按部门隔离。第五个是统计报表模块支持单题统计、交叉分析、导出 Excel、趋势图展示。模块拆完之后接口边界就清晰了每个模块对应一组接口模块之间尽量不互相调用公共能力下沉到服务层。这样后面做扩展比如增加问卷审核流只需要在问卷管理模块里加状态流转不影响其他模块。2.2 核心表设计与关键字段数据库我设计了七张核心表这里把最关键的设计决策说清楚。survey 是问卷主表存问卷标题、说明、状态、创建人、发布时间、截止时间。状态字段我用的是 tinyint0 草稿、1 已发布、2 收集中、3 已结束、4 已归档。注意我特别加了开始时间和结束时间两个时间字段而不是只靠状态字段这样系统才能自动判断问卷是否在回收期内也方便做定时上下线。survey_question 是问题表核心字段包括问卷 ID、题型、标题、是否必答、排序号、跳转规则。题型用 varchar 存题型编码比如 single、multi、fill、matrix、score。跳转规则字段存的是 JSON 字符串记录简单的跳题规则——比如选了某个选项就跳转到第几题这个字段保留了扩展空间又不至于为跳题单独建复杂的关系表。question_option 是选项表存每个问题的选项内容、选项顺序、是否允许填写补充说明。矩阵题和量表题的选项结构其实也能用这张表表达只是要多一个行选项的概念我用选项分组字段来区分行选项和列选项避免再拆表。survey_response 是答卷表每提交一份答卷就生成一条记录存问卷 ID、用户 ID或匿名标识、提交时间、答题耗时、设备信息、状态。状态字段标记有效、无效、人工剔除。answer_detail 是答卷明细表每道题的答案存一条记录填空存文本选择题存选项 ID。为了统计方便多选时每个选中项单独存一条不做逗号拼接。sys_user 和 sys_role 是用户与角色表用户表加部门字段配合数据权限使用。这套模型下来最核心的设计思想就是问题表固定结构、答案表宽容结构——题型的差异通过题型编码区分答案的差异通过选项 ID 和文本两个字段兜底。2.3 为什么不用 JSON 字段一把梭设计评审时有人提过问卷结构这么灵活直接在问卷表里放一个 JSON 字段存整个问卷答卷也用一个 JSON 字段存不是更省事我坚持拆表的原因有三条。第一统计分析需要按题目、按选项做聚合查询JSON 字段在这种查询上性能和写法都很痛苦。第二问卷发布后经常要局部修改比如改某道题的文字拆表后就是一条更新语句JSON 方式要读整个大字段再改再写。第三企业级系统有审计需求每一道题的变动最好有迹可循拆表后配合操作日志可以做到。JSON 方案适合轻量级场景但达不到企业级的要求。3. SpringBoot MyBatis 后端落地接口设计、动态 SQL 与并发控制模块和数据模型定完之后后端开发就变成一件很顺的事。这一部分我把几个关键实现拆开讲重点不是说代码怎么写而是说为什么这么设计。3.1 工程结构与核心接口后端工程我按标准的分层结构组织接口层只做参数接收和结果包装服务层处理业务逻辑持久层放 MyBatis 的接口和 XML。实体类用工具简化 getter/setter统一返回结构包含 code、message、data 三个字段异常统一走全局异常处理器。这样做的直接好处是前端对接时只需要处理一套返回格式排查问题时看日志就能定位到具体是哪个环节出错。核心接口并不复杂大概就这几个接口方法说明/api/surveyPOST创建问卷/api/survey/{id}PUT更新问卷及题目/api/survey/{id}/publishPOST发布问卷/api/survey/{id}/responsePOST提交答卷/api/survey/{id}/statisticsGET获取统计结果/api/survey/pageGET分页查询问卷列表其中创建和更新问卷走的是删了重插策略保存问卷时先删除这张问卷下的所有题目和选项再按前端传来的完整结构重新插入。这个策略非常适合问卷编辑器场景因为前端提交的是整棵题目树做增量比较反而复杂。代价是需要事务保证所以这两个接口我都加了事务注解确保删除和重插要么全成功要么全回滚。3.2 MyBatis 动态 SQL 在问卷场景的正确用法问卷系统的查询天然适合 MyBatis 的动态 SQL。最典型的场景是后台问卷列表的筛选按状态、按创建人、按标题模糊搜索、按时间范围。如果为每种组合写一条固定 SQL组合爆炸动态 SQL 用一个查询语句加上条件标签就能解决。另一个动态 SQL 的重头戏是统计查询。比如统计一道单选题每个选项被选了多少次SQL 大概是SELECT option_id, COUNT(*) AS cnt FROM answer_detail WHERE question_id #{questionId} AND option_id IS NOT NULL GROUP BY option_id如果要把统计结果直接关联出选项文字就关联一下选项表。写统计接口时有个经验尽量不要在 Java 里做分组计算能用 SQL 聚合就用 SQL 聚合数据库引擎对这类操作做了大量优化Java 内存里循环分组数据量一上来就是性能灾难。3.3 防止重复提交与并发写入问卷系统最容易被忽视的问题就是重复提交。用户在作答页手一抖点了两次提交就会产生两条一模一样的答卷直接污染统计数据。前端按钮置灰能挡住大部分情况但挡不住用户刷新页面重试或者网络超时后的重发。我在后端加了两道防线。第一道是数据库层约束在答卷表建一个唯一索引字段是问卷 ID、用户 ID、提交标识这个标识由前端在进入作答页时生成提交时带过来。第一次提交成功插入第二次提交因为唯一索引直接报错事务回滚。第二道是缓存防抖提交时先抢一个锁key 为问卷 ID 加用户 ID值随便五分钟过期抢不到锁的直接返回请勿重复提交。高并发这块企业内部的问卷系统实际上不会像电商秒杀那样瞬间涌入几十万请求但几百人同时提交是可能的。我的处理方式是事务尽量短提交接口里只做一次插入和一次更新不做其他无关操作数据库连接池用默认配置就够不用过度设计。4. Vue 前端实现问卷编辑器与动态作答页的核心思路后端做得再完善前端体验不好这套系统也落不了地。前端我拆成两个独立的页面端管理端和作答端。管理端跑在后台框架里作答端是一个完全独立的页面通过链接或二维码打开不需要登录就能填写或按配置要求登录后填写。4.1 管理端的问卷编辑器设计问卷编辑器是整个前端最复杂的部分。它的本质是一个支持增删排序的动态表单设计器。我把编辑器拆成三栏布局左侧是题型面板列出可选的题型中间是画布区展示当前题目列表支持拖拽排序右侧是属性面板选中某道题后编辑它的标题、必答、选项内容等。Vue 实现这套交互核心是动态组件。题型面板每种题型对应一个组件文件比如单选题编辑器、填空题编辑器、矩阵题编辑器画布区根据当前编辑的题目类型用动态组件渲染对应的编辑器组件。题目列表我放在一个数组中拖拽排序直接改数组顺序Vue 的响应式特性会自动更新视图。这里有个容易踩的坑用数组下标作为组件的 key 会导致拖拽排序后组件状态错乱。我踩过一次之后改用每道题生成一个唯一的临时 ID 作为 key才彻底解决题目拖动后内容串位的问题。4.2 作答页的动态表单渲染作答页对用户来说是整个系统最简单的一页但对开发者来说它是动态渲染逻辑的集中体现。一进入页面前端先请求问卷详情接口拿到的是一个包含所有题目和选项的树形结构然后遍历这个结构根据题型编码动态渲染对应的作答组件。component :isgetRenderer(question.type) :questionquestion v-modelformValues[question.id] /getRenderer 是一个映射函数把 single、multi、fill、matrix 等题型编码映射到对应的渲染组件。表单值绑定到 formValues 对象上以题目 ID 为 key这样每道题的答案天然按题目 ID 收敛提交时只需要把 formValues 序列化传给后端。必答校验、题型格式校验如手机号格式、日期格式也都在这一层做好处是校验逻辑和渲染逻辑靠近方便维护。作答页还有一个细节必须处理未登录用户和已登录用户两种模式。企业内部场景通常要求登录后作答以便统计到部门维度外部客户调研则用匿名模式。我在作答页初始化时检查配置决定是否跳转登录并据此在提交请求里带用户 ID 或者匿名标识。4.3 前端的状态管理与接口封装管理端的接口我统一封装在 api 模块下按业务域拆文件比如问卷接口、统计接口、用户接口。网络请求实例统一配置基础地址、超时时间和登录态拦截器。发布问卷、复制问卷这种操作前端先调接口成功后刷新列表失败则弹出后端返回的错误信息。为了减少样板代码我封装了一个简单的请求方法返回 Promise调用方只关心成功态。5. 统计报表模块问卷系统的价值中枢一份问卷发出去回收回来的数据如果不做分析它的价值就少了一半。统计报表模块是企业级问卷系统区别于普通表单工具的重要分水岭。5.1 单题统计与交叉分析怎么实现单题统计的逻辑很直接拿到一道题的所有答案按选项分组计数算占比。我在后端写了一个统一的统计服务入参是问卷 ID内部遍历所有题目根据题型走不同的统计分支单选题查选项计数多选题要把同一题的多个选项 ID 都纳入统计填空题统计作答人数和有内容人数量表题计算平均分和分布。交叉分析稍微复杂一点。比如想看不同部门的员工对某个问题的满意度差异这本质是两层分组SELECT u.dept_id, a.option_id, COUNT(*) AS cnt FROM answer_detail a JOIN survey_response r ON a.response_id r.id JOIN sys_user u ON r.user_id u.id WHERE a.question_id #{questionId} GROUP BY u.dept_id, a.option_id执行完这条 SQL在 Java 里按部门做一层树形整理前端用矩阵表格或者堆叠柱状图展示。交叉分析的维度不能写死我把查询条件参数化前端可以选任意两道题做交叉后端动态拼 SQL这样通用性最强。5.2 报表导出与可视化导出功能我用常规的导出工具类生成 xlsx 时按问卷概览 每题统计的格式输出。这里有个小坑如果答卷很多一次性把所有明细加载到内存再写文件内存会飙升。我后来改成按题读取聚合结果一个题一个题写入工作簿明细导出单独做成分页流式读取。企业内部的问卷量级下这种方式既简单又稳定。可视化展示我直接用一个成熟的图表库饼图展示单选题占比柱状图展示趋势和对比。前端统计页拿到后端返回的结构化统计结果按图表类型分别组装配置数据不需要做额外计算。6. 生产环境实测这些坑我替你们踩过了系统开发完到真正上线稳定运行中间其实还有一段路。我把上线过程中踩过的坑写在这里希望能帮你省掉对应的时间。6.1 字符集、排序与索引的坑第一个坑是 MySQL 字符集。早期建库用的默认字符集结果作答时有人填了 emoji直接报存储错误整条答卷写不进去。排查了才发现默认字符集最多存 3 字节emoji 要 4 字节。解决办法是把数据库、表、字段全部改成 utf8mb4并且连接串加上对应的字符集参数。这个坑在建库时就应该规避默认就用 utf8mb4。第二个坑是中文排序问题。后台问卷列表按标题排序时默认排序规则对中文不友好会出现乱序。我后来在查询排序字段时用了转成特定编码集的排序方式才得到中文拼音排序效果。第三个坑是索引缺失。列表分页和统计查询刚上线时慢得明显后来在答卷表的问卷 ID、用户 ID 上建了联合索引在答案明细表的题目 ID 上加索引查询速度立刻上来了。统计类的慢查询我是用执行计划逐个看的大家上线前记得养成这个习惯。6.2 跨域、事务和动态 SQL 的实战教训前后端分离的项目跨域问题肯定绕不开。开发环境我用反向代理的转发配置把接口开头的请求代理到后端地址前端代码里不用写完整的后端地址生产环境则由统一的入口服务做反代。这里提醒一句生产环境尽量不要用跨域的全放开配置应该由入口服务同源部署既安全又省心。另一个印象深刻的坑是事务超时。一次后台批量导入题目数据量比较大事务里执行了太多次插入导致锁等待超时。后来把批量导入拆成每几百条一个批次提交事务时间控制住了问题消失。写事务时记住一句话事务里不要做外部调用、不要做耗时操作能拆批次就拆批次。动态 SQL 也有个容易踩的地方多个条件拼接 where 时如果所有条件都没命中SQL 就会变成缺了查询条件的形式直接语法错误。稳妥的做法是让动态标签自动处理前导逻辑或者用自带的条件标签它能自动处理好开头的连接词。类似地更新语句里的尾逗号问题也有对应的标签处理。这几个标签用熟了动态 SQL 的坑能少一半。6.3 部署与日常运维的几点建议应用打包我用的是 SpringBoot 自带的打包插件打出来的可执行 jar 直接扔到服务器上启动。前端构建产物放到入口服务的静态目录配置好前端路由的 fallback否则直接访问二级路由会找不到页面。服务器资源这块我实测下来 2 核 4G 的配置跑这套系统绰绰有余数据库和应用同机部署也稳定不需要一上来就上集群。数据库备份我每天凌晨全量备份一次备份文件保留三十天。这点在问卷数据上特别重要企业问卷数据属于经营资料丢失是事故级别的。上线至今这套系统已经稳定跑完多轮员工调研和客户满意度回收答卷量最大的一次接近一万份统计报表生成和导出都稳定在几秒内完成效果达到预期。7. 这套源码能给你什么以及从哪开始看最后回到源码本身。如果你准备参考这套系统我建议不要从头到尾一行一行读而是带着问题看效率高得多。如果你关心后端实现先看数据库建表脚本理解问卷表、问题表、答卷明细表三张表的关系再看核心服务里创建问卷、发布、提交答卷这三个方法的实现基本上就能对系统的数据流转有了全貌。如果你关心前端先看编辑器页面的组件树结构和作答页的动态渲染映射这两块是前端最难的部分。如果你想跑起来按工程说明里的步骤初始化数据库、改配置、启动后端、启动前端即可整套流程我反复验证过十分钟内能跑通。我的建议是拿到源码后先基于自己的业务场景改一个小需求比如加一种新题型、加一个导出维度通过改需求来读代码比单纯看代码记得牢。算上踩过的坑这套系统从立项到稳定运行花了大约六周其中需求梳理和数据建模占了一周。如果当时直接上手写代码返工成本绝对不止这一周。我自己最大的体会是所谓企业级不一定要用到多高级的技术而是每个环节都要考虑数据可靠、可维护、可扩展。这套代码在我自己的服务器上已稳定跑了大半年后续又加了问卷审核和导出 PDF 两个功能改起来都没有动到核心结构当初在数据模型上多花的那几天可以说是整套项目里最值的一笔投入。