
简介面向正在学习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. 一套组合拳SSMLayuiJSP 做问卷系统的取舍先抛个反直觉的结论在做问卷调查类系统时用 JSP 做页面渲染比现在热门的 Vue 前后端分离更省事。原因不是技术怀旧而是这类系统天然是“重后端、轻前端”的形态。题库管理、问卷发布、答卷收集和统计导出所有核心逻辑都集中在服务端页面交互几乎就是表单填写和数据表格展示。SSMSpring 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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接收 JSONSubmitRequest里的answers是ListAnswerItem每个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, ListAnswerItem answers) { Survey survey surveyMapper.selectById(surveyId); if (survey null || survey.getStatus() ! 1) { return Result.error(问卷不存在或未发布); } ListQuestion 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 idbatchInsert parameterTypelist INSERT INTO answer_record (survey_id, question_id, option_id, answer_text, user_key) VALUES foreach collectionlist itemitem separator, (#{item.surveyId}, #{item.questionId}, #{item.optionId}, #{item.answerText}, #{item.userKey}) /foreach /insert批量插入有两点要注意一是foreach的collection必须和 Mapper 接口参数名对应如果接口是Param(list) ListAnswerRecord 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 classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views/ / property namesuffix value.jsp / /bean这意味着 Controller 返回survey/list时容器实际加载/WEB-INF/views/survey/list.jsp。把页面放在WEB-INF下有个好处用户无法通过 URL 直接访问 JSP必须经过 Controller 转发这对问卷管理端来说多了一层访问控制。Layui 的引入方式是在 JSP 顶部写上% page contentTypetext/html;charsetUTF-8 languagejava %然后在head里用link和script引入 layui.css、layui.js以及 jQuery。这里有一个常见问题JSP 里的静态资源路径不能写死成/static/因为项目部署到 Tomcat 后根路径可能带项目名所以必须用 EL 表达式取上下文路径写法是${pageContext.request.contextPath}。4.1.1 问卷列表页渲染代码table idsurveyTable lay-filtersurveyTable/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 typetext/html idbar模板块里面写编辑和删除按钮。转发到 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[typecheckbox]:checked, input[typeradio]: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属性。如果渲染时没有加idreload会报“Table not found”. 所以渲染和刷新要配对使用。5. 高频报错排查与统计答卷的实用技巧5.1 我踩过的三个坑第一个坑是 MySQL 版本导致驱动类名报错。MySQL 5.x 和 8.x 的 JDBC 驱动类名不一样5.x 用com.mysql.jdbc.Driver8.x 必须用com.mysql.cj.jdbc.Driver同时连接串要加serverTimezoneAsia/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分组再拼出“A12 票、B8 票”这样的文本即可。如果问卷数据量大建议定期把统计结果缓存到SurveyStatistic表不必每次请求都扫全表。问卷系统这类 CRUD 项目的价值从来不在高深技术而在数据模型是否经得起推敲前后端交互是否把所有边界条件都覆盖到。把本文里的表、接口和前端逻辑组合起来就是一个能部署到 Tomcat、直接用浏览器跑通的完整项目。本文还有配套的精品资源点击获取