☰
SpringBoot+Vue+MyBatis构建企业级问卷调查系统实战解析
2026/10/20 8:41:09 网站建设 项目流程

项目标题里出现的这套组合,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表单/弹窗/表格组件齐全
JWT(jjwt)0.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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有两个容易忽略的点。第一个是逻辑删除字段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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里面的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登录认证的具体实现

登录这块我直接用JWT(JSON Web Token)。整体流程是这样的:

  1. 用户提交账号密码,后端用BCryptPasswordEncoder校验密码。
  2. 校验通过后,生成JWT令牌,把用户ID、用户名、角色标识写在claims里。
  3. 前端把Token存在localStorage,每次请求在axios拦截器里加上Authorization: Bearer <token>请求头。
  4. 后端用一个拦截器(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 id="SurveyDetailMap" type="com.demo.entity.Survey"> <id property="id" column="id"/> <result property="title" column="title"/> <collection property="questions" ofType="com.demo.entity.Question"> <id property="id" column="qid"/> <result property="questionType" column="question_type"/> <result property="title" column="qtitle"/> <collection property="options" ofType="com.demo.entity.Option"> <id property="id" column="oid"/> <result property="optionText" column="option_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-for="question in survey.questions" :key="question.id"> <component :is="getQuestionComponent(question.questionType)" :question="question" v-model="answers[question.id]" @change="handleChange" /> </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组件实现真正的拖拽排序。

拖拽排序的流程是:

  1. 用户在问卷编辑页拖拽题目卡片排序。
  2. vuedraggable触发@end事件,拿到排序后的数组。
  3. 前端把完整的题目ID顺序数组提交给后端。
  4. 后端批量更新每道题的sort_order。

后端批量更新的SQL写法:

<update id="batchUpdateSortOrder"> <foreach collection="list" item="item" separator=";"> UPDATE survey_question SET sort_order = #{item.sortOrder} WHERE id = #{item.id} AND survey_id = #{item.surveyId} </foreach> </update>

这个写法利用MySQL支持一条语句里分号分隔多个语句的特性,实现单次批量更新。注意MyBatis默认是不支持批量执行的,需要在JDBC连接URL上加一个参数:allowMultiQueries=true。这个参数默认是关闭的,不加的话这个SQL会报语法错误。如果你不想动数据库连接参数,就只能用<foreach>生成多条UPDATE语句的拼接(仍然是一条请求),或者改成循环单条更新(性能差)。经验是加上allowMultiQueries=true,对性能提升明显,而且这个参数在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里用Map<Integer, Integer>按选项ID做计数,再关联选项表拿出选项文本,组装成饼图数据:

public List<OptionStatVO> statSingleChoice(Long surveyId, Long questionId) { // 查询该题所有答案 List<String> answerList = answerDetailMapper.selectAnswersByQuestion(surveyId, questionId); // 统计每个选项出现次数 Map<Integer, 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); } // 组装返回结构 List<OptionStatVO> 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、日志级别DEBUG)
  • application-test.yml:测试环境
  • application-prod.yml:生产环境(数据库密码用环境变量注入,不写死在文件里)

启动时通过--spring.profiles.active=prod指定环境。我在实际部署中还用到了Jasypt对配置文件中的密码加密,避免数据库密码裸露在配置仓库里。虽然配置一套Jasypt要花点时间,但企业客户往往有这个合规要求,算是加分项。

前端环境变量用.env.development和.env.production:

# .env.production VUE_APP_BASE_URL=/api VUE_APP_IS_SENTRY=false

前端运行时通过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追加serverTimezone=Asia/Shanghai,启动参数加-Duser.timezone=GMT+8
上传调查问卷时图片不显示Vue打包后静态资源路径是绝对路径vue.config.js里设置publicPath: './',或使用CDN地址
提交问卷偶发“数据已存在”错误用户双击提交按钮,后端防重未生效提交接口做幂等控制,同一survey_id + user_id唯一约束
统计报表数据全为0题目选项ID与答案中存的ID不一致(因为复制问卷后选项ID变化)复制问卷时同步重建答案映射关系,或统计时按题目ID关联而不是按选项全局ID
保存问卷很慢批量INSERT题目+选项没有加allowMultiQueries=true或分批执行开启多查询参数或按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. 最后再聊几个实操中的体会

做完整套系统,我有几个比较深的体会。

第一个是关于需求的预判。很多需求看起来是“做个问卷”,但真正落地的时候,客户想要的东西远不止是“问卷”——他们想要的是“可配置的表单引擎 + 可分析的数据仓库 + 可管理的权限体系”。在需求分析阶段多问几个“然后呢”,能避免很多后期返工。

第二个是关于代码的“稳定优先”。这套系统里我没有用太多花哨的新技术,全是稳定的老技术栈。我的经验是,企业项目在大多数情况下要的是“可维护性”而不是“技术先进性”。一个用起来顺手、出问题能快速定位的老方案,比一个看起来很酷但排查问题要翻一堆文档的新方案靠谱得多。当然,如果你就是为了学新技术,那另当别论。

第三个是测试的重要性。问卷系统最容易出问题的是边界场景:一条题目都没有时发布、用户填到一半问卷被下线、同一账号在不同设备同时提交……这些场景我一开始都没覆盖,上线后就被用户用真实操作“测试”出来了。后来专门补了一轮异常流测试,光这一件事就让系统的健壮性提升了一个档次。

最后再分享一个小技巧:如果接手的是一个已经存在多年的问卷系统,已经有大量历史数据,那么先别急着改表结构。先写一个“数据迁移脚本”,把老数据清洗后写入新表结构,核对统计数字的两个版本计算结果是否一致。数字对上了,再灰度切流量。历史数据迁移永远是这类系统升级里风险最高的环节,没有之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询