简介:基于SpringBoot与Vue的在线问卷调查系统,是一套面向计算机专业毕设学生及Java学习者的完整项目方案,可作为课程设计、期末大作业或毕业设计直接使用。系统围绕问卷全生命周期设计,涵盖用户登录认证、问卷创建与编辑、题目配置、发布分享、在线填写、回收数据、统计分析与结果展示等环节,前后端分离,经严格调试可稳定运行。资源包为RAR压缩格式,共755个文件,整体约18.28MB,主要包含Java后端源码、Vue前端组件、JavaScript交互逻辑、CSS样式及SQL数据库脚本,另有开发说明文档、部署和代码讲解视频。已有446人学习下载,对于掌握SpringBoot与Vue整合开发、了解前后端分离项目结构的进阶学习者颇具参考价值。借助源码和配套讲解可从零复现运行环境,并扩展问卷题型、权限管理等个性化功能。
1. 基于Springboot+Vue的在线问卷调查系统:为什么我还要自己写一个问卷系统
第一次决定自己写一个基于Springboot+Vue的在线问卷调查系统,不是因为找不到现成问卷平台,而是因为那些平台对内部系统太不友好:用户体系对不上、问卷状态流转不透明、统计数据留在别人家的黑匣子里。等你想在草稿、发布、关闭之间加入自己的审核流程,想把答卷明细落到业务库和用户表做关联分析,第三方工具基本帮不上忙。这篇文章会从表结构、Springboot后端接口、Vue前端组件到前后端联调排错,给出一套能照着敲的最小实现,也会把几个让我翻过车的坑提前交代清楚。适合正在做毕业设计,或者要给公司后台补一个问卷模块的全栈开发者。
2. 从需求到表结构:问卷模型设计与Springboot项目骨架
在线问卷的核心不是“问卷”这个词,而是“题型、选项、答卷”之间的关系。很多人在Controller里拿一个JSON对象就开始存,等做到统计功能时才发现题目和选项都在一张表里,根本算不出比例。所以第一步不是写接口,而是把数据模型画清楚。我习惯按“问卷主表 + 题目表 + 选项表 + 答卷表”拆,下面这套表结构是从几个项目中沉淀出来的,基本覆盖单选、多选、文本这三种最常见的题型。
2.1 问卷、题目、选项、答卷:先画这四张核心表
先看建表SQL,我用的MySQL 8,字符集固定utf8mb4,避免用户输入生僻字或者Emoji时乱码。
CREATE TABLE `survey` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(128) NOT NULL COMMENT '问卷标题', `description` varchar(512) DEFAULT NULL COMMENT '问卷说明', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0草稿 1已发布 2已关闭', `start_time` datetime DEFAULT NULL COMMENT '生效开始时间', `end_time` datetime DEFAULT NULL COMMENT '生效结束时间', `create_by` varchar(64) DEFAULT NULL COMMENT '创建人ID', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问卷主表'; CREATE TABLE `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文本', `title` varchar(512) NOT NULL COMMENT '题干', `sort_no` int(11) NOT NULL DEFAULT '0' COMMENT '排序号', `is_required` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否必答', PRIMARY KEY (`id`), KEY `idx_survey_id` (`survey_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表'; CREATE TABLE `question_option` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `question_id` bigint(20) NOT NULL COMMENT '所属题目ID', `option_text` varchar(256) NOT NULL COMMENT '选项内容', `sort_no` int(11) NOT NULL DEFAULT '0' COMMENT '排序号', PRIMARY KEY (`id`), KEY `idx_question_id` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选项表'; CREATE TABLE `answer_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `survey_id` bigint(20) NOT NULL COMMENT '问卷ID', `user_id` varchar(64) DEFAULT NULL COMMENT '答题人ID,匿名问卷为空', `submit_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_survey_user` (`survey_id`, `user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷主表'; CREATE TABLE `answer_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `record_id` bigint(20) NOT NULL COMMENT '答卷主表ID', `question_id` bigint(20) NOT NULL COMMENT '题目ID', `answer_text` text COMMENT '单选存选项ID,多选存逗号分隔的选项ID,文本存原始内容', PRIMARY KEY (`id`), KEY `idx_record_id` (`record_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷明细表';题目和选项单独拆表,是为了支持“一份问卷里有不同数量的题目,一道题里有不同数量的选项”。如果把选项存成JSON数组塞在题目表里,后面的统计和题目复用都会很痛苦。status字段是问卷的“生命线”,草稿、发布、关闭三种状态必须由后端统一流转,不能靠前端改一个数字。答卷主表和明细表分开,是因为一次提交对应一批答案,明细表以record_id关联可以批量插入,查询统计时也能走索引。
2.2 用Spring Initializr快速起一个Springboot工程
后端我这里选用Spring Boot 2.7.18搭配MyBatis-Plus。在这个版本上,JDK8和Maven项目都能稳定跑,自动装配原理也不复杂,足够撑起一个在线问卷调查系统。用IDEA创建时,依赖先勾Spring Web、Validation、Lombok,再手动补MyBatis-Plus和MySQL驱动。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>选MyBatis-Plus而不是JPA,是因为原生Mapper和Page插件对复杂查询更直接,问卷统计时写SQL不受ORM限制。spring-boot-starter-validation是必加的,创建问卷时前端传过来的DTO要靠它做空值校验。
接下来是application.yml,注意时区和驼峰映射这两个配置,后面前后对阵时间格式会用到。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/survey_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: automap-underscore-to-camel-case让create_time自动映射到createTime,不用每个字段都写映射注解。log-impl打开后控制台会打印SQL,联调阶段排查参数问题非常有用。id-type: auto配合数据库自增主键,插入后实体类的id字段会被自动回填。
2.3 用MyBatis-Plus把CRUD落到最小闭环
实体类只需要在类名和主键上做标记。以Survey为例:
@Data @TableName("survey") public class Survey { @TableId(type = IdType.AUTO) private Long id; private String title; private String description; private Integer status; private LocalDateTime startTime; private LocalDateTime endTime; private String createBy; private LocalDateTime createTime; private LocalDateTime updateTime; }@TableName指定表名,@TableId(type = IdType.AUTO)声明数据库自增主键。这里所有字段都用LocalDateTime而不是Date,配合后面的@JsonFormat更好控制输出格式。
Mapper接口继承BaseMapper,单表CRUD、分页、条件查询基本不用手写SQL。
@Mapper public interface SurveyMapper extends BaseMapper<Survey> { } @Mapper public interface QuestionMapper extends BaseMapper<Question> { } @Mapper public interface QuestionOptionMapper extends BaseMapper<QuestionOption> { }分页是问卷后台列表的刚需,MyBatis-Plus 3.5.x 必须显式注册分页插件,否则selectPage会查全表。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }PaginationInnerInterceptor参数里的DbType.MYSQL告诉插件当前数据库方言,它才能拼接LIMIT ?。这一步往往是网上教程没提的坑,很多新项目抄了Mapper代码却忘了注册这个Bean,结果分页整页都是同一个List。Service层拿到这个配置后,一个标准的后台分页查询长这样:
@Service @RequiredArgsConstructor public class SurveyService { private final SurveyMapper surveyMapper; public Page<Survey> pageQuery(int pageNum, int pageSize) { Page<Survey> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Survey> wrapper = new LambdaQueryWrapper<Survey>() .eq(Survey::getStatus, 1) .orderByDesc(Survey::getCreateTime); return surveyMapper.selectPage(page, wrapper); } }LambdaQueryWrapper是MyBatis-Plus推荐的条件构造器,用方法引用写字段名,编译期就能发现拼写错误。page对象里已经带了total、records,可以直接丢给前端,不需要自己封装分页Result。
3. 后端接口设计:一份问卷从草稿到发布的完整链路
问卷系统的后端难点不在增删改查,而在于“状态机”和“事务边界”。创建一个问卷要同时写题目和选项,发布问卷要校验状态,提交答卷要防止重复。这些动作如果没设计好,用户连点两下就会造出脏数据。我见过的靠谱做法是把接口按“草稿管理、发布、答卷提交”三条链路拆开,每条链路只做一件事。
3.1 问卷状态机与接口分层
先明确状态流转规则,后端才算有守门人。
| 状态值 | 含义 | 允许的操作 |
|---|---|---|
| 0 | 草稿 | 编辑问卷、修改题目、删除 |
| 1 | 已发布 | 用户答题、关闭问卷,题目只读 |
| 2 | 已关闭 | 查看统计、查看答卷,不可再提交 |
Controller只负责接收参数、调用Service,Service负责事务和状态判断,Mapper只碰SQL。这样分层的好处是:将来要加“审核中”状态,只需要改Service里的判断逻辑,Controller和数据库表都稳定。
状态流转不能裸写survey.setStatus(1)然后updateById,因为并发情况下两个请求同时执行,可能把“草稿”和“已发布”都跳过去。状态更新必须带查询条件,这是关键。
3.2 创建问卷+题目批量保存的实现
创建问卷的入参用DTO接收,不要直接吃前端传来的JSON Map。DTO里嵌一个List<QuestionItem>,题目选项一起传入。
@Data public class SurveySaveRequest { @NotBlank(message = "问卷标题不能为空") private String title; private String description; @NotNull(message = "题目列表不能为空") @Size(min = 1, message = "至少包含一道题目") private List<QuestionItem> questions; @Data public static class QuestionItem { @NotNull(message = "题型不能为空") private Integer questionType; // 1单选 2多选 3文本 @NotBlank(message = "题干不能为空") private String title; private Boolean required; private List<String> options; } }@Size(min = 1)是很多人会漏掉的一层校验,只靠前端判断“题目不能为空”不够,Postman直接调接口时后端必须拦住。
Service层用注解事务一次性插入问卷、题目、选项。
@Transactional(rollbackFor = Exception.class) public Long createSurvey(SurveySaveRequest request) { Survey survey = new Survey(); survey.setTitle(request.getTitle()); survey.setDescription(request.getDescription()); survey.setStatus(0); survey.setCreateTime(LocalDateTime.now()); surveyMapper.insert(survey); if (CollectionUtils.isEmpty(request.getQuestions())) { throw new BizException("题目不能为空"); } int questionIndex = 0; for (SurveySaveRequest.QuestionItem item : request.getQuestions()) { Question question = new Question(); question.setSurveyId(survey.getId()); question.setQuestionType(item.getQuestionType()); question.setTitle(item.getTitle()); question.setIsRequired(Boolean.TRUE.equals(item.getRequired()) ? 1 : 0); question.setSortNo(questionIndex++); questionMapper.insert(question); if (item.getQuestionType() != 3 && !CollectionUtils.isEmpty(item.getOptions())) { int optionIndex = 0; for (String optionText : item.getOptions()) { QuestionOption option = new QuestionOption(); option.setQuestionId(question.getId()); option.setOptionText(optionText); option.setSortNo(optionIndex++); questionOptionMapper.insert(option); } } } return survey.getId(); }为什么必须在方法上加@Transactional(rollbackFor = Exception.class)?默认事务只在遇到RuntimeException时才回滚,如果中途爆了BizException这类受检异常,前面插入的题库会残留。把题目的sortNo和选项的sortNo都用自增索引赋值,能保证前端渲染顺序稳定。文本题没有选项,所以用questionType != 3做判断,避免给文本题插入一行空选项。
3.3 发布问卷:状态校验与幂等处理
发布操作的正确姿势是“条件更新”。把status = 0放到UPDATE的WHERE里,数据库层保证同时只有一个请求能成功。
@Transactional(rollbackFor = Exception.class) public boolean publish(Long id) { Survey survey = surveyMapper.selectById(id); if (survey == null) { throw new BizException("问卷不存在"); } if (survey.getStatus() != 0) { throw new BizException("只有草稿状态才能发布"); } LambdaUpdateWrapper<Survey> updateWrapper = new LambdaUpdateWrapper<Survey>() .eq(Survey::getId, id) .eq(Survey::getStatus, 0) .set(Survey::getStatus, 1) .set(Survey::getUpdateTime, LocalDateTime.now()); return surveyMapper.update(null, updateWrapper) == 1; }先用selectById查出问卷,是为了给用户一个明确的业务提示;再用条件更新update,是为了防止两次点击时并发重复发布。两段代码各有职责,不能省略第一次查询直接返回“操作失败”。
用户提交答卷同样要防重。最简单可靠的防重做法是给answer_record加唯一索引:
ALTER TABLE `answer_record` ADD UNIQUE KEY `uk_survey_user` (`survey_id`, `user_id`);如果业务允许一个用户多次提交,那就不加唯一索引,改成在业务表里记录“提交批次号”。但绝大多数内部问卷只要一次,唯一索引是最后一道防线,代码里先查再插只会减少错误提示,无法根治并发。提交接口里做了唯一索引后,重复提交时数据库会抛DuplicateKeyException,把它翻译成“你已经提交过问卷”就行。
4. 前端Vue3+Vite:问卷编辑器与答题页从零搭起来
问卷调查系统的前端,其实分两个完全不同的页面:问卷编辑器(给配置人员用)和答题页(给普通用户用)。编辑器要能动态增删题目、调整选项,答题页要能按题型渲染控件、收集答案并校验。用Vue3 + Vite跑这件事很合适,组件化之后编辑器里的每个题目都能独立维护,答题页则保持极简,一个组件负责一种题型。
4.1 问卷编辑器的组件拆分
编辑器我习惯拆成SurveyEditor和QuestionItem两个组件。父组件只维护题目数组,子组件负责渲染单个题目。
<!-- SurveyEditor.vue --> <template> <div> <div v-for="(q, index) in questions" :key="q.id"> <QuestionItem :model-value="q" @update:model-value="questions[index] = $event" @remove="removeQuestion(index)" /> </div> <button @click="addQuestion">新增题目</button> </div> </template> <script setup> import { ref } from 'vue'; import QuestionItem from './QuestionItem.vue'; const questions = ref([ { id: Date.now(), questionType: 1, title: '', required: true, options: ['', ''] } ]); const addQuestion = () => { questions.value.push({ id: Date.now(), questionType: 1, title: '', required: false, options: ['', ''] }); }; const removeQuestion = (index) => questions.value.splice(index, 1); </script>父组件用v-model把每个题目对象传给子组件,子组件修改后通过update:model-value事件把新对象抛回。这样每个题目都是一个不可直接乱改的独立数据流,方便以后扩展“复制题目”“移动题目顺序”这些操作。
子组件里对每个输入框做单向绑定,再包一层更新方法:
<!-- QuestionItem.vue --> <template> <div class="question-item"> <input :value="modelValue.title" placeholder="请输入题干" @input="updateField('title', $event.target.value)" /> <select :value="modelValue.questionType" @change="updateField('questionType', Number($event.target.value))" > <option :value="1">单选</option> <option :value="2">多选</option> <option :value="3">文本</option> </select> <div v-for="(opt, i) in modelValue.options" :key="i"> <input :value="opt" placeholder="选项内容" @input="updateOption(i, $event.target.value)" /> <button @click="removeOption(i)">删除</button> </div> </div> </template> <script setup> const props = defineProps({ modelValue: { type: Object, required: true } }); const emit = defineEmits(['update:modelValue', 'remove']); const updateField = (field, value) => { emit('update:modelValue', { ...props.modelValue, [field]: value }); }; const updateOption = (index, text) => { const options = [...props.modelValue.options]; options[index] = text; emit('update:modelValue', { ...props.modelValue, options }); }; const removeOption = (index) => { const options = [...props.modelValue.options]; options.splice(index, 1); emit('update:modelValue', { ...props.modelValue, options }); }; </script>这里没有直接v-model="modelValue.title",是因为modelValue是父组件传下来的 prop,直接改对象属性在Vue3里会破坏单向数据流,调试时会变得很难追踪。用展开运算符生成新对象,每次修改都能明确知道“题目对象哪一处变了”。updateOption里先复制数组再替换,保证不可变更新;文本题没有选项数组,所以模板里要用v-if="modelValue.questionType !== 3"把选项区包起来,不然控制台会报错。
4.2 答题页的数据收集与校验
答题页的核心是“一个对象接住所有答案”。键是题目id,值是字符串或数组。单选存选项id,多选自动变成数组,文本直接存字符串。
<template> <form @submit.prevent="submit"> <div v-for="q in questions" :key="q.id"> <p> {{ q.title }} <span v-if="q.isRequired" class="required">*</span> </p> <template v-if="q.questionType === 1"> <label v-for="opt in q.options" :key="opt.id"> <input type="radio" :name="'q-' + q.id" :value="opt.id" v-model="answers[q.id]" /> {{ opt.optionText }} </label> </template> <template v-else-if="q.questionType === 2"> <label v-for="opt in q.options" :key="opt.id"> <input type="checkbox" :value="opt.id" v-model="answers[q.id]" /> {{ opt.optionText }} </label> </template> <textarea v-else-if="q.questionType === 3" v-model="answers[q.id]" rows="3" ></textarea> </div> <button type="submit">提交问卷</button> </form> </template> <script setup> import { reactive } from 'vue'; const props = defineProps({ questions: { type: Array, required: true } }); const answers = reactive({}); const submit = () => { for (const q of props.questions) { if (!q.isRequired) continue; const value = answers[q.id]; if (Array.isArray(value) ? value.length === 0 : value === undefined || value === '') { alert('请回答:' + q.title); return; } } const payload = { surveyId: props.surveyId, answers: props.questions.map((q) => { const value = answers[q.id]; return { questionId: q.id, answerText: Array.isArray(value) ? value.join(',') : value }; }) }; console.log('提交数据', payload); // 调用后端接口 }; </script>这里的技巧在于reactive({})里动态加属性是响应式的,v-model="answers[q.id]"在首次渲染时如果answers[q.id]不存在,Vue会自动为它创建空值并绑定。提交校验时,多选字段是一个数组,所以用Array.isArray区分处理;单选和文本字段是字符串或undefined,统一判断空值。把多选数组用join(',')转成逗号分隔字符串,和后端answer_detail.answer_text字段的设计对齐。
4.3 用Axios接后端接口:跨域配置与统一响应处理
前端页面对接Springboot后端,我一般封装一个http实例,统一处理响应码和错误提示。
import axios from 'axios'; const http = axios.create({ baseURL: '/api', timeout: 10000 }); http.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 200) { return Promise.reject(new Error(res.message)); } return res.data; }, (error) => Promise.reject(error) ); export default http;后端约定所有接口返回{ code: 200, message: 'ok', data: ... },前端拦截器直接把data解出来,业务代码里拿到的就是干净的业务对象,不需要每个页面都写response.data.data。超时时间设为10秒,问卷保存到数据库时如果题目数量多,一次事务可能超过3秒,太短会误报网络错误。
前后端分离后跨域是绕不开的问题。开发环境下,前端的Vite默认跑在5173,后端是8080,两个端口不同,浏览器会拦截。后端统一允许跨域:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true); } }注意allowedOrigins不能和allowCredentials(true)一起用*。allowCredentials(true)表示允许携带Cookie,此时源地址必须是明确白名单;如果前端不需要Cookie,可以直接把allowCredentials删掉,允许所有源。问卷系统要做登录态识别,所以保留Cookie并锁定源地址。
5. 避坑/常见问题排查:前后端联调里最常翻车的4个现场
问卷系统本质是表单系统,前后端联调时出问题的地方高度集中。下面这4个坑,基本是我在两个项目里真实踩过的,按“现象 → 原因 → 解决”的顺序写清楚。
5.1 跨域配置好了还是报CORS?先看是不是预检请求被拦了
现象:后端加了CorsConfig,前端请求还是报 “Access-Control-Allow-Origin” 错误,而且只在POST请求时出现,GET请求正常。
原因:当POST请求携带JSON数据时,浏览器会先发一个OPTIONS预检请求。Spring Boot的CorsConfig能处理这个预检,但如果你项目里同时存在自定义过滤器或Spring Security过滤器,且过滤器没有对OPTIONS直接放行,预检请求就会在进入CorsConfig之前被拦截,返回的响应里没有CORS头。
解决:在全局过滤器最前面加一段放行逻辑。我一般在自定义OncePerRequestFilter的doFilterInternal里写:
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin")); response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "*"); return; }这样预检请求在进入业务链路前就被放掉,真实请求再走CorsConfig。如果你用了Spring Security,记住securityFilterChain里必须permitAll掉/api/**,否则登录态验不过。
5.2 问卷接口返回JSON死循环?实体类双向引用惹的祸
现象:接口返回问卷列表时,控制台报 “Could not write JSON: Infinite recursion (StackOverflowError)”,浏览器收到的响应永远转圈。
原因:我在Survey实体里加了List<Question> questions,又在Question实体里加了Survey survey。Jackson序列化Survey时,发现它关联Question,就继续序列化Question,而Question又带着Survey,于是无限递归下去。
解决:最稳妥的方案是不在实体里搞双向关联。Question实体只保留surveyId字段,需要查题目时用Service单独查,而不是在实体上直接@OneToMany。如果项目已经用了MyBatis-Plus,本身也没有JPA那种自动关联,这个坑反而不太容易出现。但如果你用了@TableField(exist = false)加关联列表,一定要注意在其中一个方向加@JsonIgnore:
@JsonIgnore private Survey survey;更推荐的做法是后端返回一个SurveyDetailVO,里面按需组装题目和选项列表。VO只包含前端展示要的数据,彻底绕开实体关系的序列化问题。这个思路在问卷详情接口里也适用。
5.3 前端拿到的时间少了8个小时?LocalDateTime格式化没做干净
现象:前端在表单里查看问卷发布时间,显示的是2024-08-01T10:00:00,而且比数据库里看到的时间差了8个小时。
原因:实体用了LocalDateTime,Jackson对它的默认序列化格式是ISO格式,不带你说的 “yyyy-MM-dd HH:mm:ss”。如果后端配置只写了spring.jackson.date-format,这个配置只对java.util.Date生效,对LocalDateTime完全无效。
解决:我建议直接在实体时间字段上加@JsonFormat,一劳永逸:
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") private LocalDateTime createTime;timezone = "GMT+8"必须带上,不然服务器部署在UTC时区时,返回的时间会少8小时。如果你项目里时间字段特别多,也可以全局自定义一个Jackson配置类,注册LocalDateTimeSerializer,但记得把LocalDateTime的@JsonDeserialize也对应配好,否则反序列化JSON提交时又会出问题。
5.4 Vue项目用history路由,刷新页面就404
现象:本地开发好好的,打包部署到Linux后,点进问卷详情页,一刷新就出现 Nginx 404。
原因:Vue Router用了createWebHistory,路由走的是前端路由,比如/surveys/123。浏览器刷新时,Nginx会到服务器找surveys/123这个文件,找不到就返回404。实际上这个路径应该交给前端index.html去匹配。
解决:在Nginx站点配置里加一个兜底:
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }try_files的意思是:先找真实文件,找不到就全部交给index.html,由Vue Router接管。如果你的问卷后台和API共用同一个域名,记得把/api的location放在前面,让接口请求先被代理到后端Springboot服务,不要被这条规则吞掉。
这个坑非常经典,我跟朋友排查时发现他前端路由模式和部署方式不匹配。换createWebHashHistory确实也能解决,但URL会多一个#,不够整洁。生产环境我倾向于保留history模式,把Nginx配置写对。
6. 进阶:让问卷系统真正能上线的三个小技巧
基础链路跑通之后,真正决定这个系统能不能交付的是几个边角料。第一个必做的是答卷防重复提交。除了前面说的answer_record唯一索引,接口层还应该在提交时先查一次已答记录,给用户返回一个友好提示。只靠数据库约束的话,用户双击提交收到的是一行红字“Duplicate entry”,体验很差。两个手段一起上,前端按钮提交后也要立刻置灰。
第二个技巧是问卷关闭后,通过一条SQL把每个题目的选项分布算出来。文本题没法统计,单选和多选可以用这种思路:
SELECT d.question_id, d.answer_text, COUNT(*) AS count FROM answer_detail d JOIN answer_record r ON d.record_id = r.id WHERE r.survey_id = #{surveyId} AND d.answer_text IN (SELECT id FROM question_option WHERE question_id = d.question_id) GROUP BY d.question_id, d.answer_text ORDER BY d.question_id;这条SQL把明细表里的选项ID和选项表做匹配,直接按题目和选项聚合。注意多选如果存的是1,3,IN匹配不上,所以建表时多选答案如果真要放进answer_text,应该每个选项单独一行。我现在做问卷系统,从建表开始就把多选答案拆成多条明细,统计时就不需要LIKE%1,3%这种性能灾难。
第三个技巧是导出答卷为Excel。网上有EasyExcel的现成API,但如果只是给管理员看,我更喜欢直接用POI手动写列。导出的列顺序按题目ID排序 + 选项结果拼成一个二维列表,导出逻辑和问卷详情接口完全解耦。这里有个习惯值得保留:导出文件名的后缀加上时间戳,避免浏览器缓存同名文件,不然每次导出的数据都是旧的。
现在我做问卷系统,不管对方是不是只要求毕设水平,都会先把状态机、唯一索引和导出文件名的这三个问题写进设计文档再动手。它们单看不复杂,但加在一起决定了这个系统是演示Demo还是一个能交付的内部工具。希望帮到你。
本文还有配套的精品资源,点击获取