简介:面向数据库课程设计的高校选课管理系统,基于JSP+Servlet+JavaBean+Tomcat技术栈实现,覆盖管理员、教师、学生三类角色:管理员可维护教师、学生与课程信息,教师可开课、取消课程、录入成绩并按行政班或课程查看排名,学生可查看课表、选课退选、按学年查询考试成绩,同时各角色支持修改登录密码。系统配套说明文档、E-R图及实体关系模型,清楚展示数据库表设计、关联关系与核心业务逻辑,可直接作为课程设计或毕业设计的完整参考。资源压缩包共237个文件,约5.95MB,内含35个Java源码、18个JSP页面、66个JavaScript脚本及CSS样式、XML配置、图片素材等,目录结构清晰,便于按功能模块对照学习。目前已有1052人学习下载,适合具备Java Web基础、希望快速搭建选课系统或理解MVC分层设计的读者。
1. 高校选课管理系统:先搞明白这套技术栈要交付什么
高校选课管理系统是数据库设计课程设计里出现频率最高的题目之一,这个题目默认的技术栈组合就是JSP+Servlet+JavaBean+Tomcat。很多参考代码把页面渲染、业务判断和数据库操作揉在一起,看起来能跑,答辩时却经不起一句追问。真正拉开分值的点是数据库约束怎么设计、选课事务怎么控制并发、Servlet和JavaBean的职责怎么划分。
按建表、分层、选课业务、部署排错的顺序做下来,这套系统就是一个论证扎实、能演示、能扛住追问的课程设计作业。适合课程设计阶段的在校学生,也适合刚转Java Web、想用完整案例练手的开发者。选课系统麻雀虽小,但数据库设计、Web分层和事务处理三个核心知识点它全都能串起来,做完这一套,你就算真正入了Java Web的门。
2. 数据库设计先行:五张核心表的E-R拆分与建表SQL
2.1 先从需求分析里抽实体:学生、教师、课程和开课记录
选课系统的需求看三句话:学生登录后浏览本学期课程并选课退课;教师登录后开设课程并录入成绩;管理员维护学生、教师、课程基础信息。顺着这三句话抽实体,学生、教师、课程是基础资料,开课记录是业务中间层,它表达的是"某学期某老师在某时间地点开设某门课"这件事。选课记录则是学生和开课记录之间的联系,成绩作为选课记录的属性存在。
很多初学者在E-R图上直接画学生和课程多对多,然后用选课表去关联学生与课程。这个设计在只有一个专业、一个学期、一门课只有一个老师开的情况下能凑合用。只要加入"同一门课两位老师同时开""跨学期数据要区分"这两个常规需求,没有开课记录表的设计立刻就乱了。正确的做法是在课程和学生之间加入开课记录:课程与开课记录是一对多,教师与开课记录是一对多,学生与开课记录通过选课记录实现多对多。
开课记录里要包含学期、上课星期、起止节次、上课地点、容量、已选人数、选课状态。星期和节次必须用数值存储而不是字符串,后面判断时间冲突、生成课表排序都靠这两个数值字段比较。建议用weekday表示周一到周日,取值1到7,配合startSection和endSection两个TINYINT字段,不要为"周一3-4节"这种展示型字符串单独留字段,展示逻辑放到JSP里拼。
2.2 建表SQL:主键、外键、唯一约束和索引一次配齐
下面这一段是五张核心表的建表脚本,字符集统一使用utf8mb4,引擎统一InnoDB。先建基础表,再建开课表和选课表,顺序不能乱,外键引用必须在被引用表存在之后。
CREATE DATABASE IF NOT EXISTS select_course_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE select_course_system; CREATE TABLE student ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', password VARCHAR(128) NOT NULL COMMENT '盐值+哈希后的密码', name VARCHAR(30) NOT NULL COMMENT '姓名', gender ENUM('男','女') NOT NULL DEFAULT '男', major VARCHAR(50) COMMENT '专业', class_name VARCHAR(30) COMMENT '行政班级', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE teacher ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, teacher_no VARCHAR(20) NOT NULL UNIQUE COMMENT '工号', password VARCHAR(128) NOT NULL, name VARCHAR(30) NOT NULL, title VARCHAR(20) COMMENT '职称' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_code VARCHAR(20) NOT NULL UNIQUE COMMENT '课程编号', course_name VARCHAR(60) NOT NULL, credit DECIMAL(3,1) NOT NULL COMMENT '学分,允许2.5这种小数', course_type VARCHAR(20) NOT NULL DEFAULT '必修', total_hours INT UNSIGNED NOT NULL DEFAULT 32 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:学号、工号、课程编号做唯一索引,防止程序并发插入时产生重复数据。password用VARCHAR(128),SHA-256的十六进制只有64位,留出空间给盐值拼接。学分用DECIMAL(3,1)而不是FLOAT,浮点比较误差会出现在"学分超过上限"这类校验上。性别用ENUM虽然简洁,但如果后续需求要存"保密",ALTER TABLE改枚举比较麻烦,你也可以直接VARCHAR(4)。
接着是开课表和选课表,这是整个数据库设计的核心:
CREATE TABLE section ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, course_id INT UNSIGNED NOT NULL, teacher_id INT UNSIGNED NOT NULL, semester VARCHAR(20) NOT NULL COMMENT '如 2025-2026-2', weekday TINYINT NOT NULL COMMENT '1周一 2周二 ... 7周日', start_section TINYINT NOT NULL COMMENT '开始节次', end_section TINYINT NOT NULL, location VARCHAR(50) NOT NULL, capacity INT UNSIGNED NOT NULL DEFAULT 60, selected_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '已选人数,冗余字段', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可选 0已截止', CONSTRAINT fk_section_course FOREIGN KEY (course_id) REFERENCES course(id), CONSTRAINT fk_section_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(id), INDEX idx_semester (semester), INDEX idx_weekday (weekday, start_section) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE enrollment ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, student_id INT UNSIGNED NOT NULL, section_id INT UNSIGNED NOT NULL, select_time DATETIME NOT NULL, grade DECIMAL(5,2) DEFAULT NULL COMMENT 'NULL表示成绩未录入', UNIQUE KEY uk_student_section (student_id, section_id), INDEX idx_section (section_id), CONSTRAINT fk_enrollment_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_enrollment_section FOREIGN KEY (section_id) REFERENCES section(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;选课表的唯一约束(student_id, section_id)是最后一道防线。如果只靠Java代码先查后插,两个请求并发时可能同时通过校验,唯一约束能在数据库层兜底,保证同一学生不会重复选同一开课记录。grade用DECIMAL(5,2)且默认NULL,NULL恰好表示成绩未录入,查询未录成绩就是IS NULL,不需要额外状态位。
外键约束名要显式写。课程设计里老师喜欢考删除场景,有约束名报错信息一眼就能看出是哪条外键,没有约束名只能看见一个自动生成的乱码。MySQL 5.7不执行CHECK约束,所以这里没写start_section <= end_section,这个校验放到Service层用Java代码保证。tidier做法是MySQL 8.0可以加上,但课程设计要照顾大多数环境的兼容性。
2.3 范式与冗余:capacity与selected_count的取舍值得答辩重点讲
基础表全部满足第三范式:学生、教师、课程、开课记录各自只存自己维度的属性,没有传递依赖。选课记录只存外键和业务属性,也满足。真正需要解释的是section.selected_count这个字段,本可以每次SELECT COUNT(*) FROM enrollment GROUP BY section_id得到,为什么还要存一列?
我的习惯是保留这个冗余字段,但有一个硬性前提:所有对它的增加和减少都必须与选课记录操作放在同一个事务里提交。理由有两点:一是课程列表每页20条,每条都做一次聚合统计,数据库压力明显变大;二是容量校验场景里,capacity > selected_count这个判断写起来简单,不需要把count结果和并发插入的行数做比较。代价是并发更新必须用行级锁控制,否则两个请求同时读到同一个selected_count=59,capacity=60,两笔都通过校验,最终实际选课人数变成61,这就是超卖问题,下一章专门处理。
同时不要学某些参考代码里的反模式:把student_name、course_name冗余进enrollment表。姓名和课程名通过JOIN就能拿到,冗余进选课表之后,一旦学生改名、课程更名,历史数据不会自动同步,还得写额外的更新脚本,纯给自己挖坑。
3. 三层架构落地:JSP只做展示,Servlet和JavaBean分工界定
3.1 一次请求的流转路径与各层职责边界
JSP+Servlet+JavaBean是经典的Model 2架构。JSP负责渲染页面,Servlet负责接收参数、调用业务方法、决定跳转,JavaBean负责数据和业务逻辑。常见做法是把JavaBean再拆成实体Bean、DAO、Service三层:实体Bean映射表结构,DAO只做增删改查,Service做业务规则和事务控制。
一次登录请求的流转路径是这样:浏览器把表单POST到/login,Tomcat根据注解找到LoginServlet;Servlet从request里取用户名和密码,调用StudentService.login();Service从DAO拿到学生记录并比对密码,构造Student对象返回;Servlet把Student放进Session,重定向到课程列表;列表页JSP从session里取Student,用EL表达式渲染用户名。
这个流程里最容易出现的问题是两层:JSP页面里出现 <% for (...) { %> 和 Connection conn = ... 这种代码,Servlet里直接写JDBC查询,Service层名存实亡。后果不是不能运行,而是功能越加越乱,答辩被问到某一层职责时根本说不清楚。判断职责归属有个简单标准:页面代码里不该出现Java语句和JDBC连接;Servlet里不该出现SQL关键字;Service里不该出现HttpServletRequest。看到违反这三条的,直接重构,别犹豫。
3.2 实体Bean、DAO、Service:每个类的代码量应该控制在什么范围
实体Bean只做一件事:用属性描述一张表的行。属性名与数据库列名对应,提供无参构造和getter/setter,不写业务方法。DAO类一个表一个,方法名见名知义:findById、listByPage、insert、deleteOne,参数是基本类型或实体对象,返回实体、List或boolean,不接收HttpServletRequest。业务规则放进Service里。
下面是StudentDAO的查询方法,使用PreparedStatement而不是字符串拼接SQL,这是底线:
public class StudentDAO { private final DataSource dataSource; public StudentDAO(DataSource dataSource) { this.dataSource = dataSource; } public Student findByLoginName(String loginName) { String sql = "SELECT id, student_no, password, name, gender, major, class_name " + "FROM student WHERE student_no = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, loginName); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Student s = new Student(); s.setId(rs.getInt("id")); s.setStudentNo(rs.getString("student_no")); s.setPassword(rs.getString("password")); s.setName(rs.getString("name")); s.setGender(rs.getString("gender")); s.setMajor(rs.getString("major")); s.setClassName(rs.getString("class_name")); return s; } return null; } } catch (SQLException e) { throw new RuntimeException("查询学生失败", e); } } }DataSource在课程设计初期可以先用一个简易实现包一下DriverManager,后期换成连接池时DAO代码一行都不用改,这是把它通过构造器注入的意义。ps.setString(1, loginName)是占位符绑定,loginName即使包含 or '1'='1 这种输入也只会被当作文本,这是PreparedStatement相比拼接SQL最核心的优势。try-with-resources自动关闭ResultSet、PreparedStatement、Connection,省掉finally里容易漏写的资源释放。
DAO里不要出现业务判断。比如"密码不对就返回false"这种逻辑不能放在DAO里,DAO只负责按学号查出这条记录,剩下是Service的事。很多参考代码把DAO写得像万能工具类,一个executeQuery(String sql, Object... args)方法通吃所有查询,参数是省了,但SQL散落在调用方,后期维护非常痛苦,答辩时也不好看。
3.3 登录链路完整代码:从JSP表单到Session的请求串联
登录不能明文比对密码。常见做法是注册时生成随机盐,把"盐:哈希"拼在一起存进password列,登录时先按学号查出这一行,再根据盐重新计算哈希比对。为课程设计演示方便,下面给出一个简化版本并使用固定盐,注释里说明生产环境应该换BCrypt这类专门的密码哈希算法。
public class PasswordUtil { public static String encrypt(String rawPassword) { // 演示用:固定盐 + SHA-256,生产环境请使用 BCrypt/Argon2 String demo = "select-course-demo-salt-" + rawPassword; return sha256Hex(demo); } private static String sha256Hex(String text) { try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(text.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }SHA-256输出固定64个十六进制字符,password列VARCHAR(128)足够。演示代码把盐写死在类里,只适合作业演示;真实项目的盐应该是每个用户独立的随机值,存储格式为salt:hash,登录时按用户取出盐重新计算。答辩时主动讲出这一层的差别,比等老师追问要好得多。
Service和Servlet的代码就清爽了:
public class StudentService { private final StudentDAO studentDAO = new StudentDAO(DBUtil.getDataSource()); public Student login(String loginName, String rawPassword) { Student student = studentDAO.findByLoginName(loginName); if (student != null && student.getPassword().equals( PasswordUtil.encrypt(rawPassword))) { return student; } return null; } }@WebServlet("/login") public class LoginServlet extends HttpServlet { private final StudentService studentService = new StudentService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String loginName = req.getParameter("loginName"); String password = req.getParameter("password"); Student student = studentService.login(loginName, password); if (student != null) { req.getSession().setAttribute("loginStudent", student); resp.sendRedirect(req.getContextPath() + "/course/list"); } else { req.setAttribute("error", "学号或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }req.setCharacterEncoding("UTF-8")必须放在getParameter之前,否则POST中文参数从第一步就乱掉。成功用sendRedirect,浏览器地址栏变成课程列表URL,刷新页面不会重复提交表单;失败用forward转发回login.jsp,request里的error提示还能通过EL表达式保留在页面上。Servlet里没有SQL、没有业务判断,只做参数提取和页面调度,这就是控制层该有的样子。
login.jsp里的表单做法也值得注意:
<form action="${pageContext.request.contextPath}/login" method="post"> <input type="text" name="loginName" required> <input type="password" name="password" required> </form> <c:if test="${not empty error}"> <p style="color:red">${error}</p> </c:if>用EL表达式取上下文路径,项目打包后部署在任何目录下都不会把表单action写死。使用JSTL的c:if时记得在JSP顶部引入<%@ taglib prefix="c" uri="jakarta.tags.core" %>,Servlet 5.0以后前缀命名空间有变化,别照抄旧博客导致标签解析失败。
4. 选课与退课的核心业务:事务、行锁和状态校验全落地
4.1 选课Service:容量校验、冲突校验与事务提交
选课是这个系统里事务性最强的功能。一次选课至少包含三个校验和一个插入:开课记录状态、容量是否已满、同一学生是否已选过、是否与已选课程时间冲突。校验通过后插入选课记录,再更新section.selected_count。这两个写操作必须在同一事务里,否则会出现选课记录写成功但已选人数没更新,或者反过来。
容量校验在并发场景下必须用SELECT ... FOR UPDATE锁住section行。如果不用锁,两个请求同时读到selected_count=59、capacity=60,两边都通过校验,最终插入两条记录,这就是超卖。MySQL的InnoDB引擎下FOR UPDATE会在查询到的行上加排他锁,第二个事务必须等第一个提交或回滚后才能读到最新数据,这样第二个请求就能看到selected_count=60并正确报错。
public boolean selectCourse(int studentId, int sectionId) { String lockSql = "SELECT capacity, selected_count, status FROM section WHERE id = ? FOR UPDATE"; String countSql = "SELECT COUNT(*) FROM enrollment WHERE student_id = ? AND section_id = ?"; String conflictSql = "SELECT s.id FROM section s JOIN enrollment e ON e.section_id = s.id " + "WHERE e.student_id = ? AND s.semester = (SELECT semester FROM section WHERE id = ?) " + "AND s.weekday = (SELECT weekday FROM section WHERE id = ?) " + "AND s.start_section < (SELECT end_section FROM section WHERE id = ?) " + "AND s.end_section > (SELECT start_section FROM section WHERE id = ?)"; String insertSql = "INSERT INTO enrollment(student_id, section_id, select_time, grade) " + "VALUES(?, ?, NOW(), NULL)"; String updateSql = "UPDATE section SET selected_count = selected_count + 1 WHERE id = ?"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); // 1. 锁定课程行,校验状态和容量 try (PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setInt(1, sectionId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { throw new RuntimeException("开课记录不存在"); } int capacity = rs.getInt("capacity"); int selectedCount = rs.getInt("selected_count"); int status = rs.getInt("status"); if (status != 1) { throw new RuntimeException("该课程已截止选课"); } if (selectedCount >= capacity) { throw new RuntimeException("课程容量已满"); } } } // 2. 校验重复选课 try (PreparedStatement ps = conn.prepareStatement(countSql)) { ps.setInt(1, studentId); ps.setInt(2, sectionId); try (ResultSet rs = ps.executeQuery()) { if (rs.next() && rs.getInt(1) > 0) { throw new RuntimeException("不能重复选择同一门课"); } } } // 3. 校验上课时间冲突 try (PreparedStatement ps = conn.prepareStatement(conflictSql)) { ps.setInt(1, studentId); ps.setInt(2, sectionId); ps.setInt(3, sectionId); ps.setInt(4, sectionId); ps.setInt(5, sectionId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { throw new RuntimeException("与已选课程上课时间冲突"); } } } // 4. 写入选课记录并增加已选人数 try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, studentId); ps.setInt(2, sectionId); ps.executeUpdate(); } try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, sectionId); ps.executeUpdate(); } conn.commit(); return true; } catch (Exception e) { try { conn.rollback(); } catch (SQLException ex) { throw new RuntimeException("回滚失败", ex); } throw new RuntimeException("选课失败:" + e.getMessage(), e); } }这段代码有几个必须讲清楚的细节。FOR UPDATE锁的是section表里那行,不是整张表,其他学生同时选别的课程不会互相等待。conflictSql用四个子查询分别取出目标课程的semester、weekday、startSection、endSection,然后判断区间重叠:新课程开始节次小于已选课程结束节次,且新课程结束节次大于已选课程开始节次,两个区间必然相交。如果课程是3-4节和4-5节,按这个逻辑会判为冲突,这是合理的。
catch里要拿到同一个Connection做rollback。try-with-resources声明的conn作用域覆盖catch块,所以直接调用conn.rollback(),不需要新开连接。很多参考代码在catch里再获取一次连接去rollback,那是一个新连接,事务早就没了,等于白写。另外这里throw new RuntimeException会保留原始异常链,页面上能统一捕获显示"选课失败:课程容量已满",比单独返回一个布尔值好排查。
4.2 退课与改选:删除不是唯一答案,成绩状态要先判断
退课的常规逻辑是删除选课记录并让已选人数减一。但在删除之前必须判断成绩字段:如果老师已经录了成绩,退课会把成绩数据变成无来源的孤儿记录,破坏完整性。课程设计里我建议用状态守护,而不是无条件删除。
public void dropCourse(int studentId, int sectionId) { String checkSql = "SELECT grade FROM enrollment WHERE student_id = ? AND section_id = ?"; String deleteSql = "DELETE FROM enrollment WHERE student_id = ? AND section_id = ?"; String updateSql = "UPDATE section SET selected_count = selected_count - 1 WHERE id = ?"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); // 1. 先查成绩,已录入就不能退 try (PreparedStatement ps = conn.prepareStatement(checkSql)) { ps.setInt(1, studentId); ps.setInt(2, sectionId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { throw new RuntimeException("没有找到选课记录"); } if (rs.getObject("grade") != null) { throw new RuntimeException("该课程成绩已录入,不能退课"); } } } // 2. 删除选课记录 try (PreparedStatement ps = conn.prepareStatement(deleteSql)) { ps.setInt(1, studentId); ps.setInt(2, sectionId); ps.executeUpdate(); } // 3. 已选人数减一 try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, sectionId); ps.executeUpdate(); } conn.commit(); } catch (Exception e) { try { conn.rollback(); } catch (SQLException ex) { throw new RuntimeException("回滚失败", ex); } throw new RuntimeException("退课失败:" + e.getMessage(), e); } }rs.getObject("grade") != null比用grade == null或者查询条件里加grade IS NULL更直观,避免把字符串"null"混进去。DELETE语句同时带student_id和section_id两个条件,防止漏写WHERE把整张表清空,这是新手最容易犯的错。
改选场景即先退再选。如果把dropCourse和selectCourse作为两个独立请求处理,学生先退成功,再选新课的请求网络抖动,学生两头落空。常见做法是提供改选接口,在一个事务里执行退旧课和选新课两个动作,中间任何一步失败都整体回滚。实际课程设计里很多同学不做改选接口,只做退课和选课两个按钮,答辩时如果被问到"改选怎么实现",能把上面这个事务思路讲出来就够了。
4.3 课程列表分页:LIMIT offset的计算和页码越界兜底
课程列表的SQL用LIMIT offset, pageSize。很多同学直接把当前页码page当offset传进去,结果第一页跳过了第一条数据。正确的offset计算是(page - 1) * pageSize,参数必须用PreparedStatement绑定。
public PageBean<SectionVO> listAvailableSections(int page, int pageSize, String semester) { int safePage = Math.max(page, 1); int offset = (safePage - 1) * pageSize; String countSql = "SELECT COUNT(*) FROM section WHERE semester = ? AND status = 1"; String listSql = "SELECT s.id, s.weekday, s.start_section, s.end_section, s.location, " + "s.capacity, s.selected_count, c.course_name, c.credit, t.name AS teacher_name " + "FROM section s " + "JOIN course c ON s.course_id = c.id " + "JOIN teacher t ON s.teacher_id = t.id " + "WHERE s.semester = ? AND s.status = 1 " + "ORDER BY s.weekday, s.start_section " + "LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection()) { int totalCount = 0; try (PreparedStatement ps = conn.prepareStatement(countSql)) { ps.setString(1, semester); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { totalCount = rs.getInt(1); } } } int totalPages = (totalCount + pageSize - 1) / pageSize; if (safePage > totalPages && totalPages > 0) { safePage = totalPages; offset = (safePage - 1) * pageSize; } // 组装VO列表、填充PageBean的代码省略,注意listSql绑定safePage和offset PageBean<SectionVO> pb = new PageBean<>(); pb.setPage(safePage); pb.setPageSize(pageSize); pb.setTotalCount(totalCount); pb.setTotalPages(totalPages); return pb; } catch (SQLException e) { throw new RuntimeException("查询课程列表失败", e); } }totalPages用(totalCount + pageSize - 1) / pageSize实现向上取整,避免出现"数据只有10条,页面上还有个第2页可点"的情况。页码越界时把page修正到最后一页而不是返回空列表,前端翻页体验会好很多。PageBean里至少要有page、pageSize、totalCount、totalPages和dataList五个字段,dataList用泛型,展示层拿到直接循环渲染。
5. Tomcat部署与常见问题排查:五个必踩的坑
5.1 404和500:WEB-INF/lib与lib目录的部署路径陷阱
现象:项目部署后访问上下文路径出现404,或者页面里报ClassNotFoundException: com.mysql.cj.jdbc.Driver。
原因分两类。404多半是应用上下文路径不对,或者war包没有成功解压到webapps下;ClassNotFoundException多半是JDBC驱动jar放错了位置。把mysql-connector的jar丢进Tomcat的lib目录有时能用,但同一台Tomcat上部署两个项目时容易互相污染依赖。正确位置是项目里的WEB-INF/lib。
解决:用IDE部署时检查Deployment配置是否把构建产物和lib都打进了war;手工部署时确认war解压后WEB-INF/lib下有驱动jar。部署完后访问http://localhost:8080/项目名/,别漏项目名。我见过有人把war包直接改名成ROOT.war还问为什么404,那是另一个坑:如果你想用根路径访问,必须部署为ROOT.war并清掉旧应用。
5.2 把Java代码写进JSP:看起来能跑,答辩一问就穿
现象:页面功能正常,打开JSP源码全是<% %>脚本片段,数据库连接和选课时容量判断都写在页面里,Servlet里只有一行forward。
原因:不少参考代码图省事,把Java逻辑直接堆在页面里,跑起来确实没问题。但这恰恰违背了JSP+Servlet+JavaBean这组技术栈的初衷。JSP本来是视图层技术,里面写大量Java脚本会让页面无法维护,分工边界也没有了。
解决:按第3章的分层重构,JSP里只保留HTML、EL表达式、JSTL标签。判断标准很简单:在JSP文件里搜索<%符号,除了页面开头的<%@ page声明,其余的出现都算不合格。答辩时老师常问"Servlet和JSP各自职责是什么",这个重构就是你的答案。在网上找的代码如果看到JSP里出现Connection关键字,直接放弃,别在这个基础上改。
5.3 中文乱码:请求、响应、连接串、建表四层缺一不可
现象:登录页输入中文,页面上显示问号;往数据库写入后查出来是乱码。
原因:中文乱码链条涉及四个环节,请求编码、响应编码、JDBC连接串编码、数据库表字符集,任何一层不是UTF-8都会出问题。常见漏网之鱼是JDBC连接串里没加characterEncoding=UTF-8,或者表建成了latin1。
解决:同时做四件事。写一个Filter统一设置request和response的编码;JDBC URL追加useUnicode=true&characterEncoding=UTF-8;建表统一用utf8mb4;JSP页面顶部写全<%@ page contentType="text/html;charset=UTF-8" language="java" %>。如果还乱码,用MySQL客户端直接SELECT查库,先确认是写入阶段乱码还是显示阶段乱码,别上来就改代码。Tomcat 8以上的GET请求默认也是UTF-8,所以课程设计阶段不用为URL参数单独做转码处理。
5.4 事务没提交:前端提示成功,数据库没有记录
现象:学生点选课后看到"选课成功",刷新数据库enrollment表却是空的,再点一次反而提示重复选课。
原因:连接默认autoCommit=true时,执行完INSERT会立即提交,不会出现丢记录。更常见的是Service里开了事务但忘记调用commit,或者异常被catch后吞掉没有rollback,数据其实是被回滚了。还有一种隐蔽情况是DAO和Service用了不同的连接:Service开启事务,DAO又去连接池拿了一个新连接执行SQL,事务形同虚设。
解决:事务边界统一放在Service层,Service负责获取连接、setAutoCommit(false)、commit、rollback,DAO只是接收同一个Connection执行SQL,不要自己在DAO里再getConnection。写完后多做一步验证:选课成功后手动查enrollment和section.selected_count两条数据是否同步更新,这个验证习惯比代码本身更值钱。
5.5 外键约束删除失败:先删子表还是先删父表
现象:后台管理删除某学生时报外键约束失败,删除课程、删除教师同样报错。
原因:enrollment引用了student和section,section又引用了course和teacher。直接删父表记录,子表还有引用行,数据库外键约束不允许这样删。
解决:删除顺序从子到父。先删enrollment,再删section,最后删course和student、teacher。手工初始化测试数据时也要按student、teacher、course、section、enrollment的顺序插入,正好和删除顺序相反。外键可以设ON DELETE CASCADE让数据库自动级联删除,但课程设计阶段我建议不用,手动控制顺序更安全,被问到"为什么不用CASCADE"时,你可以回答:防止误删父表时把大量历史选课记录连带清掉,数据安全比省几行代码重要。
6. 从能跑到能答辩:连接池、防注入和两场验证
6.1 用数据库连接池替换DriverManager
课程设计里最划算的加分改造是把DriverManager.getConnection换成连接池。我习惯用DBCP2,配置简单,参数含义老师一问就能答上来。核心是一个静态的BasicDataSource:
public class DBUtil { private static final BasicDataSource dataSource = new BasicDataSource(); static { dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); dataSource.setUrl("jdbc:mysql://localhost:3306/select_course_system" + "?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"); dataSource.setUsername("root"); dataSource.setPassword("your_password"); dataSource.setInitialSize(5); dataSource.setMaxTotal(20); dataSource.setMaxIdle(10); dataSource.setMaxWaitMillis(3000); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }参数说明:initialSize是启动时预留的连接数;maxTotal是最大活动连接数,选课高峰期20个不够再调大;maxIdle是空闲保留数;maxWaitMillis是拿不到连接时的等待上限,超过就抛异常,避免请求无限挂起。换连接池后,前面所有DAO代码一行都不用改,因为DAO只认getConnection()。
6.2 答辩现场最值得做给评委看的两场验证
第一场是并发超选验证。把某门开课容量改成1,开两个无痕浏览器窗口,同时点选课。正确结果是只有一个成功,另一个提示容量已满,section.selected_count最终是1。如果两个都成功,说明FOR UPDATE或容量判断顺序有问题,这是整个数据库设计最重要的正确性证明。
第二场是事务回滚验证。在选课Service的插入语句之后手动抛一个异常模拟运行时故障,观察enrollment和section.selected_count是否都没有变化。正确结果是两处写操作都不落库。演示时临时加一个throw,答完就删。这个操作本身就能说明你理解事务边界在哪里。
我最早做模拟项目X时,图省事把容量判断直接写在JSP脚本里,演示那天被问到"两个学生同一秒抢最后一个名额会怎样",我支支吾吾答不上来,当场翻车。后来把所有业务判断收进Service层、统一定义事务边界,同样的功能代码量没涨多少,但之后每次被追问细节心里都有底。希望帮到你。
本文还有配套的精品资源,点击获取