简介:面向Java Web课程设计与毕业设计开发的完整选题系统项目,基于Java+Jsp+Mysql实现,包含管理员、教师、学生三种角色:管理员可管理学生信息、教师信息与课题信息,教师负责添加课题、查看课题,学生可浏览并选择课题,完整覆盖高校毕业设计选题业务中的主要流程。压缩包共221个文件,整体仅5.57MB,含98个JSP页面、12个Java源文件与24个class编译文件,以及SQL数据库脚本、XML配置文件、GIF/JPG图片与CSS样式等,目录结构清晰,sql脚本位于sql文件夹,导入后即可快速初始化数据库。系统经过多次测试运行无误,并已获得2416人学习下载,适合需要快速搭建选题平台,或想学习传统JSP+Servlet+MySQL分层架构的读者参考,可作课程设计基底或毕设项目模板。
1. Java+JSP+MySQL 的毕业设计选题系统:它到底替你解决了什么
每到毕业季,导师和教务老师最怕的不是论文写不完,而是选题阶段几十个学生在微信群里反复改志愿,Excel 表格传来传去,最后统计时发现两个人选了同一个题目。标题里这套系统就是把这件事搬上 Web:学生登录看题目、提交选题,教师登录审核,管理员统一导出结果。技术栈锁死在 Java、JSP、MySQL,不碰 Spring Boot,是高校 Web 方向最常见的课程设计与毕业设计题目之一。
这套方案适合两类人:一类是拿“毕业设计选题系统”当毕设题目的在校生,一类是想快速交付一个可演示 Web 应用的全栈初学者。整篇内容会沿着建库、写 Servlet、写 JSP 页面、部署 Tomcat 这条完整链路展开,最后把我在真实运行中踩过的坑一并交代清楚。读完你可以跟着复现,也能够明白这套结构为什么能跑得起来。
2. 开发环境与项目骨架:JDK、Tomcat、MySQL 的版本搭配与最小可运行配置
2.1 为什么是 JSP + Servlet + MySQL,而不是 Spring Boot
标题既然写死 Java+Jsp+Mysql,最合理的技术栈就是 Servlet + JSP + JDBC,最多加一个 JSTL 标签库。Spring Boot 也能做选题系统,但它默认把 JSP 支持放在嵌入式容器里,配置起来比传统 war 包部署更别扭,反而绕远了。JSP 本质是 Servlet 的模板化封装,第一次被访问时由 Tomcat 的 Jasper 引擎编译成 Servlet 再执行,所以“JSP 页面 → Servlet 类 → 数据库操作”这条链路特别适合理解 Web 项目的基础原理。
从交付角度看,传统动态 Web 项目的骨架很轻:一个能跑 Tomcat 的电脑、一个 MySQL 实例、一个 Maven 环境就够了。不需要搞微服务,不需要上消息队列,更不需要为这种业务写缓存中间件。你只需要把“请求怎么进来、业务怎么处理、数据怎么落库”这三件事理清楚,系统就已经完成大半。
2.2 版本搭配:一套可用的组合与为什么这样选
版本搭配是新手翻车最多的第一关。网上能找到的 MySQL 安装教程多半是 5.7 或 8.0 两个版本,Tomcat 又分 8.5、9.0、10.x,驱动坐标也一直在变。我建议直接用一套经过验证的组合:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | 大多数教学环境和 Tomcat 8.5 都兼容,避免 JDK 17 的模块化麻烦 |
| Tomcat | 8.5.x 或 9.0.x | 支持 Servlet 4.0 / JSP 2.3,部署 war 包最简单 |
| MySQL | 5.7 或 8.0 | 8.0 需要注意驱动类名和时区参数,下文避坑章会细说 |
| Maven | 3.6 以上 | 管理依赖和打包 war 用 |
Tomcat 10.x 不建议在这个项目里碰,因为它的包名从javax.servlet换成了jakarta.servlet,网上的教程、代码片段大部分还停留在javax,新手照着写会立刻编译报错。要让项目快速跑起来,选“老一点但资料多”的组合,比选“最新但没人踩过坑”的组合可靠得多。
2.3 用 Maven 搭一个动态 Web 项目骨架
在 IDEA 或 Eclipse 里新建 Maven 项目时,packaging 要选war,因为我们要部署到外部 Tomcat。项目建好后需要补上 Web 目录结构,最终长这样:
topic-select/ ├── pom.xml ├── src/main/java/ │ ├── com/example/dao/ # JDBC 数据访问 │ ├── com/example/entity/ # User、Topic、Selection 实体 │ ├── com/example/web/servlet/ # LoginServlet、SubmitSelectionServlet 等 │ └── com/example/util/ # DBUtil、MD5Util 等工具 └── src/main/webapp/ ├── WEB-INF/ │ └── web.xml # Servlet 3.0 可用 @WebServlet,此文件可留空壳 ├── login.jsp ├── student/ ├── teacher/ └── admin/pom.xml是这套骨架的核心,依赖声明直接决定后面会不会遇到 ClassNotFound 或版本冲突:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>topic-select</artifactId> <version>1.0.0</version> <packaging>war</packaging> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- Servlet 和 JSP 由 Tomcat 自己提供,scope 必须是 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- JSTL:JSP 页面里做循环和条件判断 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动:MySQL 8.0 必须配 8.x 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 数据库连接池,不用每次请求都创建 Connection --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies> <build> <finalName>topic-select</finalName> </build> </project>两个关键点必须理解:javax.servlet-api和javax.servlet.jsp-api的 scope 必须是provided,因为 Tomcat 自身已经带了这些类,如果打成 jar 放进WEB-INF/lib,启动时会和容器类冲突;mysql-connector-java8.x 的包名是com.mysql.cj.jdbc.Driver,不再是 5.x 时代的com.mysql.jdbc.Driver。finalName决定 war 包的名称,也决定部署后的访问路径/topic-select。
2.4 在 Tomcat 里跑通第一个 JSP 页面
项目骨架建好后,先在webapp下放一个最简单的index.jsp,用来验证整条链路是否通:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <!DOCTYPE html> <html> <head> <title>毕业设计选题系统</title> </head> <body> <h3>选题系统启动成功</h3> <a href="${pageContext.request.contextPath}/login.jsp">进入登录页</a> </body> </html>页面里用了${pageContext.request.contextPath},这是 JSP 的 EL 表达式,会自动拼上当前应用的上下文路径。不要手写死/topic-select,否则以后改 war 包名,所有链接都会断掉。
用 Maven 打包后,把target/topic-select.war复制到 Tomcat 的webapps/目录,启动bin/startup.sh(Windows 下是bin/startup.bat),浏览器访问http://localhost:8080/topic-select/。能看到页面就说明 Tomcat、JSP 编译、war 包结构三者都正常。这一步跑不通,后面所有业务功能都无从谈起。
3. 数据库设计:用户表、题目表与选题记录表的字段级拆解
3.1 先画明白关系:一个教师发布多个题目,一个学生只能有一个待审选题
数据库设计是这类系统的地基,表结构理不清楚,后面 Servlet 代码会写得非常别扭。核心表只有三张:用户表、题目表、选题记录表。用户表里用role字段区分学生、教师、管理员,而不是拆成t_student、t_teacher两张表——因为登录入口只有一个,拆表会让登录查询变得啰嗦。
题目表挂在教师 ID 上,表示“这个题是谁发布的”。选题记录表挂在学生 ID 和题目 ID 上,表示“谁选了哪个题、当前状态是什么”。这三张表的关系用一句话说就是:一个教师可以发布多个题目,一个题目可以被多个学生选(通过max_students控制名额),但一个学生同一时间只能有一条处于“待审核/通过”状态的选题记录。
状态不要用字符串,比如'waiting'、'approved',直接定义成tinyint枚举:0 待审核、1 通过、2 拒绝、3 已撤销。字符串状态在写 SQL 时会因为大小写和拼写问题埋坑,而且改状态逻辑时 Java 代码里全是魔法字符串,容易看漏。
3.2 建表 SQL:utf8mb4、唯一索引与自增主键
下面的 SQL 可以直接在 MySQL 里执行。字符集要选utf8mb4,不是utf8,因为utf8在 MySQL 里最多存 3 字节,遇到表情符号会报错;utf8mb4才是完整的 UTF-8。
CREATE DATABASE topic_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE topic_db; CREATE TABLE t_user ( id INT NOT NULL AUTO_INCREMENT COMMENT '主键', username VARCHAR(50) NOT NULL COMMENT '登录名', password VARCHAR(128) NOT NULL COMMENT '密码,建议存 MD5 加盐后的值', role TINYINT NOT NULL COMMENT '1-学生 2-教师 3-管理员', real_name VARCHAR(50) DEFAULT NULL COMMENT '姓名', student_no VARCHAR(20) DEFAULT NULL COMMENT '学号,仅学生使用', teacher_title VARCHAR(50) DEFAULT NULL COMMENT '职称,仅教师使用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE t_topic ( id INT NOT NULL AUTO_INCREMENT COMMENT '题目主键', teacher_id INT NOT NULL COMMENT '发布教师,关联 t_user.id', title VARCHAR(200) NOT NULL COMMENT '题目名称', content TEXT COMMENT '题目要求与验收目标', max_students INT NOT NULL DEFAULT 1 COMMENT '可容纳人数', selected_count INT NOT NULL DEFAULT 0 COMMENT '当前已选人数', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-开放 0-关闭', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_teacher (teacher_id), CONSTRAINT fk_topic_teacher FOREIGN KEY (teacher_id) REFERENCES t_user (id) ) ENGINE=InnoDB COMMENT='选题表'; CREATE TABLE t_selection ( id INT NOT NULL AUTO_INCREMENT COMMENT '选题记录主键', student_id INT NOT NULL COMMENT '学生用户ID', topic_id INT NOT NULL COMMENT '题目ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审核 1-通过 2-拒绝 3-已撤销', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '提交时间', audit_time DATETIME DEFAULT NULL COMMENT '审核时间', audit_note VARCHAR(255) DEFAULT NULL COMMENT '审核备注', PRIMARY KEY (id), UNIQUE KEY uk_student_topic (student_id, topic_id), KEY idx_topic (topic_id), CONSTRAINT fk_sel_student FOREIGN KEY (student_id) REFERENCES t_user (id), CONSTRAINT fk_sel_topic FOREIGN KEY (topic_id) REFERENCES t_topic (id) ) ENGINE=InnoDB COMMENT='选题记录表';注意uk_student_topic (student_id, topic_id)这个唯一索引:它保证同一学生对同一题目只能存在一条记录。如果学生被拒绝后想重新申请,正确做法是 UPDATE 原记录把status改回 0,而不是再 INSERT 一条。这个设计配合第 4 章的提交逻辑,能从数据库层面挡住重复插入。学生选不同题目是可以的,但业务上只允许保留一个活跃申请,这个靠事务代码控制。
3.3 初始化数据:管理员、教师、学生账号
系统中必须先有账号才能测试登录,直接用 SQL 插入三个基础账号。密码演示用 MySQL 的MD5()函数,正式上线建议用加盐哈希,比如 SHA-256 加随机盐,但这个项目用 MD5 足够教学演示:
INSERT INTO t_user (username, password, role, real_name, student_no) VALUES ('admin', MD5('admin123'), 3, '系统管理员', NULL), ('teacher01', MD5('teacher123'), 2, '张老师', NULL), ('student01', MD5('student123'), 1, '李四', '20210001'); INSERT INTO t_topic (teacher_id, title, content, max_students) VALUES (2, '基于JavaWeb的宿舍报修系统', '实现报修单提交、派单、状态跟踪和评价闭环。', 4), (2, '基于JSP的毕业论文过程管理系统', '实现论文选题、开题报告、中期检查的信息化管理。', 2);很多 MySQL 安装教程会默认建好 root 账号,但项目代码绝不能直接用 root 去连数据库。正式做法是创建一个专用账号,只授予topic_db的权限,这样即使连接串泄露,也不会把整个 MySQL 实例暴露出去。
3.4 索引与事务:防止重复选题的两道闸门
倒回去看业务规则:学生只能有一个待审核或通过的选题,题目不能超员。这两条规则靠代码校验无法在并发下保证,必须靠数据库配合。
第一道闸门是唯一索引uk_student_topic,防止同一学生对同一题重复插入。第二道闸门是事务加上条件 UPDATE。扣名额的 SQL 写成update t_topic set selected_count = selected_count + 1 where id=? and status=1 and selected_count < max_students,并且检查返回的更新行数——如果影响行数为 0,说明题目已满或已关闭,当前事务回滚。先用 SELECT 查再用 UPDATE 是典型的并发漏洞,两个请求同时读到名额还剩 1,最后两个人都插入成功。所有跟名额相关的操作必须在事务里,并且用带条件的 UPDATE 原子地完成,这是整套系统里最不能省的一步。
4. 核心功能实现:登录鉴权、选题流程和 JSP 页面串联
4.1 登录逻辑:UserDao 查询 + Session 保存角色
登录是整个系统的大门。表单提交到LoginServlet,Servlet 从请求里取用户名密码,调用 DAO 查询用户,成功后把用户对象塞进 Session,再按角色跳转。这里的关键点有两个:req.setCharacterEncoding("UTF-8")必须放在读取参数之前;密码校验要放在 DAO 的 SQL 里,不要把数据库里所有用户查出来再在 Java 里遍历比对。
package com.example.web.servlet; import com.example.dao.UserDao; import com.example.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/login") public class LoginServlet extends HttpServlet { private final UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 编码设置必须在 getParameter 之前 req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); User user = userDao.findByUsernameAndPassword(username, password); if (user == null) { req.setAttribute("error", "用户名或密码错误"); req.setAttribute("username", username); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setAttribute("role", user.getRole()); // 按角色分发首页 if (user.getRole() == 1) { resp.sendRedirect(req.getContextPath() + "/student/topicList.jsp"); } else if (user.getRole() == 2) { resp.sendRedirect(req.getContextPath() + "/teacher/topicList.jsp"); } else { resp.sendRedirect(req.getContextPath() + "/admin/index.jsp"); } } }代码里 forward 和 sendRedirect 的区别要分清楚:登录失败用 forward,把 request 里的错误信息带给 login.jsp,URL 不变;登录成功用 sendRedirect,让浏览器重新发起一个新请求,避免刷新页面时重复提交表单。这也是一种防止“重复提交”的基础手段。
UserDao里对应的查询方法写法如下:
public User findByUsernameAndPassword(String username, String password) { String sql = "select * from t_user where username=? and password=md5(?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); ResultSet rs = ps.executeQuery(); if (rs.next()) { User user = new User(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setRole(rs.getInt("role")); user.setRealName(rs.getString("real_name")); return user; } } catch (SQLException e) { throw new RuntimeException("查询用户失败", e); } return null; }这里用PreparedStatement传参而不是拼接字符串,是防止 SQL 注入的底线。之前看到过有同学把登录写成"select * from t_user where username='" + username + "'",输入' or 1=1 --直接绕过密码,这是 Java Web 项目实战里最丢分的翻车现场。
4.2 一个 Filter 同时做编码处理和登录拦截
JSP 页面、Servlet、Filter 都需要处理编码,只在一个地方设置没用。最简单可靠的做法是写一个全局 Filter,拦截所有请求,先设置 UTF-8,再做登录状态检查。为什么不用两个 Filter?因为多个@WebFilter("/*")的执行顺序不直观,合并成一个少踩顺序的坑。
package com.example.web.filter; import com.example.entity.User; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebFilter("/*") public class AppFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 先统一编码 req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); String uri = req.getRequestURI(); String contextPath = req.getContextPath(); String target = uri.substring(contextPath.length()); // 放行登录页、登录接口和静态资源 if (target.equals("/login.jsp") || target.equals("/login") || target.startsWith("/css/") || target.startsWith("/js/")) { chain.doFilter(request, response); return; } // 未登录一律跳回登录页 HttpSession session = req.getSession(false); if (session == null || session.getAttribute("loginUser") == null) { resp.sendRedirect(contextPath + "/login.jsp"); return; } // 角色路径隔离:学生不能访问教师目录 User user = (User) session.getAttribute("loginUser"); if (target.startsWith("/teacher/") && user.getRole() != 2) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; } if (target.startsWith("/admin/") && user.getRole() != 3) { resp.sendError(HttpServletResponse.SC_FORBIDDEN); return; } chain.doFilter(request, response); } }这个 Filter 里体现了一个重要原则:JSP 页面本身没有保护能力,用户可以直接在浏览器输入 URL 访问/student/topicList.jsp,所以必须在 Servlet 容器层面做拦截。req.getSession(false)里的 false 表示如果没有 Session 就返回 null 而不是新建一个,避免未登录访问也被创建出无用 Session。角色路径隔离做了两层:一层是登录状态,一层是角色目录,这样学生即使猜到教师页面的 URL 也进不去。
4.3 选题提交:事务、条件 UPDATE 和状态校验
选题提交是整个系统最核心的业务逻辑。一个学生在页面上点“选这个”,表单 POST 到SubmitSelectionServlet。这里不能只做insert,还要做三步校验:该学生是否已有活跃的选题记录、题目是否开放且未满员、插入是否成功。三步必须在一个事务里完成,否则会出现“名额扣了但记录没插上”这类数据不一致问题。
package com.example.web.servlet; import com.example.entity.User; import com.example.util.DBUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; import java.sql.*; @WebServlet("/student/submitSelection") public class SubmitSelectionServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); HttpSession session = req.getSession(); User loginUser = (User) session.getAttribute("loginUser"); int studentId = loginUser.getId(); int topicId = Integer.parseInt(req.getParameter("topicId")); Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 第一步:检查该学生是否已有待审核或通过的选题 int activeCount = 0; try (PreparedStatement ps = conn.prepareStatement( "select count(*) from t_selection where student_id=? and status in (0,1)")) { ps.setInt(1, studentId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { activeCount = rs.getInt(1); } } } if (activeCount > 0) { conn.rollback(); req.setAttribute("msg", "你已有一个待审核或通过的选题,不能重复提交"); req.getRequestDispatcher("/student/result.jsp").forward(req, resp); return; } // 第二步:原子扣减名额,不满足条件则影响行数为 0 String updateSql = "update t_topic set selected_count = selected_count + 1 " + "where id=? and status=1 and selected_count < max_students"; int rows; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, topicId); rows = ps.executeUpdate(); } if (rows == 0) { conn.rollback(); req.setAttribute("msg", "题目已关闭或名额已满"); req.getRequestDispatcher("/student/result.jsp").forward(req, resp); return; } // 第三步:插入选题记录,唯一索引 uk_student_topic 做最后兜底 try (PreparedStatement ps = conn.prepareStatement( "insert into t_selection(student_id, topic_id, status) values(?,?,0)")) { ps.setInt(1, studentId); ps.setInt(2, topicId); ps.executeUpdate(); } conn.commit(); resp.sendRedirect(req.getContextPath() + "/student/topicList.jsp"); } catch (Exception e) { try { if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } throw new ServletException("选题失败", e); } finally { try { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException e) { e.printStackTrace(); } } } }这段代码有两个值得细看的点。第一是conn.setAutoCommit(false)之后手动控制 commit 和 rollback,所有数据库操作全部走同一个连接,这样三个步骤要么全部成功,要么全部不生效。第二是第二步用 UPDATE 而不是先 SELECT 再 UPDATE,selected_count < max_students这个条件直接在数据库里判断,两个学生同时提交时,其中一个人的 UPDATE 会等待行锁,另一个提交后再执行时条件已经不满足,影响行数为 0,自然被回滚。这是 MySQL 事务里防超选的标准做法。
4.4 用 EL + JSTL 渲染题目列表,避免 JSP 里写 Java 脚本
前面 Servlet 负责取数据,JSP 只负责展示。有些老项目习惯在 JSP 里写<% for(...) %>这种脚本片段,虽然能跑,但页面里嵌着大量 Java 代码,调试和修改都很痛苦。这里用 JSTL 的<c:forEach>配合 EL 表达式,页面干净,读起来也更接近 HTML 模板的习惯。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head> <title>可选题目列表</title> </head> <body> <h3>当前开放选题</h3> <table border="1" cellpadding="8"> <tr> <th>题目名称</th> <th>指导教师</th> <th>已选/名额</th> <th>操作</th> </tr> <c:forEach items="${topicList}" var="t"> <tr> <td>${t.title}</td> <td>${t.teacherName}</td> <td>${t.selectedCount}/${t.maxStudents}</td> <td> <form method="post" action="${pageContext.request.contextPath}/student/submitSelection"> <input type="hidden" name="topicId" value="${t.id}"> <button type="submit">选这个</button> </form> </td> </tr> </c:forEach> </table> </body> </html>${topicList}是 Servlet 里通过request.setAttribute("topicList", list)放进去的数据,EL 表达式会自动调用 getter,${t.title}等价于t.getTitle()。每个题目旁边放了提交表单,用隐藏字段带题目 ID。这里必须用contextPath拼接 URL,否则在/topic-select/student/topicList.jsp页面里写死/student/submitSelection,请求会跑到http://localhost:8080/student/submitSelection,少了一层上下文路径,直接 404。
在展示教师姓名时,注意TeacherName这个字段不在t_topic表里,而是在查询时 JOINt_user表后放到Topic对象的属性里。一个简单的做法是:
select t.*, u.real_name as teacher_name from t_topic t left join t_user u on t.teacher_id = u.id where t.status = 1 order by t.id desc;查询结果通过rs.getString("teacher_name")映射到Topic.teacherName属性,页面里就能直接用${t.teacherName}。字段的命名映射要前后一致,teacher_name和teacherName只差一个下划线,却最容易因为拼写不一致导致数据读不出来。
5. 部署调试避坑:从 404 到中文乱码的六个常见故障排法
5.1 ClassNotFoundException:MySQL 8.0 驱动类名和时区设置
现象:项目部署后访问登录接口,页面抛java.lang.ClassNotFoundException: com.mysql.jdbc.Driver。
原因:MySQL 8.0 的驱动包把入口类从com.mysql.jdbc.Driver改成了com.mysql.cj.jdbc.Driver,同时连接 URL 需要显式声明时区参数。
解决:在DBUtil里加载驱动和拼 JDBC URL 时,统一按 MySQL 8.0 的规范写:
Class.forName("com.mysql.cj.jdbc.Driver"); String url = "jdbc:mysql://localhost:3306/topic_db" + "?characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false";如果你是 MySQL 5.7,驱动用 5.1.47 版本,类名还是com.mysql.jdbc.Driver,URL 不需要serverTimezone。检查项目里到底用的是哪个版本,直接看pom.xml里mysql-connector-java的版本号,不要凭印象猜。
5.2 依赖冲突:servlet-api 和 jsp-api 不能塞进 WEB-INF/lib
现象:Tomcat 启动时抛java.lang.ClassCastException: org.apache.jasper.servlet.JspServlet cannot be cast to javax.servlet.Servlet,或者启动日志里有SEVERE: The required Server component failed to start。
原因:WEB-INF/lib下同时存在javax.servlet-api或jsp-api依赖,与 Tomcat 自带的同名类冲突,导致类加载器里出现了两份javax.servlet.Servlet。
解决:检查pom.xml,确保这两项的 scope 是provided,然后重新mvn clean package,清空target目录。如果你不是用 Maven 而是手动往lib里拷 jar,那直接删掉servlet-api.jar和jsp-api.jar。这是一个非常典型的翻车点,新手几乎都会踩一次。
5.3 中文乱码:JSP 页面、连接串、Filter 三层缺一不可
现象:数据库里中文正常,但页面上显示问号;或者提交的“题目名称”到了数据库变成乱码。
原因:一段中文从浏览器表单到 MySQL 要经过三处编码转换:请求体的解码、JDBC 传输的编码、数据库表的字符集,任何一层不一致都会乱码。
解决:按顺序检查三层。 第一层,所有 JSP 头部必须写contentType="text/html;charset=UTF-8"并且页面文件本身是 UTF-8 编码,IDEA 右下角可以确认文件编码。 第二层,Filter 里request.setCharacterEncoding("UTF-8")必须在getParameter之前执行,前面 4.2 节的AppFilter已经做了这件事。 第三层,JDBC 连接 URL 里加characterEncoding=utf8,数据库建库时用utf8mb4。三层都对了,中文不会乱码。如果是 5.1.47 的驱动,注意 MySQL 参数名是characterEncoding,不要写成characterencoding,大小写不对直接连不上。
5.4 404:上下文路径与页面跳转 URL 不一致
现象:登录成功后跳转地址是http://localhost:8080/student/topicList.jsp,页面 404;而手动访问http://localhost:8080/topic-select/student/topicList.jsp却能打开。
原因:war 包名是topic-select,Tomcat 部署后应用上下文是/topic-select,所有请求都必须带这个前缀。代码里如果用绝对路径/student/xxx,跳转时没有拼上上下文前缀。
解决:统一使用req.getContextPath()或${pageContext.request.contextPath}拼接路径。所有sendRedirect、forward、form action、href都要带上 contextPath。还有一个细节:Forward到 JSP 时如果写/student/topicList.jsp,系统内部会自动找应用内的路径,不用带上下文前缀;但sendRedirect是给浏览器一个新的地址,必须带contextPath,两者机制不同,容易混。
5.5 并发下的重复选题与超选:代码校验挡不住同时提交
现象:测试时两个人同时点击“选这个”,名额只有 1 个,结果selected_count变成了 2,两个人都显示成功。
原因:如果逻辑是先select count(*)看名额是否够,再insert,两个请求可能同时读到名额还有 1,然后都通过校验,先后插入成功。
解决:必须用事务 + 条件 UPDATE,也就是 4.3 节里的update ... where ... selected_count < max_students。这样数据库行锁会串行化两个请求,后执行的 update 影响行数为 0,事务回滚。同样的思路也适用于教师审核:审核时把status从 0 改成 1,应该写成update t_selection set status=1 where id=? and status=0,防止同一记录被两个审核页面重复处理。
5.6 Session 失效与越权访问:页面要通过 Filter 才能保护
现象:直接浏览器访问/teacher/topicList.jsp也能看到教师页面;或者关掉浏览器再打开,还能看到登录后的页面。
原因:JSP 文件放在 webapp 目录下是公开的,Servlet 里的登录校验不会自动保护 JSP;而 Session 默认由 Tomcat 管理,关浏览器不一定立刻失效,JSESSIONID 藏在 Cookie 里,重新打开浏览器可能还带着旧凭证。
解决:任何需要登录的 JSP 都不能放在 public 根目录下“裸奔”,要么用WEB-INF目录存放页面并 forward 访问,要么用 4.2 节的AppFilter统一拦截。越权问题要靠角色路径隔离,学生访问/teacher/目录直接返回 403。Session 过期时间可以在web.xml里配置session-config,一般设 30 分钟。测试时用无痕窗口最干净,避免旧 Session 干扰。
6. 部署验证与进阶技巧:从本机 war 包到能演示的完整闭环
6.1 打包部署与一条可执行的验证清单
所有功能在 IDEA 里跑通后,最终要能以 war 包形式部署。命令行执行mvn clean package,成功后target/topic-select.war就是最终产物,扔到 Tomcatwebapps目录启动即可。没有装 IDE 的环境里,这也是把项目交出去最省事的方式。
验证时按下面清单过一遍,基本可以判断系统是否合格:
| 角色 | 操作 | 期望结果 |
|---|---|---|
| 管理员 | 登录,查看题目统计 | 能进入管理员首页,统计数据正确 |
| 教师 | 发布新题目,设置名额 | 题目出现在学生可见列表 |
| 学生01 | 选择一个题目 | 名额扣减,状态为待审核 |
| 学生01 | 再选另一个题目 | 提示已有待审核选题,不能重复 |
| 教师 | 审核通过学生01选题 | 状态变为通过,统计页正确 |
| 匿名用户 | 直接访问教师页面 | 被 Filter 拦截回登录页 |
6.2 提升完成度的小技巧:状态机、导出与密码安全
真正拿去答辩时,系统不能只有一个“能选”的功能。建议把选题状态从代码里抽出来,用一个SelectionStatus常量类或枚举管理:待审核、通过、拒绝、已撤销,所有状态流转只允许合法路径,比如“待审核 → 通过”“通过 → 已撤销”,不允许“拒绝 → 通过”这种跳跃。这样代码里没有散落的魔法数字,答辩时也能讲清楚业务边界。
导出功能是加分项,用 Apache POI 把审核通过的选题结果导出成 Excel,值班老师可以直接拿去打印。密码安全上,注册和初始化数据的密码至少要用MD5(username + password)做一层扰动,不要直接存明文;更讲究一点可以用 SHA-256 加随机盐,把盐存在 user 表单独字段里。最后提醒一句:Tomcat 默认管理页面和数据库端口不要暴露到公网,部署到云服务器上时,conf/server.xml里非必要端口一律关闭,这是 Web 服务器安全最低成本的防线。
我第一次做这类系统时,把状态字段设计成 varchar 随便填,结果教师审核逻辑越写越乱,最后推倒重来才明白状态机要提前定死。名额扣减也是吃了并发亏之后才补上事务条件和 UPDATE。这些坑非得自己踩一遍才会长记性,希望这篇文章能帮你把不该踩的部分提前绕开。希望帮到你。
本文还有配套的精品资源,点击获取