ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SSM+JSP+layui问卷系统开发实战:从数据表设计到部署避坑

SSM+JSP+layui问卷系统开发实战:从数据表设计到部署避坑 简介一份采用SSM框架SpringSpringMVCMyBatis并整合layui、JSP与MySQL的问卷调查系统项目适合正在学习Java Web开发或准备毕业设计的人群。系统清晰划分为管理端与用户端管理端支持管理员信息增删改查、问卷制作、发布、结果统计与报表展示用户端可在线填写问卷覆盖问卷从创建到数据回收的完整流程。压缩包共847个文件约42MB主要包含JSP页面、JavaScript脚本、CSS样式、Java源码、MyBatis映射文件以及数据库SQL脚本等其中大量gif动图便于直观查看各功能页面效果jar包保证依赖完整。项目为Maven结构附pom.xml导入IDE即可运行。目前已有1225人学习浏览可作为SSM项目实战参考或课程设计基础。资源附带SQL建库脚本与完整源码目录便于对照学习控制层、业务层与持久层的分层写法也能快速复现问卷发布和统计报表模块。1. SSMJSP做问卷系统为什么还在被选择问卷调查系统的业务模型本质上就是“一份问卷包含若干道题每道题可挂选项答题人提交一张答卷”。换到互联网公司这个场景通常被搬成前后端分离的ReactNode应用数据库用MongoDB存嵌套JSON。但如果要做一个可独立部署、可二次修改、能跑统计的小型问卷调查系统SSMJSPlayui反而更省力Spring管理Bean和事务SpringMVC把请求映射到ControllerMyBatis用XML写动态SQLJSP在服务端把页面拼好再返回layui只补表格、弹窗、表单校验这些交互细节。整个请求链路从数据库到页面都能线性追踪排查问题不用在网关和微服务之间来回跳。这类方案在课程设计、企业内部轻量问卷、以及需要接手维护旧JavaWeb项目时出现频率依旧很高。下面从数据库设计讲到JSP页面最后给出部署时容易踩的配置坑所有内容都按“动得了手”的标准展开。2. 数据表设计用5张表分离问卷定义与答卷数据2.1 表结构如何切分问卷系统最容易犯的设计错误是把题目和选项直接塞进JSON字段。这种做法排错方便一时统计时非常被动。传统关系型切分虽然多几张表但每种数据的生命周期清晰可见问卷、题目、选项属于“结构数据”答案明细属于“行为数据”后者只追加、不修改。表职责写入时机数据特点tb_survey问卷主表存标题、状态、有效期创建或编辑问卷低频更新tb_question题目表存题型、题干、排序编辑问卷随问卷整体替换tb_option选项表存每个题目的选项编辑问卷随问卷整体替换tb_answer答卷头表记录一次提交用户提交问卷只增不改tb_answer_detail答卷明细表记录每道题的答案用户提交问卷只增不改tb_question和tb_option共用survey_id关联不直接外键约束到tb_question是为了保留“题目被删除后历史答卷仍可读”的可能性。业务上一旦做了物理删除历史明细就断链了外键约束只会让删除变得更难。2.2 建表DDL与字段说明这里给出可直接执行的MySQL 5.7建表脚本CREATE TABLE tb_survey ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL COMMENT 问卷标题, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-草稿 1-发布 2-关闭, start_time DATETIME DEFAULT NULL COMMENT 生效时间, end_time DATETIME DEFAULT NULL COMMENT 过期时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_end_time (end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT问卷主表; CREATE TABLE tb_question ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, survey_id INT UNSIGNED NOT NULL, question_type TINYINT NOT NULL DEFAULT 0 COMMENT 0-单选 1-多选 2-填空, question_title VARCHAR(500) NOT NULL COMMENT 题干, sort_no INT NOT NULL DEFAULT 0 COMMENT 排序号, is_required TINYINT NOT NULL DEFAULT 0 COMMENT 0-非必填 1-必填, KEY idx_survey (survey_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT题目表; CREATE TABLE tb_option ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, question_id INT UNSIGNED NOT NULL, option_text VARCHAR(200) NOT NULL, sort_no INT NOT NULL DEFAULT 0, KEY idx_question (question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选项表; CREATE TABLE tb_answer ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, survey_id INT UNSIGNED NOT NULL, user_ip VARCHAR(45) DEFAULT NULL COMMENT 答题人IP匿名标识, user_id INT UNSIGNED DEFAULT NULL COMMENT 登录用户可关联账号, submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_survey_time (survey_id, submit_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷头表; CREATE TABLE tb_answer_detail ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, answer_id INT UNSIGNED NOT NULL, question_id INT UNSIGNED NOT NULL, option_id INT UNSIGNED DEFAULT NULL COMMENT 选择题选项ID填空为空, answer_text TEXT DEFAULT NULL COMMENT 填空内容或选择题自定义文本, KEY idx_answer (answer_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT答卷明细表;题型用TINYINT而不是字符串枚举是为了让Mapper XML里的判断更清爽也方便后续扩展题型时只加数字定义不迁移数据。状态、是否必填沿用相同策略。ip存放用VARCHAR(45)而不是CHAR(15)因为IPv6会到39位。update_time用ON UPDATE自动刷新修改记录时间戳不需要在Service层手动set。2.3 为了统计先写好关联查询SQL问卷系统的价值最终体现在统计页能看到每道题每个选项被选了多少次。这段SQL在建表后先跑通后续Controller和页面只需要套它的结果SELECT q.id AS question_id, q.question_title, o.id AS option_id, o.option_text, COUNT(d.id) AS cnt FROM tb_question q LEFT JOIN tb_option o ON o.question_id q.id LEFT JOIN tb_answer_detail d ON d.option_id o.id WHERE q.survey_id #{surveyId} GROUP BY q.id, o.id ORDER BY q.sort_no, o.sort_no;这里必须用COUNT(d.id)而不是COUNT(*)因为LEFT JOIN会在没有答案的选项上产生一行全NULL记录。GROUP BY按题目和选项两个维度聚合排序沿用题目与选项的sort_no。对填空题目可以用另一个分支去查d.answer_text但在MyBatis里更合理的做法是保持统计查询不变让Java层根据question_type判断输出形式。SQL先于代码写的好处是表结构有没有缺陷一眼就能看出来。问题是如果只想统计每道题的有效答卷数字应该对answer_id做COUNT(DISTINCT answer_id)否则同一份答卷多选时会被重复计数分析时要注意区分。3. SSM后端从MyBatis映射到Controller参数绑定的完整链路3.1 Maven依赖与项目结构传统SSM项目不像Spring Boot那样自带依赖管理pom.xml的版本需要自行对齐。比较省心的一套组合是Spring 5.3.x、MyBatis 3.5.x、MyBatis-Spring 2.0.x、MySQL Connector/J 8.0.x、Jackson 2.13.xdependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.30/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.13/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency ?java -- 等等我的xml语言这里使用了? 但描述意图会出现? 这个自然一下普通界面对分层没有强制约束但实际维护时最怕“Mapper里写业务、Controller里拼SQL、页面字符串截断后端返回”。项目结构建议按controller、service、mapper、entity、dto划分问卷调查本身不复杂按功能包而不是按表分包问卷相关代码全部落在survey目录内找代码时不用跨包来回翻。相关目录约定是Controller只做参数接收和响应封装Service做事务与状态判断Mapper XML是唯一放SQL的地方。3.2 动态SQL列表分页与条件过滤问卷列表页要把分页、关键字搜索、状态下拉组合起来。MyBatis的where标签会自动去掉第一个多余的AND单条件查询不会出语法错误select idselectSurveyPage resultTypeSurvey SELECT id, title, status, start_time, end_time, create_time FROM tb_survey where if testtitle ! null and title ! AND title LIKE CONCAT(%, #{title}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY id DESC LIMIT #{offset}, #{pageSize} /selectLIMIT #{offset}, #{pageSize}是传统分页写法offset由前台页码计算(page - 1) * pageSize。若项目使用PageHelper则Mapper里不需要写LIMIT由拦截器补全。这里手写LIMIT更直观适合问卷这种量级不会超过万条的数据集。注意#{title}传进去的是没有通配符的普通字符串LIKE匹配时用CONCAT拼接而不是直接在参数值里拼%这样传入空字符串时语义才干净。3.3 提交答卷的事务边界一次答卷提交会先插入tb_answer再逐条插入tb_answer_detail两步必须同时成功或同时失败。事务要放在Service层原因是Controller每个方法都是一个独立的事务边界而一个提交动作可能跨多个Mapper方法Service public class SurveyServiceImpl implements SurveyService { Autowired private AnswerMapper answerMapper; Override Transactional(rollbackFor Exception.class) public void submitAnswer(Integer surveyId, ListQuestionAnswerDTO questionAnswers, String userIp) { AnswerDO answer new AnswerDO(); answer.setSurveyId(surveyId); answer.setUserIp(userIp); answerMapper.insertAnswer(answer); for (QuestionAnswerDTO qa : questionAnswers) { AnswerDetailDO detail new AnswerDetailDO(); detail.setAnswerId(answer.getId()); detail.setQuestionId(qa.getQuestionId()); detail.setOptionId(qa.getOptionId()); detail.setAnswerText(qa.getAnswerText()); answerMapper.insertAnswerDetail(detail); } } }rollbackFor Exception.class是关键因为Spring默认只对RuntimeException回滚。如果在Service里捕获了异常又继续往上抛事务会感知不到Controller层的try-catch把异常做成业务提示时必须保证Service没有被吞掉异常。让MyBatis配置useGeneratedKeystrue和keyPropertyidinsertAnswer执行后answer.getId()才能拿到自动生成的主键。3.4 Controller参数接收与layui表格的JSON格式layui表格2.x版本通过接口返回固定格式字段含义code0表示成功非0为失败msg提示信息count数据总条数data当前页数据数组Controller不要直接操作HttpServletRequest去手动getParameter用DTO对象接收查询条件再用自定义的Result类包装返回值Controller RequestMapping(/admin/survey) public class SurveyController { RequestMapping(/list) ResponseBody public LayuiTableResult list(SurveyQueryDTO query) { int total surveyService.countByCondition(query); ListSurveyDO list surveyService.pageByCondition(query); return LayuiTableResult.ok(total, list); } RequestMapping(/submit) ResponseBody public Result submit(RequestBody AnswerSubmitDTO body, HttpServletRequest request) { surveyService.submitAnswer(body.getSurveyId(), body.getQuestionAnswers(), request.getRemoteAddr()); return Result.ok(); } }这里的SurveyQueryDTO里放title、status、page、pageSize四个字段SpringMVC会自动绑定表单参数。return Result.ok是自定义响应体负责统一把业务状态码与HTTP状态码解耦。前端不管后端异常还是业务失败统一解析code字段避免HTTP 500时前端拿不到响应body。4. layuiJSP服务端渲染与前端表格的协作边界4.1 哪些页面用JSP哪些用layui渲染不少SSM项目把JSP当成只会被淘汰的技术但配对layui使用后分工反而清晰。管理者后台的问卷列表数据会随搜索条件变化尤其适合交给layui table在浏览器端渲染用户端答题页的题目内容在服务端已经拼好更有利于控制题库的暴露范围直接用JSTL输出即可。实际划分可以参考这个表页面渲染方式理由后台问卷列表layui table JSON接口分页、搜索、排序体验好新建/编辑问卷弹窗layui layer form表单校验和动态增删题目灵活用户端答题页JSP JSTL首次请求直接拿到完整HTML不用二次请求个人信息展示页JSP JSTL数据量固定服务端渲染更简单统计结果页layui table 读统计接口需要排序和导出前的数据预览核心原则是数据展示越静态越偏向JSP交互越复杂越偏向layui JS渲染。前后端不分离时JSP页面通过${ctx}能拿到ContextPath生成正确静态资源路径避免layui的css和js从错误的相对路径加载。4.2 问卷列表页对接layui tableJSP里引入layui的方式是静态资源直引不用打包构建工具。页面容器只有一个table的承载层再由table.render初始化事件绑定table idsurveyTable lay-filtersurveyTable/table script src${ctx}/static/layui/layui.js/script script layui.use([table, layer, form], function () { var table layui.table; table.render({ elem: #surveyTable, url: ${ctx}/admin/survey/list, page: true, cols: [[ {field: id, title: ID, width: 70}, {field: title, title: 问卷调查标题, minWidth: 200}, {field: status, title: 状态, width: 90, templet: function (d) { return [草稿, 发布, 关闭][d.status] || 未知; }}, {fixed: right, title: 操作, width: 220, toolbar: #surveyTableBar} ]] }); }); /script script typetext/html idsurveyTableBar a lay-eventedit编辑/a a lay-eventstat统计/a a lay-eventclose关闭/a /scriptlayui table通过url直接请求list接口code为0时自动渲染data字段数据。col中fixed:right把操作列钉在最右侧toolbar引用一个script模板后续table.on(tool, ...)里按lay-event分派点击事件。表单校验里的状态显示不用if判断而直接用数组下标映射status字段在合法范围内时代码比switch更短。4.3 新建问卷弹窗与表单校验新建问卷时弹出一个layer.open表单内嵌layui的form模块并注册一个lay-verify规则layer.open({ type: 1, title: 新建问卷调查, area: [600px, 420px], content: $(#surveyFormTpl).html(), btn: [保存, 取消], yes: function (index, layero) { var form layui.form; form.render(null, surveyFormFilter); layero.find(form).on(submit, function (e) { var data $(this).serializeArray(); $.post(${ctx}/admin/survey/save, data, function (res) { if (res.code 0) { layer.close(index); layer.msg(保存成功); table.reload(surveyTable); } }); return false; }); // 手动触发一次表单提交验证 } });表单里的form lay-filtersurveyFormFilter中lay-filter用于区分页面上多个formrequired标签会在表单重置后触发验证。弹窗打开后必须调用form.render才能把layui的样式和事件重新绑定否则动态插入的HTML没有select组件样式提交时校验也不生效。表单提交用jQuery的serializeArray而不是FormData因为后端Controller接收的是普通表单字段不处理文件上传时保持简单更可靠。4.4 查看答卷时还原选中状态答题页选项由JSP循环输出答题人提交后回显历史答卷时要根据answer_detail里的记录把radio和checkbox重新选中。常见做法是JSP用两个集合配对判断%-- selectedSnList 存当前答案记录, 0代表未选 --% c:forEach items${question.optionList} varop varStatusst c:set varchecked value${fn:contains(selectedSnList, op.id) ? checked : }/ input typecheckbox namequestion_${question.id} value${op.id} ${checked}/ ${op.optionText} /c:forEach这段JSP的关键在于selectedSnList由Service在查询答案时填好不用在JSP里做复杂逻辑。fn:contains处理的是“选项ID的集合”包含“当前选项ID”它比较的是字符串适用于Integer拼成字符串的场景。注意多选时name相同、value不同后端接收时会把同名参数拼成数组用request.getParameterValues()或DTO里的List字段接收不能只用getParameter否则只能拿到最后一个勾选项。5. 部署配置与几个容易翻车的细节5.1 数据源参数与连接池SSM项目典型的db.properties如下MySQL 8.0与旧驱动之间在时区与公钥检索上有两处容易出错jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/survey?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8 jdbc.usernameroot jdbc.password123456 jdbc.initialSize5 jdbc.maxActive20 jdbc.maxWait60000com.mysql.jdbc.Driver在MySQL 8.0的驱动包中已经废弃必须写com.mysql.cj.jdbc.Driver。serverTimezone不指定时驱动会读取操作系统时区服务器是UTC时区就会出现时间差8小时。allowPublicKeyRetrievaltrue是解决MySQL 8.0 caching_sha2_password认证方式下“Public Key Retrieval is not allowed”报错的常用开关安全要求高的环境应改用sha256_password或建专用账号而不是全局打开这个参数。5.2 layui静态资源404SpringMVC的DispatcherServlet如果映射了/会把静态请求也拦截下来。spring-mvc.xml里要补上mvc:resources mapping/static/** location/static//位置在WEB-INF/static/与在webapp根目录下都行关键是location路径要与JSP中引用的${ctx}/static/layui/layui.js一致。经常出现的问题是ctx写在${pageContext.request.contextPath}时拼接错误JSP里确认输出的是/项目名而不是/项目名/双斜杠会让资源请求路径解析异常。5.3 IntelliJ IDEA里JSP文件没有高亮如果你在IntelliJ IDEA的Color Scheme里面对JavaWeb模板不熟打开JSP文件发现没有语法高亮多半是项目没有被识别为Web模块。右键项目根目录选择“Add Framework Support”勾选Web ApplicationIDEA才会把.jsp文件关联到JSP语法分析器。在社区版里JSP高亮支持本身是有限制的普遍解决方法是把JSP代码里的大段逻辑搬到Java端JSP页面保持标签和HTML为主这样即使在IDEA社区版里也能维持基本可读性。5.4 统计报表的复查方法统计页做出来后先用Workbench执行第2章的那段统计SQL对照页面数字判断后端接口有没有查错范围。如果数字对不上优先检查answer_id是否被COUNT(DISTINCT answer_id)去重。选择题多选时同一份答卷在明细表里占多行常规COUNT会把它重复计入此时需求是“这份问卷的有效答卷数”应针对answer_id去重需求是“每个选项的被选次数”则保留明细行数。这两个口径在同一页面上会同时出现推荐在统计DTO中同时返回两个count字段文案分别标注为“答卷数”和“选项被选次数”避免运营人员把它们混淆。这个细节处理好之后问卷系统最核心的统计链路就算完整闭环了。本文还有配套的精品资源点击获取
返回列表