做在线问卷系统这个需求,我前后碰过不下三次。最早是用纯后端模板渲染,维护起来累;后来试过快速搭建的低代码平台,但一旦涉及复杂的题型逻辑和自定义统计,灵活度根本不够。所以这次做这套系统的时候,我直接定了Spring Boot + Vue + MySQL这套前后端分离的组合,一次性把管理端、答卷端、统计端全打通。这篇文章就把我这次完整做下来的思路、核心代码、踩坑记录都摊开讲,想搭同类系统或者正在做课程设计、毕业设计的人,都可以直接参考。
先说最核心的结论:这套系统能直接跑起来,是因为后端把接口边界切得足够干净,前端把所有状态维护在内存里,数据库只做最朴素的表结构设计。不要看着“管理系统”四个字就觉得要堆功能,真正关键的只有三件事:问卷的创建与发布、答题人的填写与提交、创建人对结果的统计与导出。把这三件事做透,系统就已经完成八成了。
1. 项目定位与技术选型思路
1.1 这个系统到底解决什么问题
在线问卷系统本质上是一个数据采集工具,它要处理的不是高并发,而是题型多变、数据关系松散、统计需求随机这三件事。
传统的表单工具虽然能快速生成问卷,但它们的问题是:数据在别人那里,统计规则也被平台限制死了。自己从零写一套,好处就是所有东西都在自己掌控里——题目想怎么设计就怎么设计,数据想怎么导出就怎么导出,甚至可以跟公司内部的其他系统做数据对接。
我做这套系统的目标很明确:创建一个问卷,配置若干单选题、多选题、填空题,发布给用户填写,最后能在后台看到每道题的数据统计和原始答卷。整个流程要顺,代码要能看懂,换个人来维护也不会骂人。
1.2 技术栈选型:为什么是Spring Boot + Vue + MySQL
先说后端。Spring Boot在这类系统中的优势不用多说,它已经把Spring生态里那些繁琐的配置全部收敛掉了,一个注解就能暴露一个REST接口,内嵌Tomcat让部署也变得极其简单。不过我更看重的是它的数据访问层——配合Spring Data JPA,一个实体类加一个接口就能完成大部分CRUD操作,特别适合这种表结构清楚、关系不算复杂的业务。
MySQL就是标准答案,没有太多讨论空间。这类管理系统的数据量级,单表几万条问卷记录已经是很大的量了,MySQL内存缓存和索引机制完全扛得住,而且备份、迁移、运维方案都是现成的。
前端选Vue,核心原因是它的数据响应式机制能极大提升问卷编辑器的交互体验。问卷编辑器是一个典型的动态表单场景:题目要增删改、选项要拖拽调整、题型切换时整个数据结构都要跟着变。Vue的响应式数据和组件化开发方式,让这套复杂的交互逻辑变得可控。搭配Element Plus组件库,后台管理界面基本不需要自己造轮子。
这套组合拳打下来,一个核心优势就是代码可读性高。后端按Controller-Service-Mapper分层,前端按路由拆页面,任何一个有基础的开发者拿到代码都能快速定位到具体功能在哪。
2. 系统整体设计与数据库模型
2.1 前后端分离架构的模块划分
系统的功能模块划分可以分成四条业务线:
- 用户中心:注册、登录、个人信息维护。这层是基础,没有它就分不清问卷是谁创建的、数据属于谁。
- 问卷管理:创建问卷、编辑题目、发布问卷、关闭问卷。这里承载的是创建者的核心操作。
- 答卷管理:获取问卷详情、提交答卷、校验必填项。这是被调研者的入口。
- 统计中心:按问卷聚合结果、按题目维度拆解统计、查看原始答卷列表。
对应的后端接口设计,我直接按REST风格来:
| 模块 | 方法 | 路径 | 说明 |
|---|---|---|---|
| 认证 | POST | /api/auth/login | 登录,返回JWT |
| 认证 | POST | /api/auth/register | 注册用户 |
| 问卷 | GET | /api/surveys | 获取当前用户创建的问卷列表 |
| 问卷 | POST | /api/surveys | 创建问卷 |
| 问卷 | GET | /api/surveys/{id} | 获取问卷详情(含题目) |
| 问卷 | PUT | /api/surveys/{id} | 更新问卷及题目 |
| 问卷 | DELETE | /api/surveys/{id} | 删除问卷 |
| 问卷 | PUT | /api/surveys/{id}/publish | 发布/关闭问卷 |
| 答卷 | GET | /api/surveys/{id}/answers | 获取某问卷的答卷列表 |
| 答卷 | POST | /api/surveys/{id}/submit | 提交答卷 |
| 统计 | GET | /api/surveys/{id}/stats | 获取问卷统计数据 |
REST接口设计的核心要领是用名词表达资源、用HTTP动词表达操作。一开始我把发布问卷设计成/api/survey/publishSurvey,后来重构时改成上面的方式,接口数量没变,但语义清晰了很多,前端调用的时候也基本不用看文档就能猜个八九不离十。
2.2 数据库表设计与核心字段说明
数据库我一共设计了五张表,不多不少:
CREATE TABLE `user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `password` varchar(100) NOT NULL COMMENT '密码哈希值', `nickname` varchar(50) DEFAULT '' COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像地址', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; CREATE TABLE `survey` ( `id` bigint NOT NULL AUTO_INCREMENT, `user_id` bigint NOT NULL COMMENT '创建人ID', `title` varchar(200) NOT NULL COMMENT '问卷标题', `description` varchar(500) DEFAULT NULL COMMENT '问卷说明', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-草稿 1-发布中 2-已关闭', `start_time` datetime DEFAULT NULL COMMENT '可见开始时间', `end_time` datetime DEFAULT NULL COMMENT '可见结束时间', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问卷表'; CREATE TABLE `question` ( `id` bigint NOT NULL AUTO_INCREMENT, `survey_id` bigint NOT NULL COMMENT '所属问卷ID', `type` varchar(20) NOT NULL COMMENT 'radio/checkbox/text', `title` varchar(500) NOT NULL COMMENT '题目内容', `required` tinyint NOT NULL DEFAULT '0' COMMENT '是否必答', `sort_order` int NOT NULL DEFAULT '0' COMMENT '排序值', `options_json` text COMMENT '题目选项,JSON数组格式', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_survey_id` (`survey_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问题表'; CREATE TABLE `survey_response` ( `id` bigint NOT NULL AUTO_INCREMENT, `survey_id` bigint NOT NULL, `respondent_id` bigint DEFAULT NULL COMMENT '答题人ID,可为空', `ip` varchar(50) DEFAULT NULL COMMENT '答题IP', `duration` int DEFAULT NULL COMMENT '答题耗时(秒)', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_survey_id_created` (`survey_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷记录表'; CREATE TABLE `answer` ( `id` bigint NOT NULL AUTO_INCREMENT, `response_id` bigint NOT NULL COMMENT '答卷ID', `question_id` bigint NOT NULL COMMENT '问题ID', `content` text COMMENT '答案内容,选择题存选项值,填空题存文本', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_response_id` (`response_id`), KEY `idx_question_id` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答案明细表';有几个设计细节值得说明。
第一,选项用JSON文本字段存,而不是单独抽一张选项表。这是问卷系统最常见的设计分歧点。抽选项表的好处是数据规范化,可以在数据库层面做关联查询;坏处是前端交互会变得极其繁琐——每次增删选项都要发一次接口,拖动排序更是灾难。我选择用options_json字段,把一个题目的全部选项序列化成JSON数组。这确实牺牲了一点数据库层面的规范性,但换来了整个问卷编辑器交互的极大简化。后端拿到这个字段反序列化成List,前端绑定数组直接渲染,整个过程行云流水。
第二,答案内容统一用text字段存。单选题存选项的key(比如 "A"),多选题存选项key的JSON数组(["A","B"]),填空题直接存文本。没有按题型分字段,是因为统计的时候,反正都要在Java代码里做类型转换和重组,分开存反而是自找麻烦。
第三,问卷状态用tinyint而不是字符串。0表示草稿、1表示发布中、2表示已关闭。用数字的好处是查询快、占空间小;坏处是代码里到处都是魔法数字。所以我在后端定义了一个枚举类:
public enum SurveyStatus { DRAFT(0, "草稿"), PUBLISHED(1, "发布中"), CLOSED(2, "已关闭"); private final int value; private final String desc; // getter、构造器省略 }这样写SQL查询时直接WHERE status = 1,Java代码里用枚举做语义校验,两全其美。
3. 后端核心实现拆解
3.1 工程结构与接口分层
后端工程结构采用经典的分层架构:
src/main/java/com/example/survey/ ├── SurveyApplication.java // 启动类 ├── config/ │ ├── SecurityConfig.java // Spring Security配置 │ └── CorsConfig.java // 跨域配置 ├── controller/ │ ├── AuthController.java // 登录注册 │ ├── SurveyController.java // 问卷管理 │ ├── ResponseController.java // 答卷提交与查询 │ └── StatsController.java // 统计接口 ├── service/ │ ├── AuthService.java │ ├── SurveyService.java │ ├── ResponseService.java │ └── StatsService.java ├── repository/ │ ├── UserRepository.java │ ├── SurveyRepository.java │ ├── QuestionRepository.java │ ├── SurveyResponseRepository.java │ └── AnswerRepository.java ├── entity/ │ ├── User.java │ ├── Survey.java │ ├── Question.java │ ├── SurveyResponse.java │ └── Answer.java ├── dto/ │ ├── LoginRequest.java │ ├── LoginResponse.java │ ├── SurveyRequest.java // 创建/更新问卷的请求体 │ ├── QuestionRequest.java // 题目请求体 │ └── SubmitRequest.java // 答题提交请求体 ├── common/ │ ├── Result.java // 统一返回包装 │ ├── BusinessException.java // 业务异常 │ └── GlobalExceptionHandler.java // 全局异常处理 └── util/ ├── JwtUtil.java // JWT工具类 └── JsonUtil.java // JSON序列化工具这套分层的逻辑其实很朴素:Controller只接收参数和返回结果,Service处理业务判断,Repository只负责数据库交互。新手最常犯的错是把业务逻辑全堆在Controller里,一个方法写两百行,这样短期看是快,但后期想加一个统计接口,就会发现根本无处下手。
实体类方面,有几个Java映射的技巧值得说。比如Question实体里有一个optionsJson字段,数据库对应options_json列,Java类型却是String:
@Entity @Table(name = "question") public class Question { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "survey_id") private Long surveyId; private String type; private String title; private Boolean required; @Column(name = "sort_order") private Integer sortOrder; @Column(name = "options_json", columnDefinition = "TEXT") private String optionsJson; }前端传过来的options是数组,不能直接映射进实体。所以我在QuestionRequestDTO里定义的是List<String> options,在Service层手动转换成JSON字符串再赋值给实体。这个转换过程很简单,但一定要统一在一个地方做,别在Controller做一半、Service又做一半,否则后期会非常痛苦。
3.2 认证模块:JWT登录流程
认证模块我用了JWT(JSON Web Token)+ Spring Security的组合。JWT的优势在于无状态——服务器不需要保存session,客户端每次请求把token放在请求头里带过来,服务器通过签名校验就能确认身份。这在前后端分离的场景下非常合适,尤其是将来要做小程序端或者App端,同一套认证逻辑完全可以复用。
JWT工具类的核心代码:
public class JwtUtil { // 秘钥,实际项目中一定要放到配置文件里,不能写死 private static final String SECRET = "your-secret-key"; private static final long EXPIRE_TIME = 7 * 24 * 60 * 60 * 1000L; // 7天 public static String generateToken(Long userId, String username) { Date now = new Date(); Date expireDate = new Date(now.getTime() + EXPIRE_TIME); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("username", username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET) .parseClaimsJws(token) .getBody(); } }登录接口的处理逻辑是:先查数据库比对密码,密码用BCrypt加密,校验通过后生成token返回给前端。前端拿到token存到localStorage里,之后每个请求都在拦截器里加上Authorization: Bearer xxx请求头。
密码加密这块必须点名提醒:绝对不要明文存密码,也绝对不要用MD5直接加密。MD5虽然也叫加密,但彩虹表攻击对这个算法太有针对性了。Spring Security自带BCrypt,加盐算法在实践中验证过非常稳,直接在注册时BCryptPasswordEncoder.encode(),登录时.matches()比对即可。
我踩过的一个坑是token过期时间设置。最开始设置了30分钟,结果前端那边问卷编辑到一半token就过期了,突然跳到登录页,用户心态直接崩了。其实对于这种内部使用的管理系统,token有效期设置得长一些完全没问题,我最终设了7天。安全性和用户体验之间,这类内部工具更应该偏向后者。
还有一个全局异常处理的点也要提。如果不做统一异常处理,发生业务错误时Spring默认返回的JSON格式是它自己定义的结构,前端处理起来很不方便。我封装了一个Result类:
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }然后用@RestControllerAdvice统一捕获业务异常,前端拿到code != 200就弹出message,逻辑完全通顺。
3.3 问卷与题目的数据模型处理
问卷接口是整个系统最重的部分,尤其创建和更新接口的设计要特别谨慎。前端问卷编辑器支持的操作包括:新增题目、删除题目、上移下移题目、编辑题目内容、修改题目类型、编辑题目的选项列表,这些操作在保存时是一次性批量提交的。
对应的后端保存逻辑是:接收整个问卷的DTO,包含问卷基本信息和题目列表,然后在事务里先删掉原有题目,再批量插入新题目。
@Transactional public Long saveSurvey(SurveyRequest request) { Survey survey = new Survey(); AbstractBeanUtil.copyProperties(request, survey); // 基本信息拷贝 survey.setUserId(CurrentUser.getId()); survey.setStatus(SurveyStatus.DRAFT.getValue()); surveyRepository.save(survey); // 先删 questionRepository.deleteBySurveyId(survey.getId()); // 再插 List<QuestionRequest> questionList = request.getQuestions(); for (int i = 0; i < questionList.size(); i++) { QuestionRequest qr = questionList.get(i); Question question = new Question(); question.setSurveyId(survey.getId()); question.setType(qr.getType()); question.setTitle(qr.getTitle()); question.setRequired(qr.getRequired()); question.setSortOrder(i); question.setOptionsJson(JsonUtil.toJson(qr.getOptions())); questionRepository.save(question); } return survey.getId(); }“先删再插”这个策略,我一开始也犹豫过——万一删除成功但插入失败怎么办?所以整个方法加了@Transactional注解,一旦中间抛异常,整个事务回滚,数据库不会出现题目丢一半的状态。实践证明这个策略在问卷编辑场景下足够用,题目量级最多几十道,重新插入的开销可以忽略不计。
页面排序的实现方式值得说一下。前端用<el-draggable>实现题目的拖拽排序,拖完之后每道题的顺序就已经变了,保存时前端按顺序传过来,后端按数组下标写sortOrder。用下标代替显式的排序数值,前端零计算,后端零错误,就是这个设计最妙的地方。
3.4 答卷提交与统计聚合
答卷提交接口需要注意“答题数据完整性”的问题。两道题之间的答案不能错位,一个人在同一时间不能重复提交同一个问卷。
@Transactional public void submit(Long surveyId, SubmitRequest request) { // 校验问卷是否发布中 Survey survey = surveyRepository.findById(surveyId) .orElseThrow(() -> new BusinessException("问卷不存在")); if (survey.getStatus() != SurveyStatus.PUBLISHED.getValue()) { throw new BusinessException("问卷不在可填写状态"); } // 这里根据业务需求,可选限制单用户只能提交一次 // 如果允许匿名填写,则通过IP和userId做约束 SurveyResponse response = new SurveyResponse(); response.setSurveyId(surveyId); response.setRespondentId(CurrentUser.getId()); response.setIp(request.getIp()); responseRepository.save(response); for (SubmitItem item : request.getAnswers()) { Answer answer = new Answer(); answer.setResponseId(response.getId()); answer.setQuestionId(item.getQuestionId()); answer.setContent(item.getContent()); answerRepository.save(answer); } }统计接口是这套系统里最有含金量的一部分。设计统计逻辑的时候,我发现直接用SQL做GROUP BY非常麻烦,因为多选题的答案是一个包含多个选项值的JSON数组,需要先展开再聚合。在Java代码里做反而更直观:
public SurveyStats getStats(Long surveyId) { Survey survey = surveyRepository.findById(surveyId).orElseThrow(); List<Question> questions = questionRepository.findBySurveyIdOrderBySortOrderAsc(surveyId); List<Answer> answers = answerRepository.findAnswersBySurveyId(surveyId); // 按questionId分组 Map<Long, List<Answer>> grouped = answers.stream() .collect(Collectors.groupingBy(Answer::getQuestionId)); List<QuestionStats> questionStatsList = new ArrayList<>(); for (Question q : questions) { List<Answer> qAnswers = grouped.getOrDefault(q.getId(), Collections.emptyList()); QuestionStats stats = new QuestionStats(); stats.setQuestionId(q.getId()); stats.setQuestionTitle(q.getTitle()); stats.setAnswerCount(qAnswers.size()); if ("text".equals(q.getType())) { // 填空题:直接收集所有文本 List<String> texts = qAnswers.stream() .map(Answer::getContent) .collect(Collectors.toList()); stats.setTextAnswers(texts); } else { // 选择题:解析选项key,逐项累加 Map<String, Integer> countMap = new LinkedHashMap<>(); for (String opt : JsonUtil.fromJson(q.getOptionsJson(), List.class)) { countMap.put(opt, 0); } for (Answer ans : qAnswers) { List<String> keys = "checkbox".equals(q.getType()) ? JsonUtil.fromJson(ans.getContent(), List.class) : Collections.singletonList(ans.getContent()); for (String key : keys) { countMap.merge(key, 1, Integer::sum); } } stats.setOptionCounts(countMap); } questionStatsList.add(stats); } SurveyStats result = new SurveyStats(); result.setSurveyId(surveyId); result.setResponseCount(answers.isEmpty() ? 0 : answers.size()); result.setQuestionStats(questionStatsList); return result; }这里有个隐藏性能点:如果答卷量很大,findAnswersBySurveyId一次性把数据全部拉进内存,确实可能出现内存压力。但在问卷系统场景里,一份问卷能有几千份答卷已经是极限了,几千条数据在内存里做聚合,毫秒级就完成了,比写复杂的原生SQL维护起来舒服得多。什么时候该用SQL做聚合,什么时候该用代码做聚合,这是一个典型的trade-off判断,没有绝对的对错,只有适不适合当前场景。
4. 前端核心实现拆解
4.1 工程初始化与路由/权限设计
前端工程我直接用Vite构建,Vue3的Composition API写起来比Vue2的Options API顺手很多。工程初始化时用npm create vite@latest survey-web,选Vue + JavaScript模板,然后手动安装依赖:
npm install vue-router@4 pinia axios element-plus echarts路由设计这块,我划分成三类页面:
- 公开页面:登录页
/login、答卷填写页/survey/:id——这些不能加路由守卫,因为答题人可能根本没有登录账号。 - 用户页面:问卷列表
/survey/list、问卷编辑器/survey/edit/:id——这些需要登录后才能访问。 - 其他:404页。
路由守卫用Vue Router官方提供的全局前置守卫:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });这里有个容易忽视的细节:不能只在前端判断token是否存在就放行,因为token可能已经过期了。我更推荐在axios的响应拦截器里统一处理401状态码:
axios.interceptors.response.use( response => response, error => { if (error.response?.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );这样无论token缺失还是过期,都会一致性引导用户重新登录。之前的做法是在用户页里逐个页面判断token,后来发现这套统一处理才是正道。
Pinia作为状态管理库,我主要用来存用户信息。其实这个项目不复杂,不用Pinia也能跑,但用了之后,问卷编辑器里跨组件的状态沟通会简单很多,比如草稿保存状态的展示、题目列表的统一操作入口。
4.2 问卷编辑器的交互实现
问卷编辑器是整个前端最复杂的一块,核心是维护一个问卷的数据模型,所有操作都是对这个模型的修改。
const surveyModel = reactive({ title: '', description: '', questions: [] }); function addQuestion(type = 'radio') { surveyModel.questions.push({ id: Date.now(), // 临时ID type: type, title: '', required: false, options: ['选项1', '选项2'] }); } function removeQuestion(index) { surveyModel.questions.splice(index, 1); } function moveQuestion(index, direction) { const target = index + direction; if (target < 0 || target >= surveyModel.questions.length) return; const temp = surveyModel.questions[index]; surveyModel.questions[index] = surveyModel.questions[target]; surveyModel.questions[target] = temp; }用临时ID(Date.now())是我的习惯,前端操作时不需要关心数据库里的真实ID,保存时后端会根据数组顺序重建。如果题目已经存在数据库里,保存时要把真实ID也带上,后端用来处理“先删再插”时的关联——不过我这个方案里,直接全删全插,真实ID只用于编辑时回显题目内容的映射。
问卷填写页相对简单一些,但有几个体验细节值得打磨。必填校验不能只依赖后端,前端的即时反馈更直观。我用Element Plus表单的校验规则,动态生成rules:
const rules = computed(() => { const res = {}; surveyModel.questions.forEach(q => { if (q.required) { res['question_' + q.id] = [{ required: true, message: '该题为必答题', trigger: q.type === 'text' ? 'blur' : 'change' }]; } }); return res; });多选题的答案处理也比想象中麻烦,因为Element Plus的checkbox组绑定的是数组,提交后端时要把数组序列化成JSON,再作为content传给后端。
4.3 答卷填写页与结果图表
结果展示页面我用了ECharts。问卷统计页里,每个选择题渲染一个饼图或者柱状图,填空题渲染一个表格列表。
ECharts的接入方式很简单,安装后按需引入:
import * as echarts from 'echarts/core'; import { PieChart, BarChart } from 'echarts/charts'; import { TooltipComponent, LegendComponent } from 'echarts/components'; import { CanvasRenderer } from 'echarts/renderers'; echarts.use([PieChart, BarChart, TooltipComponent, LegendComponent, CanvasRenderer]);在Vue里使用图表组件时,要特别注意在DOM渲染完成后初始化实例,并且组件销毁前要调用dispose释放资源:
onMounted(() => { chart = echarts.init(document.getElementById('chart_' + props.questionId)); chart.setOption(buildOption(props.stats)); }); onBeforeUnmount(() => { chart?.dispose(); });这里遇到过一个经典问题:问卷列表页把选项key和选项值用一种样式展示,但统计页需要传入完整的选项名称。如果问卷创建时选项key直接是中文,那统计图表的x轴标签就直接显示中文,无需映射,所以简化设计:选项的key就用选项文本本身,不要用A、B、C这种代号,不然统计页还得维护一张映射表,平白增加复杂度。
5. 运行配置与联调细节
5.1 环境准备与启动步骤
这套系统的运行环境要求不复杂:
| 环境 | 版本建议 | 说明 |
|---|---|---|
| JDK | 17+ | Spring Boot 3.x要求最低17 |
| Maven | 3.8+ | 后端依赖管理 |
| Node.js | 18+ | Vite 5要求 |
| MySQL | 5.7+ / 8.x | 使用utf8mb4字符集 |
启动步骤我整理成了README里的STANDARD流程:
第一步,初始化数据库。
CREATE DATABASE survey_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后Spring Boot配置了spring.jpa.hibernate.ddl-auto=update,所以表结构会由Hibernate自动创建,不需要手动执行建表SQL。但这里要注意:自动建表适合开发环境,生产环境强列建议使用Flyway做版本化管理。自动建表不会帮你维护字段变更,一旦表结构改了,新代码跑在旧库上会报错。
第二步,修改后端配置。在application.yml里配置好数据库连接信息:
spring: datasource: url: jdbc:mysql://localhost:3306/survey_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 jpa: hibernate: ddl-auto: update show-sql: false第三步,启动后端。
cd survey-server mvn spring-boot:run第四步,启动前端。
cd survey-web npm install npm run dev以上四步走完,浏览器打开http://localhost:5173就能看到系统首页了。
5.2 关键配置项
前后端联调时的跨域问题,是我每次做前后端分离项目都要强调的。开发阶段,前端运行在5173端口,后端运行在8080端口。如果不在后端放开跨域限制,浏览器的同源策略会直接拦截掉所有请求。
后端加跨域配置的方式很简单,我放在了独立的CorsConfig里:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }前端在axios里也可以配baseURL指向后端地址,省得每个请求都写全路径:
axios.defaults.baseURL = 'http://localhost:8080/api';还有一个细节是allowCredentials(true)和allowedOriginPatterns("*")的搭配。如果配置了allowCredentials(true),就必须使用allowedOriginPatterns而不是allowedOrigins("*"),否则Spring会直接拒绝请求,这个坑我自己是踩过的,特意记在这里。
生产环境构建时,前端执行npm run build,生成dist目录,后端通过静态资源映射把前端页面托管起来:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/**") .addResourceLocations("classpath:/static/"); } }简单粗暴但很有效,整个系统打成一个jar包,部署时只要确保服务器有JRE就行。
6. 常见问题与排查经验
这套系统从开发到调试,我遇到了不少典型问题。有些是常识性错误,有些是设计缺陷导致的后知后觉。整理成表格,方便大家遇到问题时候直接排查:
| 问题类别 | 具体现象 | 排查思路 | 解决方案 |
|---|---|---|---|
| 划批次 | 前端请求后端报404 | 检查 @RequestMapping 路径和方法上的路径是否拼接正确 | 用/api/surveys/{id}这种统一前缀 |
| 划批次 | 提交答卷后数据一直保存失败 | 检查答案的questionId是否真实存在 | 保存前先查询问卷的所有题目ID做合法性校验 |
| 跨域 | 浏览器控制台报CORS错误 | 确认后端CorsConfig是否放行 | 参考5.2的Cors配置 |
| 编码 | 问卷标题中文变成问号 | 数据库字符集不是utf8mb4 | 建库时显式指定utf8mb4 |
| 权限 | 未登录也能访问问卷编辑页 | 路由守卫没生效 | 路由meta加requiresAuth,在beforeEach里拦截 |
| 数据一致性 | 多人同时提交,统计结果对不上 | 非事务导致部分写入成功 | 提交方法加@Transactional |
6.1 后端常见问题
问题一:JPA懒加载导致序列化报错。如果Entity之间存在关联关系(比如Survey里@OneToMany标注了questions),在Controller直接返回Entity时,Jackson序列化可能会触发懒加载异常LazyInitializationException。我的处理方式很干脆:Controller层一律不返回Entity,统一转成DTO或直接返回Service层组装好的VO。这样既规避了懒加载问题,也避免了Entity结构变动影响接口稳定性。
问题二:事务回滚不生效。有人会踩@Transactional注解放在方法内部调用的场景里,比如类A的方法调用同类里的B方法,B上的事务注解失效了。原因是Spring的事务是基于代理实现的,同类内部方法调用不走代理,注解就不会生效。解决方案是把B方法放到另一个Service类里,或者用AopContext.currentProxy()获取当前代理再调用。
问题三:时间时区问题。中国用户录入的问卷开始时间是“2025-06-01 10:00”,但写入MySQL后,查询出来发现少了8小时。原因有两层:一是JDBC连接串里没配serverTimezone=Asia/Shanghai;二是MySQL本身时区设置问题。检查一下连接串和MySQL的time_zone,前后对齐就能解决。
6.2 前端常见问题
问题一:Element Plus表单校验不生效。表单校验规则里要求FormItem的prop值必须与校验规则里的字段名对应,并且表单绑定的是model对象和rules对象。很多初学者会漏掉FormItem的prop,导致校验规则根本触发不了——因为校验器没法找到对应的字段去取值。
问题二:ECharts图表宽高为0。如果图表容器在页面初始渲染时是隐藏状态(比如在标签页里),ECharts初始化后宽度会是0,图表直接不显示。后来我改成了在标签切换的事件里重新调用chart.resize()并setOption,问题就消失了。
问题三:问卷编辑器里选项的v-model绑定。在v-for循环里给选项输入框绑定v-model="option.text",一开始觉得没问题,后来发现选项的增删操作会让某些输入框的内容串位。原因是我直接用了数组下标作为key,删除中间元素后,后面的元素复用之前的key,Vue的虚拟DOM复用了组件实例,状态就乱了。解决方式是用一个自增id或者唯一值作为key,绝不要用下标。
6.3 数据库相关经验
问卷系统的统计功能一开始我准备直接写聚合SQL:
SELECT question_id, content, COUNT(*) FROM answer WHERE question_id IN (...) GROUP BY question_id, content后来思考了一下还是选择了在Java里聚合,原因有三点:
- 多选题答案的JSON解析在SQL里很难处理,MySQL的
JSON_TYPE函数虽然可以做,但写出来非常晦涩,维护成本高。 - 统计逻辑里涉及题型判断、选项映射、顺序保持,这些在代码里表达更自然。
- 数据量级小,性能差距可以忽略。
结论是:SQL和Java各有适用的场景,统计聚合这一步跟着业务复杂度走,业务复杂就代码做,业务简单才让SQL做。
7. 写在最后:几点实操体会
这套系统做下来,我最大的感受是:前后端分离的项目,真正的成本不在写代码,而在设计数据契约。前端和后端各写各的,一旦接口定义不清晰,联调阶段就是灾难。所以我在动手开发之前,先把接口文档和数据结构定义好,整个开发周期反倒被压缩了将近一半。
我个人在实操中特别想提醒的是:别追求一步到位的完美设计,先把主流程跑通。我最初想要的功能列表很长,包括问卷复制、问卷模板、用户权限的细粒度控制、答卷的导出Excel,但后来发现把这些全做完再调试主流程,周期会被拖得很长。所以我砍掉迭代,先保证“创建问卷->填写问卷->查看统计”这条链路完全顺畅,剩下的功能都是在这条链路的基础上做加法。
如果你也想做一套类似的系统,我的建议是复制我的数据库设计,它已经过实践检验,覆盖了问卷类业务的核心场景。前端编辑器部分可以先用表格形式平铺题目,等主流程通了再优化成拖拽交互。在开发和调试过程中,遇到问题时仔细排查第六节列出的方向,基本都能解决。
这套系统后续还可以扩展的方向也不少:比如在问卷中增加分页跳转逻辑、引入用户答题时长的埋点分析、支持问卷结果的导出PDF,甚至接入一个简单的消息通知模块,让创建者在收到新答卷时及时得到提醒。这些都是在这套代码基础上的自然生长点,有需要的话我们可以在后续内容里继续深挖。