恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SpringBoot+Vue+MyBatis构建企业级问卷调查系统实战解析
首页
资讯中心
/
SpringBoot+Vue+MyBatis构建企业级问卷调查系统实战解析
SpringBoot+Vue+MyBatis构建企业级问卷调查系统实战解析
发布时间:2026/10/20 8:41:14
项目标题里出现的这套组合SpringBoot Vue MyBatis MySQL基本就是当前企业级Java业务系统最主流的配置说白了就是招聘网站上那些“Java开发工程师”岗位天天在写的技术栈。问卷调查系统这东西表面看就是个CRUD但真正做起来问卷设计、动态表单、答案统计、权限管控、并发提交哪一块都有不少坑。这几个月我陆续做了几套类似的项目踩了些坑也沉淀了些经验正好借这个机会把一套比较完整的实现思路拆开来聊聊给准备做这类系统的同学一个能直接抄作业的参考。先说这套系统能做什么。核心就三块管理员端配置问卷题目、选项、分值、逻辑跳转、用户端填写问卷PC和手机浏览器都兼容、后台统计分析单选、多选、矩阵、量表题的统计视图。再加一些企业级的东西比如多角色权限、答卷审核、导出报表、系统操作日志。适合做毕业设计、企业内部工具或者接私活的时候作为基础框架二次开发性价比都比较高。我在这套系统上花的时间大头不是在写代码而是在设计和调优。整套做下来从表结构设计到权限模型搭建从前端动态表单渲染到后端统计分析再到最后的Linux部署我把完整思路都整理了一遍。这篇文章比较长涉及的模块也比较多收藏下来慢慢看。1. 项目整体设计与模块拆解1.1 先从真实的痛点说起很多人在做问卷系统时会陷入一个误区一上来就设计一堆“高级功能”结果连最基础的“动态表单”都没想清楚。所谓动态表单就是管理员在后台配置的题目类型是变化的可能是单选题、多选题、下拉选择、评分、矩阵量表这些题型的数据结构完全不同用户在填写端看到的界面也不一样。第一版做的时候我用的是“硬编码方案”——每种题型写死一套前端组件后端也针对每种题型单独处理。这样做单选、多选没问题但需求一加“分值权重”再加“根据上一题答案跳转到不同题目”马上就崩了。代码里全是if (questionType 1)这种分支后期维护成本极高而且加一个新题型要动前端、后端、数据库三处代码非常痛苦。后来重构的方案核心思路就一条把问卷的信息结构化用“元数据驱动”的方式渲染和管理。数据库里存的是问卷的结构化定义前端拿到这份定义后根据题型动态渲染对应组件后端拿到提交数据后根据题型动态校验和存储。这样加新题型只需要加一个组件类型不用改核心流程。这套系统的整体模块可以分成五个核心域问卷设计域模板管理、题目管理、问卷发布、作答域答卷填写、进度保存、防重复提交、数据域答卷存储、原始数据导出、统计分析、用户权限域管理员、普通用户、审核角色、基础支撑域登录认证、操作日志、系统配置。每个域的边界要清晰不然代码写到后期会纠缠在一起。1.2 技术选型的逻辑这套系统的技术选型是典型的“稳定压倒一切”没有追新全部选的是市场验证过的成熟方案。后端用SpringBoot 2.7.x这个版本在社区里非常成熟各种问题一搜就有答案。持久层用MyBatis而不是MyBatis-Plus并不是说MyBatis-Plus不好而是这个项目里要写很多复杂的统计SQL和动态SQL原生MyBatis的可控性更强而且很多企业的存量项目也是老MyBatis用这套技术去面试和接活的时候更有“手写SQL功底”的说服力。前端用Vue 2.6 Element UI这个组合在企业后台项目中使用率极高组件全、文档丰富、坑基本都被前人踩平了。数据库用MySQL 5.7支持JSON字段但绝大多数核心数据我还是选择了结构化存储因为统计场景需要。这里展开说一下为什么用Vue 2而不是Vue 3。其实个人开发我也喜欢Vue 3 TypeScript但放到“企业级”这个背景下Vue 2 Element UI的生态太成熟了——你要找一个表单组件、一个富文本、一个权限指令的现成方案都是现成的。对接到老项目也方便。如果是从零开始学直接学Vue 3也行但如果是复现这套项目用Vue 2会更顺手。关键依赖版本如下表技术组件版本说明SpringBoot2.7.14稳定版本安全漏洞都已修复MyBatis3.5.x手写SQL灵活可控MySQL5.7.x结构化存储支持外键约束实际生产建议逻辑外键Vue2.6.x配合Element UI生态Element UI2.15.x表单/弹窗/表格组件齐全JWTjjwt0.9.1无状态登录态Hutool5.8.x工具库压缩Excel导出工作量如果你完全是个新手我建议先照着这个版本搭一版能跑的跑通了再去改造成Vue 3或者MyBatis-Plus。一上来就搞“全家桶最新版”配置不对的时候心态很容易崩。1.3 功能模块树项目的功能结构可以按目录视角拆成这样系统登录与用户认证账号密码登录、JWT签发与校验、退出登录用户与权限管理用户CRUD、角色分配、菜单权限问卷管理问卷CRUD、复制问卷、发布/下线、截止时间控制题目管理单选/多选/填空/评分/矩阵/下拉/Slider排序拖动分值设置答卷管理用户答卷列表、答卷详情、删除异常答卷统计报表每题统计、交叉分析按部门/性别、Excel导出系统管理操作日志、字典管理、公告配置模块划分的要点是“问卷”和“答卷”必须分开这是两个不同的领域对象。问卷是模板答卷是实例。很多初学者会把这两个概念混在一起导致统计的时候要从问卷表里捞答案数据逻辑非常混乱。问卷表只管“题目怎么定义”答卷表只管“用户怎么作答”统计查询的时候再通过问卷ID关联起来。另外一点经验是问卷的“复制”功能值得做一下。企业内部做员工满意度调研每季度一次问卷结构基本不变如果每次都要重新配置一遍就太浪费时间了。一份问卷一键复制后改改名称和起止时间就能再次发布这个功能用户反馈非常好。2. 数据库设计与核心表结构2.1 表关系总览数据库是整个系统的地基设计得好不好决定了后面统计SQL难不难写。我的核心表一共八张外加三张关联表架构清晰最重要sys_user用户表sys_role角色表sys_user_role用户角色关联表sys_menu菜单权限表实际用的时候通常前端路由控制后端做接口级权限survey问卷主表survey_question题目表survey_option选项表单选、多选、下拉的选项survey_answer_sheet答卷主表一次提交一张survey_answer_detail答卷明细表每一题的回答为什么要设计成主表加明细表的结构因为一次问卷提交可能包含几十道题的答案如果不拆明细表要么用JSON字段塞在一个字段里要么就设计成一个大宽表。JSON字段虽然看着方便但统计的时候用SQL去解析JSON性能和优雅程度都差很多。宽表则完全无法应对题目数量不固定的场景。所以标准做法是一张答卷主表记录这次的提交人、提交时间、总耗时、IP等公共信息明细表一行记录一道题的答案。2.2 核心表的字段细节survey问卷主表CREATE TABLE survey ( id bigint(20) NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 问卷标题, description text COMMENT 问卷说明, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0草稿 1发布 2下线, start_time datetime DEFAULT NULL COMMENT 开始时间, end_time datetime DEFAULT NULL COMMENT 结束时间, creator_id bigint(20) DEFAULT NULL COMMENT 创建人ID, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT 逻辑删除, PRIMARY KEY (id), KEY idx_status_start_time (status, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个容易忽略的点。第一个是逻辑删除字段deleted企业系统里做物理删除风险很大数据审计和误删恢复都麻烦。所有表都加上这个字段查询的时候默认带上where deleted 0。第二个是update_time一定要用ON UPDATE CURRENT_TIMESTAMP这样每次更新行记录时时间戳自动刷新排查数据问题的时候非常有用。survey_question题目表CREATE TABLE survey_question ( id bigint(20) NOT NULL AUTO_INCREMENT, survey_id bigint(20) NOT NULL COMMENT 所属问卷ID, question_type tinyint(4) NOT NULL COMMENT 1单选 2多选 3填空 4评分 5矩阵 6下拉, title varchar(500) NOT NULL COMMENT 题干, sort_order int(11) NOT NULL DEFAULT 0 COMMENT 排序, is_required tinyint(1) NOT NULL DEFAULT 0 COMMENT 是否必答, extra_data json DEFAULT NULL COMMENT 扩展属性如矩阵行信息、分值, PRIMARY KEY (id), KEY idx_survey_sort (survey_id, sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;extra_data这个JSON字段是我后来加的。设计的时候没法穷举所有题型的所有属性比如矩阵题需要存“行标题数组”评分题需要存“最大分值”这些不好单独建字段就统一放在JSON里。MySQL 5.7的JSON类型支持索引和查询函数实际上针对这类扩展属性的场景非常实用。题目表排序不能用更新时间来排序因为管理人员拖拽排序后需要的是用户看到的顺序。所以维护一个sort_order字段前端拖拽排序时批量把顺序提交上来后端在一个事务里更新所有题目的sort_order。这里有个批量更新的小技巧后面在第4部分细说。2.3 答卷存储的关键设计答卷的存储是问卷系统里最需要注意的部分直接决定了统计查询的性能。survey_answer_sheet答卷主表CREATE TABLE survey_answer_sheet ( id bigint(20) NOT NULL AUTO_INCREMENT, survey_id bigint(20) NOT NULL COMMENT 问卷ID, user_id bigint(20) DEFAULT NULL COMMENT 用户ID匿名问卷可为空, submit_time datetime DEFAULT NULL COMMENT 提交时间, duration_seconds int(11) DEFAULT NULL COMMENT 填写时长(秒), ip_addr varchar(50) DEFAULT NULL COMMENT 提交IP, status tinyint(4) DEFAULT 0 COMMENT 0有效 1作废 2待审核, PRIMARY KEY (id), KEY idx_survey_user (survey_id, user_id), KEY idx_submit_time (submit_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;survey_answer_detail答卷明细表CREATE TABLE survey_answer_detail ( id bigint(20) NOT NULL AUTO_INCREMENT, sheet_id bigint(20) NOT NULL COMMENT 答卷ID, question_id bigint(20) NOT NULL COMMENT 题目ID, answer_text text COMMENT 答案内容, PRIMARY KEY (id), KEY idx_sheet_question (sheet_id, question_id), KEY idx_question_id (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里面的answer_text字段是重点。不同类型的题答案的存储格式不一样但为了方便统一查询我都存成文本字符串。单选存选项ID如“3”多选存逗号分隔的选项ID串如“1,4,5”填空直接存文本矩阵题存JSON字符串如{row1:选项2,row2:选项5}。存的时候灵活取的时候再根据题目类型做解析。统计的时候会遇到一个棘手的问题多选的答案存的是“1,4,5”这种字符串SQL无法直接按逗号拆分进行统计。MySQL的FIND_IN_SET()函数可以部分解决但如果要做复杂的交叉分析建议直接用程序代码拉取原始明细后在内存里统计。数据量在一个小公司范围内几万份答卷完全扛得住没必要为了统计去引入大数据组件自找麻烦。另外有一个Excel导入导出的需求要提前考虑到。企业用户非常喜欢用Excel处理数据管理员经常要求把“题目选项”批量导入到系统里免去一条条配置题目的痛苦。所以选项表survey_option在设计上要预留sort_order排序字段和option_score分数字段这样导入的时候能保留原Excel里的顺序和分值。3. 后端核心实现与权限模型3.1 权限模型的三种方案对比企业级系统绕不开权限。这里我对比了三种常见方案用户表加role字段的“硬编码角色”最简单但改角色权限要改代码不灵活。RBAC用户-角色-权限企业系统的主流方案新增角色只需要配置即可适合权限要求复杂的中大型系统。部门岗位数据权限在RBAC基础上加了部门隔离适合层级组织架构但实现复杂度高不少。我最终选了RBAC加简单的数据权限控制。用户表不带角色字段通过sys_user_role关联表实现多对多。菜单表sys_menu维护前端路由和按钮标识后端接口用自定义注解RequirePermission(survey:create)做接口级校验。前端根据登录后返回的权限标识数组用v-permission指令控制按钮显隐。权限标识的命名规范是模块:操作比如survey:create、survey:delete、answer:export。这样在做权限分配页面的时候直接分组展示给管理员勾选即可非常直观。需要注意后端接口校验一定要自己写不能只靠前端隐藏按钮因为接口本身是可以被绕过直接调用的。3.2 JWT登录认证的具体实现登录这块我直接用JWTJSON Web Token。整体流程是这样的用户提交账号密码后端用BCryptPasswordEncoder校验密码。校验通过后生成JWT令牌把用户ID、用户名、角色标识写在claims里。前端把Token存在localStorage每次请求在axios拦截器里加上Authorization: Bearer token请求头。后端用一个拦截器HandlerInterceptor校验Token解析出用户信息后放行同时把用户信息放到ThreadLocal里供业务代码取用。为什么不用Session因为问卷系统经常要部署成前后端分离架构Session的跨域处理和集群共享都比较麻烦。JWT是无状态的后端不需要保存会话状态水平扩容的时候省了很多事。JWT容易踩的坑是秘钥和过期时间。秘钥不要硬编码在代码里用配置项维护在application.yml中还要足够长否则容易被暴力破解。过期时间建议设置成2小时同时前端axios拦截器里做一个“401统一处理”检测到Token过期跳转登录页并友好提示用户“登录状态已过期请重新登录”。实际开发中几乎每个系统都要做一个BaseController里的“当前用户”获取方法public class BaseController { protected LoginUser getLoginUser() { return UserContext.get(); } }所有业务Controller继承这个基类代码里直接调用getLoginUser().getId()就能知道当前操作人是谁非常方便。3.3 问卷保存与发布的流程控制问卷保存和发布之间有一个状态流转的问题。我的设计是问卷的“保存”和“发布”拆成两个动作。保存在草稿状态下可以反复修改发布后问卷结构锁定用户才可以访问填写。这样好处是防止“正在编辑的问卷被用户填了一半题目突然变了”这种恶劣体验。发布状态的控制逻辑集中在一个Service方法里Override Transactional(rollbackFor Exception.class) public void publish(Long surveyId) { Survey survey surveyMapper.selectById(surveyId); if (survey null) { throw new BizException(问卷不存在); } if (survey.getStatus() SurveyStatus.PUBLISHED) { throw new BizException(问卷已发布请勿重复操作); } // 发布前校验问卷必须至少有一个有效题目 int questionCount questionMapper.countBySurveyId(surveyId); if (questionCount 0) { throw new BizException(问卷没有题目不能发布); } // 更新状态 surveyMapper.updateStatus(surveyId, SurveyStatus.PUBLISHED); }这里Transactional注解很重要。凡是涉及“先查后改”、而且业务上有“状态一致性”要求的操作都要开启事务。否则两个管理员同时点击发布可能出现状态重复更新的问题。虽然极端但企业系统里并发问题不要赌运气。3.4 MyBatis里的自定义SQL技巧这个项目里MyBatis的主要难点在于动态SQL和一对多结果映射。问卷详情查询是一个典型的“一对多”场景——一张问卷对应多道题每道题对应多个选项。如果用三次查询再在代码里手动装配代码会非常啰嗦。直接用MyBatis的resultMap里的collection标签一条SQL嵌套查出来。resultMap idSurveyDetailMap typecom.demo.entity.Survey id propertyid columnid/ result propertytitle columntitle/ collection propertyquestions ofTypecom.demo.entity.Question id propertyid columnqid/ result propertyquestionType columnquestion_type/ result propertytitle columnqtitle/ collection propertyoptions ofTypecom.demo.entity.Option id propertyid columnoid/ result propertyoptionText columnoption_text/ /collection /collection /resultMap使用collection嵌套的时候如果子表的数据和主表数据是1:N关系SQL拿出来的行数会膨胀一行问卷变为多行题目此时如果还用了PageHelper分页插件count查询会统计出错误的总行数。所以我在问卷详情查询这种场景下放弃了分页插件直接findById不会出现这个问题。动态SQL保存问卷时要批量插入题目。MyBatis的foreach标签配合MySQL的insert into ... values ... ON DUPLICATE KEY UPDATE语法可以实现一次批量插入或更新。但要注意MySQL对单条INSERT语句的长度有限制max_allowed_packet默认是4M。如果一份问卷有几百道题一次批量插入可能超出限制建议超过200条就分批次执行。4. 前端Vue实现与动态表单渲染4.1 目录结构与工程化规范前端工程化这一步没做好的话项目跑起来之后维护就是个灾难。我建议从一开始就按模块划分目录src/ ├── api/ // 接口请求封装按模块拆分 ├── components/ // 通用组件 ├── router/ // 路由配置 ├── store/ // Vuex状态管理登录态、权限 ├── views/ │ ├── login/ // 登录页 │ ├── survey/ // 问卷管理页 │ ├── surveyEdit/ // 问卷编辑页拖拽排序等 │ ├── answer/ // 用户填写页 │ ├── statistics/ // 统计报表页 │ └── system/ // 系统管理 ├── utils/ // 工具函数请求封装、鉴权指令 └── permission.js // 路由守卫严格按模块划分目录能让协同开发更顺畅。不然所有人都在views下乱建文件夹过两个月你根本找不到当时的代码在哪里。API层一定要统一封装axios实例设置baseURL、超时时间并在拦截器里统一处理Token注入和401跳转。service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.msg || 请求失败)) } return res }, error { return Promise.reject(error) } )这里有个细节前后端约定统一的响应结构。我用的格式是{ code: 200, msg: success, data: {...} }。前端拦截器先小范围统一解包所有接口拿到的直接是data字段里的值。这样业务代码不用每个接口都重复做一次if (res.code 200)的判断。4.2 动态表单渲染的核心实现动态表单是问卷系统的门面。用户在填写端看到的界面完全由后端的结构化题目定义驱动。前端拿到问卷数据后遍历questions数组根据questionType字段用component :is...动态渲染对应组件。核心代码简写如下template div v-forquestion in survey.questions :keyquestion.id component :isgetQuestionComponent(question.questionType) :questionquestion v-modelanswers[question.id] changehandleChange / /div /template script const componentMap { 1: SingleChoiceQuestion, 2: MultipleChoiceQuestion, 3: FillBlankQuestion, 4: ScoreQuestion, 5: MatrixQuestion, 6: DropdownQuestion } export default { methods: { getQuestionComponent(type) { return componentMap[type] || DefaultQuestion } } } /script每个题型组件内部维护自己的渲染逻辑。单选组件用el-radio-group多选用el-checkbox-group评分用el-rate或者自定义的星标组件。最重要的是每个组件的v-model绑定值要符合后端约定单选存选项ID多选存数组转逗号拼接矩阵题存对象转JSON字符串。组件内部对“必答校验”的处理是关键。提交表单时遍历所有题目如果是必答题且当前答案为空则提示用户并定位到对应题目。这里有个体验上的细节只提示“第X题为必答项”还不够最好scrollIntoView滚动到出错题目附近不然长问卷用户找了半天都不知道是哪里漏了。4.2.1 表单校验与防误提交填写端需要双重防误触。第一是提交前的二次确认弹窗明确提示“提交后不可修改”第二是重复提交防抖提交按钮点击后立即置灰、显示loading直到后端响应。后端这里还要加一道防重逻辑——同一用户对同一问卷只能提交一次用survey_answer_sheet表里的UNIQUE KEY (survey_id, user_id)来兜底。企业员工调研场景里同一人重复提交数据会污染统计结果后端防重不能省。还有一个常见的坑浏览器后退按钮会导致填写的答案丢失。如果题目数量大建议每答一题自动保存答案到本地localStorage用户意外关闭浏览器后再次打开能提示“恢复上次填写的进度”。这个功能我是在二期迭代里加的产品经理反馈满意度提升很明显。4.3 问卷编辑器的拖拽排序实现管理员配置问卷时免不了要调整题目顺序。最简单的实现是上下移动按钮上移/下移效果直观且不需要引入拖拽库。后来为了体验升级我引入了vuedraggable组件实现真正的拖拽排序。拖拽排序的流程是用户在问卷编辑页拖拽题目卡片排序。vuedraggable触发end事件拿到排序后的数组。前端把完整的题目ID顺序数组提交给后端。后端批量更新每道题的sort_order。后端批量更新的SQL写法update idbatchUpdateSortOrder foreach collectionlist itemitem separator; UPDATE survey_question SET sort_order #{item.sortOrder} WHERE id #{item.id} AND survey_id #{item.surveyId} /foreach /update这个写法利用MySQL支持一条语句里分号分隔多个语句的特性实现单次批量更新。注意MyBatis默认是不支持批量执行的需要在JDBC连接URL上加一个参数allowMultiQueriestrue。这个参数默认是关闭的不加的话这个SQL会报语法错误。如果你不想动数据库连接参数就只能用foreach生成多条UPDATE语句的拼接仍然是一条请求或者改成循环单条更新性能差。经验是加上allowMultiQueriestrue对性能提升明显而且这个参数在MySQL 5.7下没有明显的安全副作用。4.4 前端路由守卫与页面权限动态表单之外前端还有一块容易出问题的就是路由权限控制。我的做法是登录成功后后端返回当前用户拥有的菜单/权限标识前端存到Vuex里。然后路由分为两部分一部分是静态路由登录页、404页一部分是动态路由需要权限才能访问的页面如问卷管理、系统管理。在permission.js全局前置守卫里做拦截router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (!token) { if (to.path /login) { next() } else { next(/login) } return } // 已登录但还没有拉取用户信息 if (!store.state.user.hasUserInfo) { store.dispatch(user/fetchUserInfo).then(() { // 动态添加路由 const accessRoutes generateRoutes(store.state.user.permissions) router.addRoutes(accessRoutes) next({ ...to, replace: true }) }).catch(() { // 拉取用户信息失败清空登录态 store.dispatch(user/logout) next(/login) }) return } next() })这里有一个很常见的坑router.addRoutes之后原先的next()会重新触发beforeEach但此时hasUserInfo已经是true了会正常放行。但如果hasUserInfo更新不及时会出现循环跳转。我遇到过几次排查出来的原因都是Vuex状态没同步更新。调试的时候打印一次store.state.user能很快定位问题。5. 统计分析模块从原始数据到可视化5.1 统计方案设计统计是整个系统里“看起来简单做起来复杂”的模块。为什么复杂因为问卷数据是非标准化的不同题型的答案格式不同不同问卷的题目数量不同统计的维度也不同。我采用的策略是“读取原始数据在程序里聚合计算”。以单选统计为例后端从survey_answer_detail表里查询某问卷所有单选题目明细然后在Java里用MapInteger, Integer按选项ID做计数再关联选项表拿出选项文本组装成饼图数据public ListOptionStatVO statSingleChoice(Long surveyId, Long questionId) { // 查询该题所有答案 ListString answerList answerDetailMapper.selectAnswersByQuestion(surveyId, questionId); // 统计每个选项出现次数 MapInteger, Integer countMap new HashMap(); for (String answer : answerList) { if (answer null || answer.isEmpty()) { continue; } int optionId Integer.parseInt(answer); countMap.merge(optionId, 1, Integer::sum); } // 组装返回结构 ListOptionStatVO result new ArrayList(); for (Option option : optionMapper.selectByQuestionId(questionId)) { OptionStatVO vo new OptionStatVO(); vo.setOptionText(option.getOptionText()); vo.setCount(countMap.getOrDefault(option.getId(), 0)); result.add(vo); } return result; }为什么不直接在SQL里写GROUP BY因为多选、填空、矩阵题的答案存储格式不标准SQL统计写起来非常绕FIND_IN_SET对多选统计性能也不好。在Java内存里统计的代码更直观、更容易维护而且在这个数据量级下几万条答案性能毫无压力。交叉分析比如“男性用户中选A选项的比例”就是在这个方案上叠加一层用户维度过滤。先查到符合条件的用户ID列表再查答案时用WHERE user_id IN (...)过滤即可。只要数据量控制在合理范围这套方案完全可行。5.2 图表前端渲染与Excel导出图表渲染我用的是ECharts。按题型选图单选用饼图或条形图多选用条形图因为占比之和可能超过100%饼图会误导矩阵题用堆叠柱状图评分题用雷达图或平均分柱状图。饼图适合单选最多展示Top 5选项加“其他”。这里有个经验条形图的排序要按数量降序排列这样观察者一眼能看出“哪个选项占主导”。ECharts默认会按数据顺序渲染所以后端返回统计结果前先按count做一次排序。Excel导出我用的Hutool的ExcelWriter。Hutool处理Excel比直接操作Apache POI要简单一个数量级几行代码就生成一张表。导出内容分为两块一块是“汇总统计表”每题每个选项的票数和占比一块是“原始数据明细表”每行一条答卷。企业客户通常两个都想要。ExcelWriter writer ExcelUtil.getWriter(true); writer.write(statDataList, true); // 输出到HttpServletResponse ServletOutputStream out response.getOutputStream(); writer.flush(out, true); writer.close();导出功能一定要控制权限只有管理员角色才能调用下载接口。我还遇到过一个问题导出几千条数据时如果直接全量写入内存再输出可能出现内存溢出。稳妥的做法是分页查询原始数据比如每次查1000条写入Excel后再查下一页。Hutool的write方法支持流式写入分批次写完全没问题。5.3 图表统计慢怎么办数据量大到一定程度比如超过10万条答卷内存统计开始吃力。这时有几个优化方向给survey_answer_detail的sheet_id和question_id加联合索引这是查询入口的索引必须有。统计时只查需要的字段不要select *。考虑在问卷发布结束后做“预统计”把统计结果算好存到一张survey_stat_snapshot表里用户看报表时直接查快照不用每次都算。如果连快照都抗不住再考虑把答案数据冗余一份到宽表或者ES但那是“大炮打蚊子”对绝大部分企业场景没有意义。预统计这块我做了个简化版本管理员点击“刷新统计”按钮时后台异步计算统计快照前端先展示上次的快照数据计算完成后提示刷新。这样把计算压力从“每次请求都要算”迁移到了“手动或定时触发”。6. 部署上线与踩坑实录6.1 从开发到生产环境的部署要点本地开发时前端用npm run serve起的开发服务器后端用IDE启动前后端通过代理解决跨域。但生产环境必须用Nginx做静态资源托管和反向代理推荐的做法是“前后端同源部署”这样规避跨域问题。Nginx核心配置示意server { listen 80; server_name your-domain.com; # 前端静态资源 location / { root /home/app/dist; index index.html; try_files $uri $uri/ /index.html; } # 后端API反向代理 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里的proxy_pass http://127.0.0.1:8080/末尾带了斜杠意味着请求路径中的/api/前缀会被剥掉。比如前端请求/api/survey/list实际转发给后端的是/survey/list。所以后端Controller上的RequestMapping不要带/api前缀避免路径重复。这个细节不仔细看很难发现我第一次配置就踩了。前端打包时要确认接口请求的baseURL配的是相对路径/api而不是绝对路径http://localhost:8080。很多人开发环境改来改去发布到生产后忘记把请求地址改回环境变量结果所有接口都请求到开发服务器上页面完全白屏。生产环境还要处理gzip压缩、静态资源缓存、HTTPS证书。如果域名备案不允许直接用IP访问就得配HTTPS。Nginx开启gzip对前端的js、css文件压缩效果极为明显特别是Vue打包出来的chunk文件能减少70%体积。6.2 环境变量与配置文件管理SpringBoot的配置通过application.yml管理不同的环境用不同的profile文件application-dev.yml本地开发数据库、Redis、日志级别DEBUGapplication-test.yml测试环境application-prod.yml生产环境数据库密码用环境变量注入不写死在文件里启动时通过--spring.profiles.activeprod指定环境。我在实际部署中还用到了Jasypt对配置文件中的密码加密避免数据库密码裸露在配置仓库里。虽然配置一套Jasypt要花点时间但企业客户往往有这个合规要求算是加分项。前端环境变量用.env.development和.env.production# .env.production VUE_APP_BASE_URL/api VUE_APP_IS_SENTRYfalse前端运行时通过process.env.VUE_APP_BASE_URL读取axios的baseURL就设成这个值。这样换环境只改env文件不用动代码。6.3 问题排查与避坑速查这里把我在开发这套系统过程中踩过、也帮别人排查过的典型问题整理成一份速查表基本涵盖了问卷系统的常见故障点现象根因解决方案前端所有接口报404Nginx的proxy_pass末尾是否带/api导致路径不一致确认后端Controller没有/api前缀或去掉proxy_pass的尾斜杠分页查询中列表数据与实际不符PageHelper和嵌套resultMap冲突详情查询不使用分页插件分页只用在单表单查场景Linux系统时间比北京时间差8小时JVM时区默认UTCJDBC URL追加serverTimezoneAsia/Shanghai启动参数加-Duser.timezoneGMT8上传调查问卷时图片不显示Vue打包后静态资源路径是绝对路径vue.config.js里设置publicPath: ./或使用CDN地址提交问卷偶发“数据已存在”错误用户双击提交按钮后端防重未生效提交接口做幂等控制同一survey_id user_id唯一约束统计报表数据全为0题目选项ID与答案中存的ID不一致因为复制问卷后选项ID变化复制问卷时同步重建答案映射关系或统计时按题目ID关联而不是按选项全局ID保存问卷很慢批量INSERT题目选项没有加allowMultiQueriestrue或分批执行开启多查询参数或按200条分批插入前端动态表单渲染不出来后端返回的问卷结构里questionType不存在后端返回前做数据完整性校验前端兼容处理未知类型降级为只读文本MySQL偶尔报PacketTooBigException单条SQL语句太大超过max_allowed_packet调大MySQLmax_allowed_packet或分批插入实践中还有一个容易忽略的地方问卷系统中“复制问卷”这个功能。复制问卷时如果直接将原问卷的题目和选项复制一遍新的题目ID和选项ID会变化原答卷详情表里存的选项ID就没办法对应到新问卷。所以我在复制问卷时做了完整的“新ID映射关系保存”——把老题目ID、老选项ID、新题目ID、新选项ID一一对应存到一张映射表这样从旧问卷版本来的人可平滑迁移到新版历史答卷统计也不会乱。6.4 用Docker部署的参考方案如果服务器配置允许我建议用Docker Compose统一编排环境把MySQL、后端、前端三个容器管理起来。好处是环境一致性极高换机器部署只需要一条docker-compose up -d。编写docker-compose.yml的关键点MySQL需要挂载数据卷否则容器销毁数据就丢了。后端镜像构建时用maven构建阶段和runtime阶段分开生成镜像体积更小。前端用Nginx镜像托管静态资源如果需要HTTPS把证书挂载进容器。这里有一个坑Docker容器里的localhost是容器自身的地址后端容器连接数据库时不能写localhost要写mysql这个服务名在同一个compose网络内。很多第一次用Docker的人在这里卡半小时。我个人对Docker的态度是“基础设施可以用但别什么都硬塞进Docker”——如果服务器本身就很紧张比如1核2G的小鸡跑三个容器有点吃力不如直接二进制部署更省资源。这个取舍要看实际场景没有绝对的对错。7. 最后再聊几个实操中的体会做完整套系统我有几个比较深的体会。第一个是关于需求的预判。很多需求看起来是“做个问卷”但真正落地的时候客户想要的东西远不止是“问卷”——他们想要的是“可配置的表单引擎 可分析的数据仓库 可管理的权限体系”。在需求分析阶段多问几个“然后呢”能避免很多后期返工。第二个是关于代码的“稳定优先”。这套系统里我没有用太多花哨的新技术全是稳定的老技术栈。我的经验是企业项目在大多数情况下要的是“可维护性”而不是“技术先进性”。一个用起来顺手、出问题能快速定位的老方案比一个看起来很酷但排查问题要翻一堆文档的新方案靠谱得多。当然如果你就是为了学新技术那另当别论。第三个是测试的重要性。问卷系统最容易出问题的是边界场景一条题目都没有时发布、用户填到一半问卷被下线、同一账号在不同设备同时提交……这些场景我一开始都没覆盖上线后就被用户用真实操作“测试”出来了。后来专门补了一轮异常流测试光这一件事就让系统的健壮性提升了一个档次。最后再分享一个小技巧如果接手的是一个已经存在多年的问卷系统已经有大量历史数据那么先别急着改表结构。先写一个“数据迁移脚本”把老数据清洗后写入新表结构核对统计数字的两个版本计算结果是否一致。数字对上了再灰度切流量。历史数据迁移永远是这类系统升级里风险最高的环节没有之一。