简介:面向正在学习Java Web整合开发或需要完成课程设计、毕业设计的学生与初级开发者,这套基于SpringMVC、Spring与MyBatis的问卷调查系统提供了完整的前后端实现。项目区分管理员与普通用户两类权限:管理员可维护管理员信息、制作并发布调查问卷、统计调查结果并生成报表;用户端则以填写问卷为主,页面交互由JSP、jQuery及layui完成。压缩包共847个文件,整体42.05MB,核心代码集中在Java、JSP与XML配置中,同时包含JS、CSS等前端资源及SQL脚本、pom.xml,导入MySQL并在Tomcat 7/8/9下即可运行。从内容预览可看出分层结构覆盖Controller、Service、Mapper,目录模块清晰,便于阅读与二次开发。目前已有1225人学习浏览,适合作为SSM整合项目的入门参照,也可在此基础上扩展动态表单、问卷分发与统计图表等实战功能。
1. 一套组合拳:SSM+Layui+JSP 做问卷系统的取舍
先抛个反直觉的结论:在做问卷调查类系统时,用 JSP 做页面渲染,比现在热门的 Vue + 前后端分离更省事。原因不是技术怀旧,而是这类系统天然是“重后端、轻前端”的形态。题库管理、问卷发布、答卷收集和统计导出,所有核心逻辑都集中在服务端,页面交互几乎就是表单填写和数据表格展示。SSM(Spring + SpringMVC + MyBatis)负责把业务和 SQL 写清楚,Layui 负责把表格和弹窗做得不难看,JSP 承担服务端渲染和页面跳转,MySQL 存全部业务数据,这套组合在中小型内部系统、课程设计和毕业设计里依然能打。适合的人群很明确:还在用 SSM 做项目的人、需要快速交付一个可用问卷系统的开发者,以及想理解传统 MVC 架构下完整开发流程的初学者。接下去我会按“表结构 → 后端接口 → 前端交互 → 排错技巧”的顺序,把一个可运行的问卷系统拆开讲。
2. 问卷系统的表结构设计:四张表和题型存储策略
2.1 核心业务表划分
问卷调查系统的数据模型,核心是问卷、题目、题目选项、用户答卷四个实体。对应的表分别是survey、question、question_option、answer_record。其中survey存问卷本身的信息,比如标题、描述、状态、创建时间;question存问卷下的每道题,包括题型、题干、是否必填和排序;question_option存选择题的选项;answer_record存用户提交的每一题答案。这个模型不复杂,但有一个容易被忽视的设计点:问卷和题目是一对多,而题目和选项是一对多,这三层嵌套在录入和读取时都需要注意顺序和级联关系。
2.1.1 问卷表与题目表
问卷表字段不宜过多,通常保留问卷标题、副标题、开始时间、结束时间、是否发布、创建人、创建时间。题目表必须存survey_id作为外键关联,同时存题型常量。题型我通常用int类型而不是字符串,因为单选题、多选题、填空题在后续判断逻辑里用switch比用if字符串比较清晰。排序字段sort_order也必须有,不然题目顺序就只能依赖id,一旦后期做题目插入或修改,顺序就乱了。
2.1.2 选项表与答卷表
选项表相对简单,字段是question_id、选项文本、排序号。这里有个细节:选项不要存成“A、B、C”这种带字母的文本,前后端渲染时动态生成字母前缀,否则一旦在中间插入选项,字母顺序调整就很麻烦。答卷表则要同时记录survey_id、question_id、option_id和answer_text,对单选题而言option_id有值,对填空题而言answer_text有值,多选题我会让每条选项独立一行记录,而不是用逗号拼接存进一个字段。逗号拼接的好处是表结构简单,坏处是统计每道题选项分布时要把字符串拆开,SQL 写起来很痛苦,所以宁可多几行数据。
2.2 建表 SQL 与字段意图
我用的 MySQL 版本是 5.7,如果迁移到 8.0 也向下兼容。下面是建表 SQL,注释里标注了每个字段的用途和设计理由。
CREATE TABLE `survey` ( `id` int(11) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL COMMENT '问卷标题', `description` varchar(500) DEFAULT NULL COMMENT '问卷说明', `status` tinyint(4) DEFAULT '0' COMMENT '0未发布 1已发布 2已结束', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '截止时间', `creator` varchar(64) DEFAULT NULL COMMENT '创建人', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问卷表'; CREATE TABLE `question` ( `id` int(11) NOT NULL AUTO_INCREMENT, `survey_id` int(11) NOT NULL COMMENT '所属问卷ID', `question_type` tinyint(4) NOT NULL COMMENT '1单选 2多选 3填空', `title` varchar(500) NOT NULL COMMENT '题干', `required_flag` tinyint(4) DEFAULT '1' COMMENT '是否必填 1是 0否', `sort_order` int(11) DEFAULT '0' COMMENT '排序,越小越靠前', PRIMARY KEY (`id`), KEY `idx_survey_id` (`survey_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目表'; CREATE TABLE `question_option` ( `id` int(11) NOT NULL AUTO_INCREMENT, `question_id` int(11) NOT NULL COMMENT '所属题目ID', `option_text` varchar(200) NOT NULL COMMENT '选项文本', `sort_order` int(11) DEFAULT '0' COMMENT '排序', PRIMARY KEY (`id`), KEY `idx_question_id` (`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='题目选项表'; CREATE TABLE `answer_record` ( `id` int(11) NOT NULL AUTO_INCREMENT, `survey_id` int(11) NOT NULL COMMENT '问卷ID', `question_id` int(11) NOT NULL COMMENT '题目ID', `option_id` int(11) DEFAULT NULL COMMENT '选中选项ID,填空题为空', `answer_text` varchar(1000) DEFAULT NULL COMMENT '填空内容或补充说明', `user_key` varchar(64) DEFAULT NULL COMMENT '答题人标识,如IP+时间戳', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_survey_question` (`survey_id`, `question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答卷记录表';这里有两个设计意图需要说明。第一,user_key字段不是用户ID,而是答题人的匿名标识,因为问卷系统往往不需要登录,我用 IP 加随机数生成的字符串区分不同答卷。第二,answer_record的联合索引idx_survey_question是为了统计每道题分布时避免全表扫描,比如“第一题选 A 的有多少人”这种查询,没有这个索引数据量大了之后会明显变慢。
2.3 外键与字段类型的边界
我刻意没有在表上建物理外键约束,只建了普通索引。原因有两个:一是物理外键会让批量插入和删除的效率降低,二是问卷系统的题目和选项通常只在编辑时变化,删除/修改操作集中在管理端,逻辑上的关联关系用JOIN维护就可以了。如果你做毕业设计,物理外键倒是可以加上,因为答辩时导师更看重的是完整性设计而非性能优化。字符集选utf8mb4是为了支持用户填写的表情符号,这个问题在老系统里经常踩坑,默认的utf8在插入 Emoji 时直接报错。
3. 后端接口与事务边界:问卷提交的动态校验实现
3.1 分包结构与请求入口
SSM 项目的包结构我保持经典分层:controller、service、mapper、entity。系统涉及的角色分两类,一类是管理员(后台创建问卷),一类是普通用户(前台填写并提交答卷)。管理员端需要提供问卷的增删改查接口,用户端需要提供问卷详情查询和答卷提交接口。这里只挑最核心的“提交答卷”接口展开,因为它是整个系统里业务逻辑最重的部分。
3.1.1 Controller 层代码
@Controller @RequestMapping("/api/survey") public class SurveyController { @Autowired private SurveyService surveyService; @RequestMapping(value = "/submit", method = RequestMethod.POST) @ResponseBody public Result submit(@RequestBody SubmitRequest request) { // 请求对象包含 surveyId 和 answers 列表 return surveyService.submitAnswers(request.getSurveyId(), request.getAnswers()); } }请求参数用@RequestBody接收 JSON,SubmitRequest里的answers是List<AnswerItem>,每个AnswerItem含questionId、optionIds、answerText。这里不直接用HttpServletRequest逐个取参数,因为前台 Layui 的table和表单序列化出来的数据格式不一致,统一走 JSON 交互更稳定。Result是一个简单的统一返回对象,包含code、msg、data三个字段,前端根据code是否为 0 判断提交结果。
3.1.2 Service 层校验逻辑
@Transactional(rollbackFor = Exception.class) public Result submitAnswers(Integer surveyId, List<AnswerItem> answers) { Survey survey = surveyMapper.selectById(surveyId); if (survey == null || survey.getStatus() != 1) { return Result.error("问卷不存在或未发布"); } List<Question> questions = questionMapper.selectBySurveyId(surveyId); if (questions.isEmpty()) { return Result.error("问卷没有题目"); } // 1. 校验必填题是否遗漏 for (Question q : questions) { AnswerItem item = findAnswer(answers, q.getId()); if (item == null && q.getRequiredFlag() == 1) { return Result.error("第" + q.getId() + "题必填"); } } // 2. 多选题最多选5项,单选只能选1项 for (AnswerItem item : answers) { Question q = findQuestion(questions, item.getQuestionId()); if (q.getQuestionType() == 2 && item.getOptionIds().size() > 5) { return Result.error("多选题最多选择5项"); } if (q.getQuestionType() == 1 && item.getOptionIds().size() != 1) { return Result.error("单选题只能选择一个选项"); } } // 3. 逐个落库 String userKey = UUID.randomUUID().toString().replace("-", ""); for (AnswerItem item : answers) { Question q = findQuestion(questions, item.getQuestionId()); if (q.getQuestionType() == 3) { answerRecordMapper.insert(new AnswerRecord(surveyId, q.getId(), null, item.getAnswerText(), userKey)); } else { for (Integer optionId : item.getOptionIds()) { answerRecordMapper.insert(new AnswerRecord(surveyId, q.getId(), optionId, null, userKey)); } } } return Result.success(userKey); }这段代码的关键逻辑有三层:先查问卷状态,再做必填和选项数量校验,最后才写库。注意@Transactional(rollbackFor = Exception.class)这个注解很重要,如果中间某条记录插入失败,整个提交会回滚,不会出现一道题写进去、另一道题没写进去的脏数据。校验时我用findAnswer和findQuestion这两个临时方法在内存里匹配,而不是嵌套循环,数据量小的时候嵌套循环问题不大,但写成 O(n) 的匹配逻辑更清晰。userKey用 UUID 生成,作为这次答卷的唯一标识,在统计时区分不同答题人。
3.2 Mapper 层与 MyBatis 的批量插入
单条insert在数据量小时没有问题,但如果问卷有多选题,一次提交可能产生多条answer_record,循环调用单条 insert 会发起多次数据库往返。我习惯用 MyBatis 的<foreach>做批量插入,一次提交只执行一条 SQL。
<insert id="batchInsert" parameterType="list"> INSERT INTO answer_record (survey_id, question_id, option_id, answer_text, user_key) VALUES <foreach collection="list" item="item" separator=","> (#{item.surveyId}, #{item.questionId}, #{item.optionId}, #{item.answerText}, #{item.userKey}) </foreach> </insert>批量插入有两点要注意:一是foreach的collection必须和 Mapper 接口参数名对应,如果接口是@Param("list") List<AnswerRecord> records,这里就写list;二是拼接的 SQL 有长度限制,max_allowed_packet默认是 4M,一份问卷一两百道题完全够用,但如果做导出或大量同步场景就要注意分批。批量插入失败时,MyBatis 会把整个 batch 视为一个事务单元,配合 Service 层的@Transactional,任意一条失败都会全部回滚,这也是为什么事务注解必须加在批量插入的调用方。
4. Layui 表格渲染与 JSP 页面交互细节
4.1 前端资源定位与公共页
JSP 页面放在WEB-INF/views目录下,Layui 的静态资源放在webapp/static。SpringMVC 的视图解析器配置决定了 JSP 的查找路径和前缀后缀,我配置如下:
<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/" /> <property name="suffix" value=".jsp" /> </bean>这意味着 Controller 返回"survey/list"时,容器实际加载/WEB-INF/views/survey/list.jsp。把页面放在WEB-INF下有个好处,用户无法通过 URL 直接访问 JSP,必须经过 Controller 转发,这对问卷管理端来说多了一层访问控制。Layui 的引入方式是在 JSP 顶部写上<%@ page contentType="text/html;charset=UTF-8" language="java" %>,然后在<head>里用<link>和<script>引入 layui.css、layui.js,以及 jQuery。这里有一个常见问题:JSP 里的静态资源路径不能写死成/static/,因为项目部署到 Tomcat 后根路径可能带项目名,所以必须用 EL 表达式取上下文路径,写法是${pageContext.request.contextPath}。
4.1.1 问卷列表页渲染代码
<table id="surveyTable" lay-filter="surveyTable"></table> <script> layui.use(['table', 'layer'], function () { var table = layui.table; var layer = layui.layer; table.render({ elem: '#surveyTable', url: '${pageContext.request.contextPath}/api/survey/list', method: 'get', page: true, cols: [[ {field: 'id', title: 'ID', width: 80}, {field: 'title', title: '问卷标题'}, {field: 'status', title: '状态', width: 100, templet: function (row) { if (row.status === 1) return '已发布'; if (row.status === 2) return '已结束'; return '未发布'; }}, {field: 'createTime', title: '创建时间', width: 170}, {title: '操作', width: 160, toolbar: '#bar'} ]], text: {none: '暂无数据'} }); }); </script>这段代码里最值得说的是templet这个属性,它支持把一个数字类型的字段(比如status)渲染成可读的文字,不必在 Java 后端做字典转换。工具栏toolbar: '#bar'对应页面里隐藏的<script type="text/html" id="bar">模板块,里面写编辑和删除按钮。转发到 JSP 后,Controller 返回的 JSON 格式必须符合 Layui table 的约定,即{"code":0,"msg":"","count":100,"data":[...]},不然表格无法渲染。我通常在 Controller 里用LayuiTableResult这个专用对象包装,字段名严格按下划线转驼峰对应。
4.2 填卷页面与表单交互
用户打开问卷后,JSP 页面通过 jQuery 动态加载问卷详情接口,按题目类型循环生成表单元素。Layui 的表单组件有个特点:动态生成的<input>和<select>必须重新调用form.render(),否则新元素不会应用 Layui 的样式,也不会触发校验。这一点是 Layui 新手容易忽略的,因为官方文档的示例都是静态页面,放在 JSP 动态渲染场景下经常出现“表单样式有但提交时 ‘Uncaught TypeError: form.on is not a function’”之类的报错。
4.2.1 答卷提交的核心逻辑
function submitAnswer() { var answers = []; var questionItems = $('.question-item'); var validFlag = true; // 遍历题目元素,组装提交数据 questionItems.each(function () { var item = $(this); var qid = item.attr('data-question-id'); var type = item.attr('data-type'); var questionAnswer = {questionId: qid, optionIds: [], answerText: ''}; if (type === '1' || type === '2') { var checked = item.find('input[type=checkbox]:checked, input[type=radio]:checked'); if (checked.length === 0) { layer.msg('还有题目未回答'); validFlag = false; return false; } checked.each(function () { questionAnswer.optionIds.push($(this).val()); }); } else { var textVal = item.find('textarea').val(); if (!textVal) { layer.msg('填空内容不能为空'); validFlag = false; return false; } questionAnswer.answerText = textVal; } answers.push(questionAnswer); }); if (!validFlag) { return; } // 提交到后端接口 $.ajax({ url: ctx + '/api/survey/submit', type: 'POST', contentType: 'application/json', data: JSON.stringify({surveyId: surveyId, answers: answers}), success: function (res) { if (res.code === 0) { layer.msg('提交成功', {icon: 1}); setTimeout(function () { location.href = ctx + '/survey/thanks'; }, 800); } else { layer.msg(res.msg, {icon: 2}); } } }); }这段逻辑并不复杂,但它把“表单校验”和“接口提交”分离了:前端只校验有没有作答,不能校验多选题是否超过 5 项,那样太容易被绕过,真正的数量限制在后端 Service 层做。ctx变量我在 JSP 里定义成${pageContext.request.contextPath},这样所有 ajax 请求都带上项目上下文路径,避免部署路径变更时接口 404。填完提交成功后跳转到感谢页,这个页面不需要走逻辑,直接返回一个静态 JSP。
4.3 刷新问题与 Layui 常见坑
抖个机灵:热搜词里有个“layui tabs 刷新页面”。管理端往往要用layui-tab切换不同模块,切回“问卷列表”Tab 时表格数据不会自动刷新,因为table.render()只在页面加载时执行一次。解决办法是在 Tab 切换的事件监听里重新加载表格数据,写法是table.reload('surveyTable', {page: {curr: 1}}). 这个id不是elem选择器,而是table.render里配置的id属性。如果渲染时没有加id,reload会报“Table not found”. 所以渲染和刷新要配对使用。
5. 高频报错排查与统计答卷的实用技巧
5.1 我踩过的三个坑
第一个坑是 MySQL 版本导致驱动类名报错。MySQL 5.x 和 8.x 的 JDBC 驱动类名不一样,5.x 用com.mysql.jdbc.Driver,8.x 必须用com.mysql.cj.jdbc.Driver,同时连接串要加serverTimezone=Asia/Shanghai,否则报时区错误。第二个坑是 JSP 项目里 IDEA 的编译版本不一致,报“源发行版 17 需要目标发行版 17”,这个是 Maven 的compiler插件默认用了 JDK 17,而本机装的是 JDK 8,把pom.xml里<source>和<target>都改成 1.8 就好。第三个坑是 Layui 的form模块没有正常加载,如果只引入layui.js不执行layui.use(['form'], callback),所有表单元素都不渲染,提交时表单内容为空。
5.2 用一条 SQL 快读统计答卷分布
问卷系统最常被问到的功能是“统计每个选项被选了多少次”。写法如下:
SELECT q.id AS question_id, q.title AS question_title, o.id AS option_id, o.option_text, COUNT(a.id) AS answer_count FROM question q LEFT JOIN question_option o ON q.id = o.question_id LEFT JOIN answer_record a ON a.option_id = o.id WHERE q.survey_id = #{surveyId} GROUP BY q.id, o.id ORDER BY q.sort_order, o.sort_order;说明一下这个 SQL 的细节:用LEFT JOIN而不是INNER JOIN,是因为部分多选题可能一个选项都没人选,INNER JOIN会把 0 选的选项过滤掉,统计结果里就看不到这个选项的完整分布。GROUP BY必须包含q.id和o.id,因为option_text依赖o.id. 使用这个结果时,在 Java 里按questionId分组,再拼出“A:12 票、B:8 票”这样的文本即可。如果问卷数据量大,建议定期把统计结果缓存到SurveyStatistic表,不必每次请求都扫全表。
问卷系统这类 CRUD 项目的价值从来不在高深技术,而在数据模型是否经得起推敲,前后端交互是否把所有边界条件都覆盖到。把本文里的表、接口和前端逻辑组合起来,就是一个能部署到 Tomcat、直接用浏览器跑通的完整项目。
本文还有配套的精品资源,点击获取