简介:这是一份基于JavaWeb的在线问卷调查系统课程设计源码包,涵盖前后端完整工程与数据库脚本,面向需要完成Java课设或学习Spring Boot、Servlet+JSP项目的开发者。系统实现用户注册登录、问卷创建与填写、单选题多选题文本题、发布暂停结束、数据统计及管理员后台管理等核心功能,业务流程完整,适合直接用于项目答辩或二次扩展。压缩包共1366个文件,大小约93.77MB,以java后端源码、html/css/js前端页面、sql建表脚本、xml配置文件为主体,并包含png/svg等界面资源与md说明文档,结构清晰便于定位。数据库按用户表、问卷表、问题表、选项表、答卷表等设计,代码注释与目录组织可帮助理解问卷业务的数据流;导入IDE并配置数据库即可运行,节省从零搭建的时间。已有50人浏览学习,可作为JavaWeb课程设计、毕业设计或就业项目练手的实用参考。
1. 在线问卷调查系统:JavaWeb 课程设计里最值得复现的一类全栈项目
做 JavaWeb 课程设计时,"在线问卷调查系统"是出现频率最高的选题之一,但很多同学拿到的源码要么缺数据库脚本,要么前后端对不上,跑起来全是坑。这份资源是完整的 JavaWeb 前后端项目,附带数据库脚本,覆盖用户注册登录、问卷创建与填写、管理员管理、发布/暂停/结束状态控制,以及基于单选题、多选题、文本题三种题型的数据统计。适合正在做 JavaWeb 课程设计的学生,也适合想快速搭一套问卷原型、需要参考增删改查与业务状态流转写法的初级开发者。核心价值在于:它不是单点功能的 Demo,而是一条从建表到统计分析的完整链路。
2. 架构选型与数据库设计:六张表如何撑起问卷的创建、答题与统计
2.1 技术选型:Spring Boot + JSP + MySQL 为什么适合课程设计
这套系统的后端主体是 Java Web 技术栈,常见做法是 Spring Boot + JSP,配合 MyBatis 操作 MySQL。很多课程设计用 Servlet + JSP 做也不是不行,但 Spring Boot 内置 Tomcat,省去单独配置服务器这一步,导入 IDEA 直接跑起来,对答辩演示更友好。资源里能看到 Controller、Service、Mapper 分层,这是标准的 Spring Boot 三层结构。
选型理由要落在两点上。第一,JSP 做服务端渲染,问卷页面这种表单密集型页面不需要额外写 Vue/React 的前后端对接代码,JSTL 标签遍历问题列表即可,代码量少一半;第二,MyBatis 的 XML 里写动态 SQL,联表查询和统计聚合比 JPA 更直观,也方便后期加条件。构建工具用 Maven,依赖管理统一走 pom.xml,换机器导入时不容易丢包。
一个容易被忽略的点是 Java 版本。建议 JDK 8 或 11,配 Spring Boot 2.x。IDEA 运行 JavaWeb 项目时,记得确认 Maven 仓库路径和 JDK 编译级别,否则会出现 "java: 无效的目标发行版" 这类启动即崩的问题。
2.2 六张核心表的字段设计与建表 SQL
根据需求分析,数据库至少需要六张表:users(用户)、surveys(问卷)、questions(问题)、options(选项)、responses(答卷)、answers(具体答案)。下面是我按这套系统整理出的建表脚本,字段注释直接写在 SQL 里:
CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'MD5加密后的密码', role TINYINT DEFAULT 0 COMMENT '0-普通用户,1-管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE surveys ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL COMMENT '问卷标题', description VARCHAR(500) COMMENT '问卷说明', creator_id INT NOT NULL COMMENT '创建人ID', status TINYINT DEFAULT 0 COMMENT '0-草稿,1-发布中,2-已暂停,3-已结束', start_time DATETIME COMMENT '发布时间', end_time DATETIME COMMENT '结束时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_creator (creator_id) ); CREATE TABLE questions ( id INT AUTO_INCREMENT PRIMARY KEY, survey_id INT NOT NULL COMMENT '所属问卷', question_type TINYINT NOT NULL COMMENT '1-单选,2-多选,3-文本', title VARCHAR(500) NOT NULL COMMENT '题目内容', sort_no INT DEFAULT 0 COMMENT '排序号', required TINYINT DEFAULT 1 COMMENT '是否必答', KEY idx_survey (survey_id) ); CREATE TABLE options ( id INT AUTO_INCREMENT PRIMARY KEY, question_id INT NOT NULL COMMENT '所属题目', option_text VARCHAR(200) NOT NULL COMMENT '选项文字', sort_no INT DEFAULT 0, KEY idx_question (question_id) ); CREATE TABLE responses ( id INT AUTO_INCREMENT PRIMARY KEY, survey_id INT NOT NULL COMMENT '被答问卷', user_id INT COMMENT '答题人,未登录可为NULL', submit_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_survey_user (survey_id, user_id) ); CREATE TABLE answers ( id INT AUTO_INCREMENT PRIMARY KEY, response_id INT NOT NULL COMMENT '所属答卷', question_id INT NOT NULL COMMENT '对应题目', answer_text TEXT COMMENT '文本题答案', option_id INT COMMENT '选择题选中的选项ID', KEY idx_response (response_id) );这段脚本里有几个设计决策值得说明。answers 表同时保留 answer_text 和 option_id 两个字段,是为了让选择题和文本题共用一张答案表,后端查统计时按 question_type 分流处理,不必为每种题型单独建表。surveys 表的 status 字段是状态机入口,发布、暂停、结束都靠这个字段的数值切换,后面管理端的所有逻辑都围绕它展开。
另外,我没有在 SQL 里强制声明外键,而是通过索引和业务层保证关联。课程设计场景下这样写的好处是导入数据、删除数据不会被外键约束卡住,避免演示时删个问卷直接报外键错误。
2.3 表关系与状态字段:发布、暂停、结束是怎么控制的
六张表的关系是:users 和 surveys 是 1 对 N,一个用户可创建多份问卷,管理员其实是 role=1 的 users 记录;surveys 和 questions 是 1 对 N;questions 和 options 是 1 对 N;responses 以 survey_id 关联问卷、user_id 关联用户;answers 通过 response_id 挂到一张答卷下,再通过 question_id 回查题目。
状态流转是这套系统的业务核心。status 字段的四个值对应的行为如下:
| status | 含义 | 前端行为 | 管理端行为 |
|---|---|---|---|
| 0 | 草稿 | 不展示 | 可编辑题目、可发布 |
| 1 | 发布中 | 展示并可答题 | 可暂停、可结束 |
| 2 | 已暂停 | 不展示 | 可恢复发布、可结束 |
| 3 | 已结束 | 永久隐藏 | 仅查看统计 |
草稿状态的问卷用户可以继续编辑题目;发布后前端答题列表只展示 status=1 的数据;暂停时保留已提交的答卷,但答题入口关闭;结束状态前端隐藏问卷,管理端仍能看到统计结果。这个状态字面量在前后端要一致,最怕数据库里写了 0-3 的注释,前端 JS 里判断却写成了字符串 "0",后面排查半天才发现是类型不一致。
3. 问卷创建到答案落库:一条完整链路的实现与参数说明
3.1 创建问卷:动态生成问题与选项
创建问卷是整套系统里最复杂的写操作,因为一次请求要同时插入 surveys、questions、options 三张表。前端表单通常用动态添加行的方式让用户录入题目,提交时把问题列表拼成一个 JSON 数组传给后端。后端需要做的是在事务里先插入问卷主记录,拿到自增主键后再循环插入题目,每个题目再循环插入选项。
核心 Controller 代码如下:
@PostMapping("/survey/save") @ResponseBody public Result save(@RequestBody SurveySaveRequest req, HttpSession session) { User user = (User) session.getAttribute("loginUser"); if (user == null) { return Result.error("请先登录"); } Survey survey = new Survey(); survey.setTitle(req.getTitle()); survey.setDescription(req.getDescription()); survey.setCreatorId(user.getId()); survey.setStatus(0); // 新建问卷默认为草稿 List<Question> questions = req.getQuestions(); if (questions == null || questions.isEmpty()) { return Result.error("问卷至少需要一道题目"); } surveyService.saveWithQuestions(survey, questions); return Result.success(); }saveWithQuestions 的实现要点在 Service 层:先调用 surveyMapper.insert 回填 survey.getId(),再遍历 questions 调用 questionMapper.insert,接着遍历每个 question 的 options 列表调用 optionMapper.insert。因为是多表写入,方法上必须标注 @Transactional,任何一道题插入失败都要回滚整个问卷。我一般还会在题目循环里做一道校验——如果 questionType 是 1 或 2(单选、多选),options 必须至少有两个;如果是 3(文本题),options 应该为空,否则统计时会出现"文本题带选项"的脏数据。
3.2 前端渲染:JSP + JSTL 怎么把问卷变成可答题表单
问卷页面渲染有两个方案:JSP 服务端渲染和纯 JS 动态生成。这套资源用的是 JSP 方案,页面通过 JSTL 的 c:forEach 遍历 questions 列表,每个问题根据 questionType 分支渲染成 radio、checkbox 或 textarea。
这里有一个非常关键的细节——input 的 name 属性。单选组的 name 用 question_{id},多选的 name 用 question_{id}[],文本题用 question_{id},这样后端按固定前缀解析参数时才能把答案对应到具体题目。
<c:forEach items="${questionList}" var="q" varStatus="st"> <div class="question-item"> <p>${st.index + 1}. ${q.title} ${q.required == 1 ? '*' : ''}</p> <c:choose> <c:when test="${q.questionType == 1}"> <c:forEach items="${q.options}" var="opt"> <label><input type="radio" name="question_${q.id}" value="${opt.id}" /> ${opt.optionText}</label> </c:forEach> </c:when> <c:when test="${q.questionType == 2}"> <c:forEach items="${q.options}" var="opt"> <label><input type="checkbox" name="question_${q.id}[]" value="${opt.id}" /> ${opt.optionText}</label> </c:forEach> </c:when> <c:otherwise> <textarea name="question_${q.id}" rows="3" cols="40"></textarea> </c:otherwise> </c:choose> </div> </c:forEach>这段 JSP 的核心是 name 命名规范。单选和文本题用 question_${q.id},多选用 question_${q.id}[],后端用 @RequestParam Map<String, String[]> 接收所有参数,然后遍历 Map 的 key,凡是前缀是 question_ 的都按规则拆分出题目 ID,再根据题目类型读取一个值还是多个值。多选用数组接收还有一个好处:用户一个多选都不勾时,请求里根本没有这个名字的参数,后端要单独处理这种"未作答"情况,把它记为空集合而不是直接抛"参数缺失"。
3.3 答案提交:事务与校验
答题提交接口的输入是一张问卷的完整答案集合,后端要同时写 responses 和 answers 两张表。先插入 responses 拿到自增主键,再循环题目参数逐个插入 answers。由于是多表写入,同样要加 @Transactional,任何一道题的答案写失败,整个答卷要回滚,避免出现"有答卷没答案"的半截数据。
@PostMapping("/survey/submit") @Transactional public Result submit(@RequestBody SubmitRequest req, HttpSession session) { // 校验问卷真实存在且状态为发布中 Survey survey = surveyMapper.findById(req.getSurveyId()); if (survey == null || survey.getStatus() != 1) { return Result.error("问卷不存在或已停止"); } Response resp = new Response(); resp.setSurveyId(req.getSurveyId()); User user = (User) session.getAttribute("loginUser"); resp.setUserId(user == null ? null : user.getId()); responseMapper.insert(resp); // 插入后 MyBatis 回填 resp.getId() Map<Integer, String[]> answers = req.getAnswers(); for (Map.Entry<Integer, String[]> entry : answers.entrySet()) { Answer ans = new Answer(); ans.setResponseId(resp.getId()); ans.setQuestionId(entry.getKey()); if (entry.getValue() != null && entry.getValue().length > 1) { // 多选:每个选中选项生成一条答案记录 for (String optId : entry.getValue()) { ans.setOptionId(Integer.parseInt(optId)); answerMapper.insert(ans); } } else { // 单选或文本:数组长度一定为 1 String raw = entry.getValue()[0]; Question q = questionMapper.findById(entry.getKey()); if (q.getQuestionType() == 3) { ans.setAnswerText(raw); } else { ans.setOptionId(Integer.parseInt(raw)); } answerMapper.insert(ans); } } return Result.success(); }这里要提醒一个细节:同一张答题表单里,文本题提交的值在 Map 里是 String[],但数组长度永远是 1;可以用数组长度是否大于 1 来区分多选和单选/文本。还有一种更稳的做法是提交前先查一次题目类型,按题型的预期数据规模处理,但那样要多一次查询。课程设计规模下,用数组长度判断足够,但代码注释里要写清楚这个约定,否则后来人看不懂为什么多选要循环插入。
4. 管理后台与统计分析:发布/暂停/结束状态和结果聚合
4.1 管理端功能边界
管理端要回答"管理员能做什么"这个问题。梳理下来的功能边界是:查看所有问卷列表、查看每份问卷的答卷明细、切换问卷状态、查看统计结果。注意管理端不需要也不能编辑普通用户创建的问卷内容,改题目会影响已提交答案的对应关系,这是业务上要避免的。
权限控制这里有一个常见做法:登录接口校验通过后,把用户对象放进 session,管理端接口统一判断 session 里的 role 是否为 1。用拦截器做会更干净——写一个 HandlerInterceptor,在 preHandle 里查 session,不是管理员直接重定向到登录页,同时放行 /login、/register 和静态资源路径。
还有一个容易被答辩老师追问的点:普通用户创建的问卷,管理员能不能改?这套资源的定位是管理员负责全局管理,但问卷内容归创建人所有。所以我的建议是权限校验分两层:状态切换和管理操作,允许管理员或创建人;问卷内容编辑,只允许创建人。这样回答"管理员能随便改用户问卷吗"时就有明确答复。
4.2 状态流转与权限控制
状态切换的接口很简单,参数只有 surveyId 和目标状态,但要注意两点约束:只有创建人或管理员可以调用;状态只能按"草稿 → 发布中 → 暂停/结束"的方向走,不能从"已结束"跳回"草稿",否则统计结果和状态会自相矛盾。下面是一个状态切换的 Service 示例:
@Transactional public int changeStatus(int surveyId, int targetStatus, User operator) { Survey survey = surveyMapper.findById(surveyId); if (survey == null) { throw new BizException("问卷不存在"); } if (operator.getId() != survey.getCreatorId() && operator.getRole() != 1) { throw new BizException("无权操作该问卷"); } if (survey.getStatus() == 3) { throw new BizException("已结束的问卷不能改状态"); } Survey update = new Survey(); update.setId(surveyId); update.setStatus(targetStatus); if (targetStatus == 1) update.setStartTime(new Date()); if (targetStatus == 3) update.setEndTime(new Date()); return surveyMapper.updateStatus(update); }这套逻辑里最容易翻车的是校验顺序:先查存在性,再查权限,最后查状态合法性。如果把状态判断放在权限之前,普通用户可以通过改状态接口探出问卷是否处于草稿阶段,属于越权信息泄露。状态切换成功后,列表页和答题页都是实时查数据库,所以不存在缓存一致性问题——前提是别在查询方法上画蛇添足加缓存。
4.3 统计聚合:单选、多选、文本题的三种计算方式
统计分析是这份资源里技术含量最高的一块。三种题型分别处理:单选统计是 group by option_id 计数;多选要把每条答案里的每个选项拆开再聚合,因为一套多选答案会生成多条 answers 记录;文本题不参与图表统计,直接列出原文列表分页展示。
单选和多选的统计可以共用一条 SQL,区别只在于多选答案天然是逐条记录的,不需要额外拆分:
<select id="countByOption" resultType="map"> SELECT a.option_id AS optionId, o.option_text AS optionText, COUNT(*) AS cnt FROM answers a INNER JOIN options o ON a.option_id = o.id WHERE a.question_id = #{questionId} GROUP BY a.option_id, o.option_text ORDER BY cnt DESC </select>这条 SQL 的结果集能直接喂给前端 ECharts 的饼图,不需要额外拼接数据。统计口径按题型区分如下:
| 题型 | 统计方式 | 占比口径 |
|---|---|---|
| 单选 | GROUP BY option_id 计数 | 占比分母 = 答卷数 |
| 多选 | 按 option_id 逐条计数 | 占比分母 = 答卷数,各选项占比之和可能超过 100% |
| 文本 | 查 answer_text 原文列表 | 不分页展示即可,无占比概念 |
多选统计的百分比分母不是答案条数而是答卷数,这是多选和单选在统计口径上最大的区别。如果按答案条数当分母,每个选项的占比都会被低估,算出的总和也毫无意义。另一个细节是文本题的展示要加分页,否则一份回收量大的问卷会让管理页面卡死。
5. 避坑指南:把项目跑起来时最常见的五个问题
5.1 数据库连接报错:Communications link failure
现象:项目启动后访问首页,页面直接报 Communications link failure,IDEA 控制台里 Spring Boot 的启动日志停在数据源初始化,Tomcat 端口倒是起来了,但所有请求都进不去。
原因:八成是 MySQL 8 的驱动与连接串不匹配。MySQL 8 使用的驱动类是 com.mysql.cj.jdbc.Driver,连接串必须带 serverTimezone=Asia/Shanghai,否则时区校验直接拒绝连接。另一类高频原因是本地 MySQL 的 root 密码和配置文件里不一致。
解决:核对 application.properties,按下面这份配置逐项比对:
spring.datasource.url=jdbc:mysql://localhost:3306/survey_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 spring.datasource.username=root spring.datasource.password=你自己的密码 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver如果用 MySQL 5.7,driver-class-name 要改回 com.mysql.jdbc.Driver,并且不需要 serverTimezone 参数。这个驱动类差异是课程设计项目里最典型的启动拦路虎,版本和配置对着抄就能解决。
5.2 中文乱码:插入数据库后全是问号
现象:问卷标题在页面上显示正常,但提交后到 MySQL 里查出来的全是 ??,更诡异的是部分表中文正常,只有后创建的几张表乱码。
原因:连接串没加 characterEncoding=utf8,或者建表时用的字符集不是 utf8mb4。IDEA 里新建数据库时不指定字符集,建表脚本里又写了 DEFAULT CHARSET=utf8,就容易出现这种"半正常半乱码"。
解决:连接串补上 characterEncoding=utf8;已经建好的表执行 ALTER TABLE 转字符集;新脚本直接在建表语句里带上 DEFAULT CHARSET=utf8mb4。注意 CONVERT 会重建表,数据量小没问题,数据多的话先备份。
ALTER TABLE surveys CONVERT TO CHARACTER SET utf8mb4; ALTER TABLE questions CONVERT TO CHARACTER SET utf8mb4;5.3 题目 ID 对不上:提交答案时 name 属性丢失
现象:答题页渲染正常,提交后后端 Map 里只有部分 question_ 参数,另外一部分题目永远统计不到数据。
原因:如果问卷在编辑时删过题目又重新添加,数据库里自增 ID 不连续,前端页面若还缓存着旧 DOM,提交的 name 还是旧的 question_id,后端按新题目集合查不到这道题,数据就被静默丢弃。
解决:编辑问卷保存时,先对比前后题目 ID 集合,把被删除题目的 answers 和 options 一并清掉;答题页面提交前做一次 DOM 重载,别用浏览器缓存。更稳的办法是在提交接口里校验解析出的 question_id 必须存在于当前问卷题目集合中,不存在的直接报错,让问题在测试阶段暴露,而不是让统计悄悄缺数据。
5.4 问卷状态更新不生效:页面还是显示"发布中"
现象:管理员点击暂停,数据库里 surveys.status 确实变成了 2,但前端首页仍然能看到问卷入口,点进去还能答题。
原因:管理端调用 updateStatus 更新的是数据库,而首页列表查询走了另一段代码——很可能加了一层本地缓存,或者是查询方法用了 @Cacheable 之类的注解。总之是"写一个通道、读一个通道",两边没打通。
解决:统一让首页列表和管理端状态切换走同一个 Service 方法,去掉课程设计阶段不必要的缓存注解。这个项目阶段不需要 Redis,数据库实时查询完全扛得住,还能避免演示时状态切换不生效的尴尬。从那以后我凡是遇到"库里改了页面没变",第一反应就是查读路径上有没有缓存。
5.5 统计结果出现 null:LEFT JOIN 的坑
现象:单选统计结果里 optionText 出现 null,ECharts 饼图多出一个"未命名"扇区,占比还不小。
原因:countByOption 最初用了 LEFT JOIN,选项文字理论上不为空;但问卷编辑时删除过选项,answers 表里的 option_id 指向了已经不存在的记录,LEFT JOIN 匹配不上就返回 NULL。
解决:统计 SQL 用 INNER JOIN 替代 LEFT JOIN,自动过滤悬空选项;删除题目时连带清理该题目下的 answers 记录。前端渲染时对 optionText 做空值兜底,显示为"已删除选项",至少让答辩老师看到的是可解释的数据而不是裸 null。
6. 从课设答辩到可演示项目:导出功能与自测清单
一份能上台演示的课程设计,光跑通还不够,我通常会再加一个"导出问卷结果"的功能,成本很低但答辩效果提升明显。导出本质上是把统计 SQL 的结果集转成 CSV 文件输出,通过 HttpServletResponse 设置 Content-Type 为 text/csv,文件名带时间戳,前端一个按钮就能触发下载。这种方式比接 POI 导出 Excel 轻量得多,不需要引入额外依赖,CSV 用 Excel 或 WPS 打开一样能做图表。
response.setContentType("text/csv;charset=UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=survey_" + surveyId + ".csv"); Writer writer = response.getWriter(); writer.write("\ufeff"); // 写入BOM,解决Excel打开中文乱码 writer.write("选项,票数\n"); // 遍历统计结果逐行写入:optionText + "," + cnt代码核心就这几行,唯一要提醒的是 BOM 头 \ufeff——CSV 文件用 Excel 直接打开中文会乱码,加这一行就解决,不加就翻车。我给课设加上导出功能后,答辩老师最常问的"数据怎么给甲方看"就有了现成答案。
最后分享一个我的习惯:每次改完状态流转或统计逻辑,我会强制走一遍完整的自测清单——新建问卷、添加单选/多选/文本三种题、发布、用两个账号分别填写、暂停、看统计、结束、导出结果。这个流程模拟了系统从生到死的完整生命周期,任何一环出问题都能在演示前暴露。尤其是状态切换后立刻去查前端是否还有答题入口,以及统计占比是否随答卷数变化,这两处是这套系统最薄弱的环节,也是答辩时最容易被追问的地方。希望帮到你。
本文还有配套的精品资源,点击获取