SSM问卷调查系统实战:Java课设/毕设源码与数据库设计全解析
简介:这套问卷调查系统以SSM框架为后端支撑,前端采用layui组件和JSP动态页面,数据库选用MySQL,面向具备Java基础的学生或希望快速搭建在线问卷系统的开发者。项目将管理员与普通用户分离,管理员端涵盖管理员信息维护、问卷制作与发布、调查结果统计和报表展示,用户端支持问卷填写与提交,权限边界清晰,功能覆盖完整,可直接作为课程设计或毕业设计蓝本。压缩包共包含847个文件,总大小约42.05MB,其中既有31个Java源文件、36个JSP页面用于业务逻辑与页面展示,也有138个JavaScript脚本和44个CSS样式文件负责前端交互与美化,38个XML配置和35个Jar包支撑框架运行,另有300张GIF演示图直观展示各模块操作过程,目录层级清楚,并附带SQL脚本与txt说明,便于快速还原环境。资源至今已有1226人学习或下载,整体研读后可掌握SSM环境搭建、MyBatis映射文件编写、layui表格和表单组件调用,以及前后端数据交互与问卷统计报表的实现细节,稍作调整即可适配其他调研场景。通过逐步复现源码,还能理解数据库设计、控制层转发与业务逻辑封装的关键写法,适合做二次开发和毕业设计参考。
1. 问卷调查系统,一个能跑通完整前后端链路的 Java 课设
这套基于 SSM 的问卷调查系统,是 Java 后端项目里很典型的“管理端 + 用户端”双角色工程。管理员可以维护账号、制作问卷、发布问卷、查看统计报表,用户拿到链接就能在线填写,整个闭环覆盖了 CRUD、表单提交、拦截器、聚合统计这些面试必问的点。很多 Java 学习者把它当课设或毕设蓝本,原因很朴素:SSM + layui + JSP + MySQL 这套组合老练、源码简单、每一层都能讲清楚。
我拆这个项目时最关心三件事:数据库表如何设计才能同时撑起发布和统计、SSM 的 XML 配置到底在哪里挖坑、以及问卷数据在前端怎么展示成报表。这篇笔记按“是什么 → 怎么实现 → 怎么部署 → 怎么改进阶”的顺序写,适合正在写 Java 课设、或者想从 SSM 过渡到 Spring Boot 之前先补一次手写分层的人。源码包里已经有 SurveyController、SurveyService、QuestionService 这些完整类,数据库导入后基本可以直接跑,我下面讲的每一步都能在工程里找到对应文件。
2. SSM + layui + MySQL 的工程拆解:分层、建表与配置文件
2.1 为什么是 SSM 而不是 Spring Boot:这套分层你能看到所有底层动作
SSM 本质上是 SpringMVC + Spring + MyBatis 的组合,SpringMVC 管请求转发、Spring 管对象装配和事务、MyBatis 管 SQL 映射。相比 Spring Boot 的全自动配置,SSM 的所有动作都是“明牌”的:你写了一个 Controller,要自己把它扫描进容器;你写了一个 Mapper 接口,要自己配置 mapper 扫描;你写了一个事务方法,要自己告诉 Spring 哪条方法需要事务。
这套问卷调查系统的源码里,后端包结构一般是这样:
src/main/java/com/survey ├── controller // SurveyController、QuestionController ├── service // SurveyService、QuestionService ├── dao // SurveyMapper、QuestionMapper、OptionMapper ├── entity // Survey、Question、Option、AnswerRecord └── common // 分页、结果封装、拦截器等pom.xml 里的核心依赖是这套东西能跑起来的前提。常见做法是统一 Spring 版本为 5.x,MyBatis 和 mybatis-spring 也要匹配,否则启动时会报抽象方法错误。
<dependencies> <!-- spring --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.1.8.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.1.8.RELEASE</version> </dependency> <!-- mybatis --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.2</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.0.2</version> </dependency> <!-- mysql --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.47</version> </dependency> <!-- jsp/jstl --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>为什么要强调 pom 版本匹配?因为这个项目是 Maven 项目,IDEA 导入后会直接下载依赖。如果 Spring 版本是 4.x,而 mybatis-spring 是 2.x,启动就会遇到NoSuchMethodError,而且错误信息很隐蔽,不看完整堆栈根本找不到原因。这也是我在拆这种老项目时最喜欢保留 pom.xml 原样的原因,改版本通常是给自己挖坑。
2.2 数据库表怎么设计:五张表支撑发布、填写、统计三个动作
问卷调查系统的核心数据流是:管理员建问卷 → 问卷下有题目 → 题目下有选项 → 用户提交后生成一份回答记录 → 统计时按题目和选项聚合。最稳的表结构是 5 张表加一张管理员表,互相之间只用外键逻辑关联,不用物理外键,方便以后改字段。
CREATE DATABASE survey_system DEFAULT CHARACTER SET utf8mb4; USE survey_system; CREATE TABLE t_admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, real_name VARCHAR(50), create_time DATETIME ) ENGINE=InnoDB; CREATE TABLE t_survey ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, description TEXT, status TINYINT DEFAULT 1 COMMENT '1草稿 2发布 3关闭', create_time DATETIME, publish_time DATETIME ) ENGINE=InnoDB; CREATE TABLE t_question ( id INT PRIMARY KEY AUTO_INCREMENT, survey_id INT NOT NULL, question_type TINYINT DEFAULT 1 COMMENT '1单选 2多选 3文本', title VARCHAR(500) NOT NULL, sort_order INT DEFAULT 0 ) ENGINE=InnoDB; CREATE TABLE t_option ( id INT PRIMARY KEY AUTO_INCREMENT, question_id INT NOT NULL, option_text VARCHAR(200) NOT NULL, sort_order INT DEFAULT 0 ) ENGINE=InnoDB; CREATE TABLE t_answer_record ( id INT PRIMARY KEY AUTO_INCREMENT, survey_id INT NOT NULL, user_id INT, answer_content TEXT, create_time DATETIME ) ENGINE=InnoDB; CREATE TABLE t_answer_detail ( id INT PRIMARY KEY AUTO_INCREMENT, record_id INT NOT NULL, question_id INT NOT NULL, option_id INT, answer_text TEXT ) ENGINE=InnoDB;这里要特别解释一下t_answer_record和t_answer_detail为什么要拆开。answer_record存的是“谁在什么时间填了哪份问卷”,一条记录代表一次提交;answer_detail存的是“该次提交中每个题目的具体答案”。统计时以 detail 为主表,关联 option 和 question,就能算出每道题每个选项的票数。如果只放一张表,用户提交多选题就会导致一行存多个 option_id,统计 SQL 会写得非常痛苦。
answer_content字段是冗余设计,我一般建议保留。它的作用是当用户填文本题时,可以直接把答案拼成一个长字符串存进去,方便后续做关键词模糊查询;做常规统计报表时不依赖它,只当作兜底数据。
2.3 关键配置:Spring 容器、MyBatis 映射和 web.xml 怎么协同
SSM 项目最常见的启动顺序是:Tomcat 先读 web.xml,加载 ContextLoaderListener,让它去读 applicationContext.xml 初始化 Spring 容器;再加载 DispatcherServlet,让它去读 spring-mvc.xml,把 Controller 层拉起来。两份配置文件职责不同,别图省事合成一份,不然后续排查“为什么 service 是 null”的时候会非常头疼。
先看jdbc.properties,这是所有数据库相关问题的源头:
jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/survey_system?useUnicode=true&characterEncoding=utf8&useSSL=false jdbc.username=root jdbc.password=123456然后是spring-mybatis.xml,用 SqlSessionFactoryBean 扫描 mapper 文件:
<bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.survey.dao"/> </bean>这里有个细节:MapperScannerConfigurer的basePackage必须和@MapperScan("com.survey.dao")指向同一个包,否则 MyBatis 会生成代理类但 Spring 容器里仍然找不到。我遇到过不少项目,Service 里@Autowired一个 Mapper,启动时不报错,调用时才发现是 null,原因就是 Mapper 接口没有被扫描进去。
web.xml 里的 DispatcherServlet 配置决定了请求怎么进 Controller:
<servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping> <filter> <filter-name>encoding</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>utf-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encoding</filter-name> <url-pattern>/*</url-pattern> </filter-mapping><url-pattern>/</url-pattern>会把所有请求交给 SpringMVC,这也意味着 JSP 页面里的静态资源 layui 的 js、css、图片,都需要在 spring-mvc.xml 单独放行。后面的避坑章节我会专门说这一点。
3. 核心链路代码:管理员建卷、用户答卷、统计查询
3.1 SurveyController 与 QuestionService:一次“创建问卷 + 批量保存题目”的典型写法
问卷调查系统的管理端,最核心的操作是“一次提交同时生成问卷和它的全部题目”。前端表单通常用 layui 的动态行来实现,一个题目对应一个题目标题下拉框和一组选项输入框,最终把所有字段拼成同名数组提交。后端接收的核心是SurveyController的save方法。
@Controller @RequestMapping("/admin/survey") public class SurveyController { @Autowired private SurveyService surveyService; @RequestMapping("/save") public String saveSurvey(HttpServletRequest request, Model model) { Survey survey = new Survey(); survey.setTitle(request.getParameter("title")); survey.setDescription(request.getParameter("description")); survey.setStatus(1); // 草稿 String[] questionTitles = request.getParameterValues("questionTitle"); String[] questionTypes = request.getParameterValues("questionType"); String[] optionGroups = request.getParameterValues("optionText"); surveyService.saveSurveyAndQuestions(survey, questionTitles, questionTypes, optionGroups); return "redirect:/admin/survey/list"; } }这个写法的好处是 Controller 只做参数收集,真正的业务逻辑全部下沉到 Service,符合 SSM 项目的分层约定。SurveyService内部要做两件事:插入问卷拿到自增 id,再循环插入题目和选项,这里用到了 MySQL 的useGeneratedKeys。
public void saveSurveyAndQuestions(Survey survey, String[] titles, String[] types, String[] optionGroups) { surveyMapper.insert(survey); if (titles == null) { return; } for (int i = 0; i < titles.length; i++) { Question q = new Question(); q.setSurveyId(survey.getId()); q.setTitle(titles[i]); q.setQuestionType(Integer.parseInt(types[i])); q.setSortOrder(i); questionMapper.insert(q); if (optionGroups != null && i < optionGroups.length) { String group = optionGroups[i]; if (group != null && !"".equals(group.trim())) { String[] opts = group.split("\\|"); for (String opt : opts) { Option option = new Option(); option.setQuestionId(q.getId()); option.setOptionText(opt.trim()); optionMapper.insert(option); } } } } }这里有两个参数说明。第一,optionGroups前端传过来的是“每道题的选项拼接串”,比如 "非常满意|满意|一般|不满意",Service 层用split("\\|")拆开,再逐条插入选项表;第二,sort_order我直接用数组下标i,这样前端题目显示顺序就是后端存储顺序,不需要额外做排序字段维护。
注意题型的第 3 种“文本题”不需要选项,所以前端在optionText里传空字符串即可,代码里用"".equals(group.trim())跳过了插入选项。如果把空字符串也拆成数组,会得到[""],插入一条空选项,统计报表里就会出现一个空白行,这种问题很难一眼发现。
3.2 用户端 JSP + layui 表单:单选、多选、文本题的提交协议
用户端的问卷页面是基于 JSP 动态渲染的。后端把题目和选项数据放进 request 后转发给fill.jsp,JSP 里用 JSTL 的<c:forEach>循环生成表单项。
<form id="surveyForm" action="${pageContext.request.contextPath}/survey/submit" method="post"> <input type="hidden" name="surveyId" value="${survey.id}"/> <c:forEach items="${questionList}" var="q" varStatus="qs"> <div class="question-block"> <p>${qs.count}. ${q.title}</p> <c:if test="${q.questionType == 1}"> <c:forEach items="${q.optionList}" var="opt"> <label> <input type="radio" name="answer_${q.id}" value="${opt.id}"/> ${opt.optionText} </label> </c:forEach> </c:if> <c:if test="${q.questionType == 2}"> <c:forEach items="${q.optionList}" var="opt"> <label> <input type="checkbox" name="answer_${q.id}" value="${opt.id}"/> ${opt.optionText} </label> </c:forEach> </c:if> <c:if test="${q.questionType == 3}"> <textarea name="answer_${q.id}" rows="3"></textarea> </c:if> </div> </c:forEach> <button type="button" onclick="submitSurvey()">提交问卷</button> </form>这里最关键的约定是name="answer_${q.id}",也就是“每个题目的 input 的 name 都包含题目 id”。提交时前端拿到所有带answer_前缀的参数名,通过 jQuery 序列化成一个对象,再 POST 出去。
function submitSurvey() { var answers = []; $('input[name^="answer_"], textarea[name^="answer_"]').each(function () { var name = $(this).attr('name'); var value = $(this).val(); answers.push({ questionId: name.replace('answer_', ''), value: value }); }); $.ajax({ url: '/survey/submit', type: 'POST', data: { surveyId: $('#surveyId').val(), answers: JSON.stringify(answers) }, success: function (res) { layer.msg('提交成功', { icon: 1 }); } }); }用户端 Controller 收到answers后,解析 JSON 数组,再一条条写入answer_detail。多选题天然会出现同一个 questionId 对应多条 value,后端用循环插入即可,这正好对应了前面表结构把 detail 拆出来的理由。有人会把多选题答案用逗号拼成一个字段,虽然也能展示,但统计时要用FIND_IN_SET或者LIKE去处理,效率和可读性都差很多,不建议。
3.3 统计报表的 Service 层实现:把选项编号换算成可展示的占比
统计功能是整个项目里最体现 SQL 功力的地方。需求是“每个题目下,每个选项被选了多少次”,同时要支持单选和多选。单选可以直接按 option_id 分组,多选时一行 detail 里只有一条记录,也能按 option_id 分组,所以两种情况可以共用同一套 SQL。
<select id="selectStatistics" resultType="map" parameterType="long"> SELECT q.title AS questionTitle, o.option_text AS optionText, COUNT(ad.option_id) AS answerCount FROM t_question q LEFT JOIN t_option o ON q.id = o.question_id LEFT JOIN t_answer_detail ad ON o.id = ad.option_id WHERE q.survey_id = #{surveyId} GROUP BY q.id, q.title, o.id, o.option_text ORDER BY q.sort_order, o.sort_order </select>Service 层拿到这个 List 后,不能直接丢给前端。因为前端要按“题目”维度分组,而 SQL 返回的是平铺的每一行“题目+选项+数量”。我一般会在 Service 里做一次组装。
public List<Map<String, Object>> getStatistics(Long surveyId) { List<Map<String, Object>> rows = surveyMapper.selectStatistics(surveyId); Map<String, List<Map<String, Object>>> grouped = new LinkedHashMap<>(); for (Map<String, Object> row : rows) { String qTitle = (String) row.get("questionTitle"); grouped.computeIfAbsent(qTitle, k -> new ArrayList<>()).add(row); } List<Map<String, Object>> result = new ArrayList<>(); for (Map.Entry<String, List<Map<String, Object>>> entry : grouped.entrySet()) { Map<String, Object> item = new HashMap<>(); item.put("questionTitle", entry.getKey()); item.put("options", entry.getValue()); result.add(item); } return result; }前端 JSP 只需要两层循环就能渲染出表格或者柱状图。如果要更直观,可以在这个 List 基础上再算一次百分比,但千万别在 SQL 里做百分比除法,因为当总数为 0 时会返回 null,前端展示会崩。
4. 部署与常见问题排查:JDK 1.8、Tomcat 与 MySQL 5.7 的实战笔记
4.1 从 Navicat 建库到 IDEA 启动:一次完整的部署流程
这套项目的运行环境很明确:JDK 1.8、Tomcat 7/8/9、MySQL 5.7、IDEA 或 Eclipse。我在本机复现时用的是 JDK 1.8 + Tomcat 9 + MySQL 5.7,一次跑通。下面按步骤来,每步都不要跳。
第一步,安装并启动 MySQL 5.7,在 Navicat 里新建数据库survey_system,字符集选utf8mb4,然后右键“运行 SQL 文件”,把源码包里的survey.sql导入进去。导入后刷新一下表,看到 t_admin、t_survey 等表存在就说明成功了。MySQL 8 理论上也能用,但驱动版本要改,后面排查章节会说。
第二步,打开 IDEA,选择“Open”,选中项目根目录,等待 Maven 自动导入依赖。确认 pom.xml 的依赖里没有红字报错。然后在 Project Structure 里把 Project SDK 设置为 JDK 1.8,Language Level 设为 8。
第三步,修改jdbc.properties,把jdbc.password改成你本地 MySQL 的密码。这是整个部署里最简单也最容易忘的一步,十个翻车里有五个是这里。
第四步,配置 Tomcat。点击 Run 下拉框 -> Edit Configurations -> 左上角 + 号 -> Tomcat Server Local,在 Deployment 页签添加survey:war exploded,Application context 写/survey。注意一定要用 war exploded,不要用 war,否则 IDEA 每次要重新解包,改一个 JSP 都要重启。
第五步,启动 Tomcat。如果能在控制台看到 “Depoying artifacts” 和 “Completed” 字样,再访问http://localhost:8080/survey,看到登录页就算跑通了。
命令行方式部署也给你,适合不想用 IDEA 内置 Tomcat 的人:
# 先把项目打成 war 包 mvn clean package -DskipTests # 复制到 tomcat 的 webapps 目录 cp target/survey.war /usr/local/tomcat/webapps/ # 启动 tomcat /usr/local/tomcat/bin/startup.sh这种方式不需要回到 IDE,Tomcat 会自动解压 war 包并部署。缺点是想看日志只能去logs/catalina.out里翻,不如 IDEA 的控制台直观。
4.2 常见问题排查:五个最容易击穿新手的点
问题一:数据库连接报Access denied for user 'root'@'localhost'
现象:Tomcat 启动后,日志里出现Access denied for user 'root'@'localhost' (using password: YES),页面 500。
原因:jdbc.properties里写的密码和你本地 MySQL 的 root 密码不一致;或者你的 MySQL 8 使用了caching_sha2_password认证插件,而项目里的mysql-connector-java是 5.1.x 版本,驱动不识别新认证方式。
解决:先在 Navicat 里用 root 连接一次,确认密码。如果是 MySQL 8,把 pom.xml 里的 mysql 驱动换成mysql-connector-java 8.0.11,并在 jdbc.url 末尾追加serverTimezone=Asia/Shanghai&useSSL=false。如果是 MySQL 5.7,直接用 5.1.47 驱动最稳。
问题二:启动后报ClassNotFoundException: org.springframework.web.servlet.DispatcherServlet
现象:Tomcat 控制台直接抛ClassNotFoundException,应用没有起来。
原因:IDEA 里 Tomcat 的 Artifact 配置没有把 Maven 库里的 jar 包打包进去。很多新手把项目导入 IDEA 后直接点启动,没有先配置 Artifact 的lib目录。
解决:在 Project Structure -> Artifacts -> 选中项目 -> 在 Available Elements 里把Maven: org.springframework:spring-webmvc:5.1.8.RELEASE等 jar 全部双击添加到 WEB-INF/lib 下。更快的做法是把 Artifact 类型改成Web Application: Exploded,IDEA 会自动带上依赖。
问题三:页面能打开,但 layui 的 CSS、JS 全部 404
现象:浏览器 F12 看到http://localhost:8080/survey/static/layui/css/layui.css404,页面完全裸奔。
原因:web.xml 里 DispatcherServlet 的映射是/,它会接管所有请求,包括普通静态文件。SpringMVC 默认没有处理静态资源的 Handler,于是.css、.js请求全部走了 Controller 的映射,自然是 404。
解决:在 spring-mvc.xml 里加一行静态资源放行配置。
<mvc:annotation-driven/> <mvc:resources mapping="/static/**" location="/static/"/>如果你的项目里静态资源目录叫assets,就把路径替换成assets/**。加了配置之后不需要重启 Tomcat,只要重新部署 war exploded 即可。
问题四:表单提交中文全部变成?问号
现象:管理员新建问卷时,标题里的“满意度调查”保存后变成????。
原因:数据库建库时是 utf8mb4,但 JDBC 连接串没有指定字符集,导致连接层用的默认字符集不是 utf8;同时 Tomcat 接收 POST 请求时默认 ISO-8859-1,中文被转成拉丁字符。
解决:检查两层,一是 jdbc.url 里要有characterEncoding=utf8,二是 web.xml 里配置了上面写的CharacterEncodingFilter。这两个都检查完,重新导入 SQL 文件再试。
问题五:统计报表里出现空选项,或者多选题计数不准
现象:报表页显示一道题有 N 个空选项目,或者多选题票数总和比提交人数多。
原因:空选项是因为题目创建时optionGroups[i]传了空字符串,后端没有过滤干净;多选题计数不准是因为每次提交后,答案明细表里单选题和文本题的数据也不同,统计时多表关联导致笛卡尔积。
解决:创建题目时,后端循环体里必须先if (group == null || "".equals(group.trim()))跳过;统计 SQL 里始终以t_answer_detail为主表、t_option为左表,避免用INNER JOIN。多选题用户勾了几个选项就有几条 detail 记录,这是正确设计,不要把多选答案合并成一条。
4.3 怎么确认系统真的跑通了:访问一下这两个地址就行
项目跑通后不要急着改代码,先做一次端到端的冒烟验证。打开浏览器,分别访问下面两组地址:
管理端:http://localhost:8080/survey/admin/login 用户端:http://localhost:8080/survey/survey/fill?surveyId=1管理端登录后,建一份包含单选、多选、文本题的问卷,然后发布。再打开用户端地址,以普通用户身份填一次问卷并提交。最后回到管理端的统计菜单,如果能看到每道题每个选项的投票数量和占比,这个系统的链路才算是真正闭环了。
这一步很多人会跳过,但恰恰是它最能帮你发现“Controller 转发路径写错”“Service 事务没提交”这类隐蔽问题。我一般会在确认完这个闭环之后,再考虑给项目加新功能。
5. 进阶改法:给自己问卷系统加多题型和 Excel 导出
5.1 多题型支持:表结构和页面渲染的小改
如果你拿到源码后想让它能在简历或面试里讲出更多东西,第一件事就是支持多种题目类型。现有表结构已经有question_type字段,前端 JSP 里我建议把单选项和工整的多选项解析在一块,用一个 ECharts 柱状图来展示统计结果。ECharts 的 CDN 地址放在 JSP 页面的底部,然后把后端统计结果转成 JSON 输出。
5.2 用 POI 把统计结果导成 Excel:一个可以抄的 Service 方法
统计报表做出来后,另一个常用诉求是导出 Excel。Apache POI 是可以直接依赖的库,不需要额外的中间件。核心方法很直观:创建一个HSSFWorkbook,在 Service 里把统计结果循环写入 cell。
public void exportStatistics(Long surveyId, HttpServletResponse response) { List<Map<String, Object>> stats = getStatistics(surveyId); HSSFWorkbook workbook = new HSSFWorkbook(); HSSFSheet sheet = workbook.createSheet("问卷统计"); HSSFRow header = sheet.createRow(0); header.createCell(0).setCellValue("题目"); header.createCell(1).setCellValue("选项"); header.createCell(2).setCellValue("票数"); int rowIndex = 1; for (Map<String, Object> data : stats) { HSSFRow row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue((String) data.get("questionTitle")); row.createCell(1).setCellValue((String) data.get("optionText")); row.createCell(2).setCellValue(((Number) data.get("answerCount")).intValue()); } response.setContentType("application/vnd.ms-excel"); response.setHeader("Content-Disposition", "attachment; filename=statistics.xls"); try { workbook.write(response.getOutputStream()); } finally { workbook.close(); } }这里有个容易翻车的点:HSSFWorkbook的write(response.getOutputStream())之后,不能手动调用response.getWriter(),否则会抛出IllegalStateException。导出按钮在 JSP 里直接是一个普通链接,不需要走 AJAX,不然浏览器不会触发下载。
最后说一点我在拆这个项目时最深的第一人称教训:一开始我以为把 SQL 文件导入数据库、再配好 Tomcat 就能跑,结果花了一晚上排静态资源 404 和 java.sql.SQLException。从那以后我每次接手 SSM 老项目,都强制先走一遍“建库 → 改 jdbc → 部署 → 填写一条问卷 → 看统计”这个闭环,再决定要不要动代码。这个习惯帮我避开了至少三次因为环境问题导致的假 bug,希望帮到你。
本文还有配套的精品资源,点击获取