简介:一份基于Servlet+jsp实现的学生选课管理系统完整源码,面向计算机相关专业正在准备毕业设计的学生,以及需要JavaWeb项目实战练习的初学者。系统涵盖管理员、教师、学生三类角色,实现学生信息管理、课程信息维护、教师成绩录入、学生选课与成绩查询等核心功能;后端采用Servlet与MySQL,前端使用JSP、CSS、Bootstrap与jQuery,可在IDEA或Eclipse中配合Navicat和JDK1.8直接运行部署。压缩包共112个文件,包含22个Java源文件、22个class编译文件、21个JSP页面、相关JAR依赖库及SQL数据库脚本等,整体大小仅2.39MB,结构清晰便于导入和二次开发。该源码项目经过调试可直接作为毕业设计使用,也适合在学习中对照理解JavaWeb分层开发流程。目前已获得553人学习浏览,是一个值得参考的完整实战案例。
1. 学生选课管理系统:一套能直接跑起来的 JavaWeb 课设样板
期末前两周,课设题目里十个有四个是学生选课管理系统。这个标题描述的交付物,就是把这个经典业务做成了完整工程:JSP + Servlet + JDBC + MySQL 的 javaWeb 项目,附带建库脚本和初始数据,导入 IDEA 配好 Tomcat 就能跑通从登录到选课到成绩查询的整条链路。它对三类人有直接价值:准备交课程设计的学生,想快速看懂一个真实 JavaWeb 业务怎么分层的初学者,以及需要一套带数据库脚本的演示工程去改造成自己系统的开发者。注意,这不是 Spring Boot 微服务,而是传统 JavaWeb 项目,先确认技术栈再决定要不要用。
2. 先定数据再写代码:五张核心表的结构与建库脚本
选课管理系统的业务本质,是“用户、课程、选课关系”三件事。源码里代码可以换写法,但表结构基本跑不出这个范围。我习惯先讲表设计,因为字段定错,后面改代码的代价远大于改一句 SQL。
2.1 三种角色与权限边界
最常见的角色划分是管理员、教师、学生。课程设计里我见过两种建模方式:一种是给每个角色单独建表,登录时分别查三张表;另一种是共用一张 user 表,用 role 字段区分,再通过关联字段挂到各自的明细表。后一种更常见,也更好扩展,因为登录入口只有一个,Session 里存的对象也统一。
sys_user 表只负责账号、密码、角色,学生姓名、班级、学号这些信息放到 student 表,教师职称、系别放到 teacher 表。这里的关联字段叫 user_ref_id,它指向 student.id 或 teacher.id。管理员没有对应明细,user_ref_id 置 0 即可。这个设计的好处是,以后要加一个“教务员”角色,只需要在 sys_user 里新增一个 role 枚举值,再决定要不要新建一张明细表,不需要动登录逻辑。
权限边界的控制也依赖 role 字段。管理员能访问用户管理和课程管理页面,教师能开课和录成绩,学生只能选课、退课、查成绩。页面展示和 Filter 拦截都以这个字段为准,后面第 4 章会给出具体代码。如果角色设计得再简单一些,比如只要学生和管理员,那张 sys_user 表就可以少一个枚举值,但表结构不用改。
2.2 五张核心表的字段设计
表结构建议如下,字段名可以直接对应到项目里的实体类属性。
| 表名 | 用途 | 关键字段 |
|---|---|---|
| sys_user | 登录账号与角色 | id、username、password、role、user_ref_id |
| student | 学生基本信息 | id、user_id、student_no、name、class_name、major |
| teacher | 教师基本信息 | id、user_id、teacher_no、name、dept、title |
| course | 课程信息与容量 | id、course_no、course_name、credit、teacher_id、capacity、selected_count、time_place |
| elective | 选课与成绩记录 | id、course_id、student_id、status、score、create_time |
这里有一个容易忽略的点:course 表里的 capacity 和 selected_count 是容量控制的两个关键字段。selected_count 不是纯冗余字段,它的作用是让选课操作可以用一条 UPDATE 语句同时完成“容量判断 + 人数递增”,避免先 SELECT 再 INSERT 带来的并发超选问题。elective 表里的 score 字段为什么提前留出来,是因为成绩录入本来就和选课记录绑定在一起,教师录成绩时直接按 course_id 更新这一列即可,不需要额外建一张成绩表。
字段类型上,student_no、teacher_no、course_no 这类编号字段全部用 VARCHAR,不要用 INT。原因是编号可能带前导零或字母,用 INT 会静默丢失格式。credit 用 DECIMAL(3,1) 而不是 FLOAT,因为浮点类型在计算总学分时容易出现 2.99999 这种精度问题,课程设计阶段看不出差别,答辩时一算总分会很尴尬。
2.3 建库脚本:DDL 与初始化数据
把下面的 SQL 存成 init.sql,在 Navicat 或命令行里执行即可。需要注意 utf8mb4 编码和唯一索引,这两处是最容易在运行阶段踩坑的地方。
CREATE DATABASE IF NOT EXISTS course_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE course_system; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role ENUM('ADMIN', 'STUDENT', 'TEACHER') NOT NULL, user_ref_id INT NOT NULL DEFAULT 0, INDEX idx_role (role) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, student_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, class_name VARCHAR(50), major VARCHAR(50), INDEX idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE teacher ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, teacher_no VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, dept VARCHAR(50), title VARCHAR(20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0, teacher_id INT NOT NULL, capacity INT NOT NULL DEFAULT 60, selected_count INT NOT NULL DEFAULT 0, time_place VARCHAR(200), INDEX idx_teacher (teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE elective ( id INT PRIMARY KEY AUTO_INCREMENT, course_id INT NOT NULL, student_id INT NOT NULL, status TINYINT DEFAULT 1, score DECIMAL(5,1) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_course_student (course_id, student_id), INDEX idx_student (student_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 DDL 里有几个参数值得说明。password 字段用 VARCHAR(64),是因为 md5 哈希后是 32 位,预留长度是为了将来换 SHA-256 或加盐后不需要改表。role 用 ENUM 而不是 VARCHAR,能在数据库层限制非法角色,程序里传一个不在枚举里的值会直接报错,这比在 Java 代码里写 if 判断更早暴露问题。ENGINE 全部指定 InnoDB,是因为选课和人数递增需要行级锁和事务回滚,MyISAM 不支持这些,换成 MyISAM 后第 4 章的防超选写法就失效了。
elective 表的联合唯一索引 uk_course_student 是防重复选课的最后一道防线。即使 Controller 层和 Service 层都漏了查重,数据库也会拒绝插入第二条 course_id + student_id 相同的记录。底层会报 Duplicate entry 错误,业务层再把这个异常转成“你已经选过这门课程”的提示。再插一段初始化数据,密码统一用 md5('123456') 生成,学生、教师、课程三张表各给一条:
INSERT INTO sys_user (username, password, role, user_ref_id) VALUES ('admin', MD5('123456'), 'ADMIN', 0), ('stu001', MD5('123456'), 'STUDENT', 1), ('tea001', MD5('123456'), 'TEACHER', 1); INSERT INTO student (user_id, student_no, name, class_name, major) VALUES (2, '20240001', '张明', '计算机2401班', '软件工程'); INSERT INTO teacher (user_id, teacher_no, name, dept, title) VALUES (3, 'T1001', '李华', '计算机学院', '副教授'); INSERT INTO course (course_no, course_name, credit, teacher_id, capacity, selected_count, time_place) VALUES ('C001', 'JavaWeb程序设计', 3.0, 1, 2, 0, '周一 3-4节 教学楼A201');注意课程容量故意写成 2,而不是真实的 60。这么做的目的是让演示时能快速触发“课程已满”的分支,不 demo 到第三个人就已经能看到完整逻辑。很多课设源码默认容量 60,演示到最后也没人把课选满,容量判断的代码就像黑匣子一样没被验证过。初始化数据的细节还有一层含义:admin 的 user_ref_id 是 0,表示管理员不关联任何业务表;而 stu001 和 tea001 通过 user_id 关联到各自详情表的主键,sys_user.id 与 student.id 并不相等,查询时要用关联字段,不要直接用 userId 去当作 studentId 用。
3. 用 IDEA 把项目跑起来:导入、Tomcat 配置与数据源连接
拿到源码之后的第一件事不是读代码,而是让它先跑起来。很多人在这一步就被卡住,因为 IDEA 里 JavaWeb 项目的运行配置比 Spring Boot 复杂:要装 Tomcat、要配置 Artifact、要建数据源。按下面的顺序做,十分钟内能启动。
3.1 导入工程与 JDK 和 Tomcat 配置
多数课设源码是 Maven 工程,目录下有 pom.xml,IDEA 直接用 Open 选择该目录,等 Maven 导入完成即可。如果不是 Maven 结构而是 WebContent 或 webapp 目录,就按普通 JavaWeb 项目打开,手动把 jar 包放进 WEB-INF/lib。Maven 工程里最关键的依赖如下:
<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.28</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.20</version> </dependency> </dependencies>这里有两个参数要解释。servlet-api 的 scope 设为 provided,意思是编译时需要、运行时交给 Tomcat 提供,避免和 Tomcat 自带的 servlet 实现冲突。mysql-connector-java 用 8.0.28,对应的驱动类名是 com.mysql.cj.jdbc.Driver;如果你本机是 MySQL 5.7,用这个驱动也能连,但连接串里必须带 serverTimezone,否则会报时区异常。
导入完成后配置 Tomcat。IDEA 右上角下拉框选 Edit Configurations,点加号找 Tomcat Server Local。Name 随意,选本地 Tomcat 安装目录,JRE 保持默认。切到 Deployment 标签页,把当前项目的 war exploded 加进去,Application context 我习惯直接改成 /,这样访问时不带项目名,路径问题会少很多。war exploded 和 war 的区别在于前者是解压目录,支持 JSP 修改后直接刷新,不用重启 Tomcat;后者是打包后的压缩包,适合部署到服务器。本地开发选 war exploded 就够了。
3.2 数据库连接池配置:Druid 的参数含义
项目里如果用的 Druid,通常有一个 db.properties 或 druid.properties 放在 src/main/resources 下。核心内容如下:
driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/course_system?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username=root password=123456 initialSize=5 maxActive=20 minIdle=5 maxWait=60000 validationQuery=SELECT 1几个参数的调整逻辑:initialSize 是连接池启动时创建的连接数,开发环境 5 就够,改成 0 也行,只是第一次请求会慢一点。maxActive 是连接池能同时持有的最大连接数,课程设计这个量级 20 绰绰有余,不要盲目调大,每个连接都会占用 MySQL 的服务端内存。maxWait 是拿不到连接时的等待毫秒数,60000 表示 60 秒超时。如果经常报连接等待超时,优先检查是不是代码里拿到 Connection 没关闭,而不是先加大 maxWait,用连接池时连接关闭写错位置,内存被拖垮只是时间问题。
选择 Druid 而不是裸 JDBC,是因为它自带连接复用和监控,db.properties 不用跟着每个 DAO 方法重复加载。早期课设源码里经常看到每个 DAO 方法里 Class.forName 一次,那在并发表面上没问题,但每次连接都重新认证,性能很差。数据库连接池这类基础组件,属于“平时看不见,一旦访问量上来就翻车”,直接用成熟方案省心。
url 里的 characterEncoding=utf8 必须和数据库字符集一致。前面建库用的是 utf8mb4,MySQL 驱动能识别 utf8 这个别名。serverTimezone 是 MySQL 8 驱动强制要求的参数,不写的报错信息非常长,最后一行才是 the server time zone,看到这里别慌,加上 Asia/Shanghai 就能过。
3.3 启动与验证
在 IDEA 里点击运行后,Tomcat 日志出现一行类似 Server startup in 的信息,说明容器起来了。浏览器访问 http://localhost:8080/,正常情况下会跳到登录页 login.jsp。用第 2 章插入的 admin / 123456 登录,能进管理员页面,说明 JDBC 连接、Session、页面转发这三层都通了。
如果看到 404 或 500,先别急着看业务代码。404 多半是 Application context 没改成 /,或者页面访问路径写死;500 大概率是数据库连接失败或 JSP 编译错误。看 IDEA 的 console 标签页,把堆栈第一行读出来,80% 的启动问题都能在这里定位。比如出现了 Access denied for user,说明 db.properties 账号密码不对,别去改代码;出现了 ClassNotFoundException,才需要回到 Maven 依赖和 Artifact 配置上。
提示:项目运行起来后,先看一眼登录成功后跳转的 URL 里有没有项目名。如果地址栏是 http://localhost:8080/course_system_war_exploded/,说明 Deployment 里没改 context,后续每个链接都要带一长串前缀,早点统一成 / 再继续。
4. 核心功能代码怎么读:登录鉴权、事务选课与防超选
项目跑起来以后,要做的是把登录、选课、退课这几条主流程对应的代码找出来,理解它的分层和几个关键写法。这既是为了应付答辩提问,也是你把它改成自己系统的前提。代码无非是数据库增删改查加页面跳转,但增删改查之间的顺序和边界决定了数据对不对。
4.1 登录与鉴权:从 LoginServlet 到 Filter 拦截
登录逻辑一般由 LoginServlet 接收表单提交的 username 和 password,查库校验,通过后把用户对象放进 Session。一个典型的实现如下:
@WebServlet("/login") public class LoginServlet extends HttpServlet { private SysUserDao userDao = new SysUserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = MD5Utils.md5(req.getParameter("password")); SysUser user = userDao.findByUsernameAndPassword(username, password); if (user == null) { req.setAttribute("error", "账号或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); return; } req.getSession().setAttribute("loginUser", user); resp.sendRedirect(req.getContextPath() + "/index.jsp"); } }这段代码里值得注意的点有三个。第一,request.setCharacterEncoding("UTF-8") 必须放在读取参数之前,否则 POST 提交的中文会乱码。第二,密码在 DAO 层做比对,但加密动作发生在 Servlet 里,也就是说数据库里存的永远是哈希值。这个约定要全项目统一,注册、初始化数据、登录三处都必须走 MD5Utils,任何一处漏了都会出现“数据库能看到密码,但程序登不进去”的怪现象。第三,重定向用 req.getContextPath() 拼路径,而不是硬编码 /项目名/index.jsp,这样改应用上下文时不会牵连所有跳转。
外观上,这种写法比直接在 JSP 里写数据库查询要清晰,但还有个明显缺点:md5 并没有加盐,撞库成本很低。课程设计阶段用它是常规操作,因为代码短、好解释;如果你的题目要求更高,把 MD5Utils 替换成 BCrypt 即可,原理一样,只是哈希值更长。
光登录还不行,受保护页面需要拦截。常见的做法是一个 Filter 检查 Session 里的用户和角色:
@WebFilter("/admin/*") public class AdminFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); SysUser user = session == null ? null : (SysUser) session.getAttribute("loginUser"); if (user == null || !"ADMIN".equals(user.getRole())) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, response); } }这个拦截器解决的是越权问题。注意 getSession(false) 的写法,当前没有 Session 就返回 null,而不是主动创建一个新 Session,能避免未登录用户每次访问都生成无意义的 Session 对象。角色判断放在 Filter 里,可以实现即使有人直接输入 admin/index.jsp 的地址,也会被拦回登录页。学生和教师模块的拦截逻辑同理,只是判断条件换成对应的 role 值。
4.2 选课业务:在事务里完成三条 SQL 的联动
选课不是一条 INSERT 那么简单。一次完整的选课要检查课程是否存在、是否已满、学生是否已选过,然后插入选课记录,再把 course 表的 selected_count 加一。这三步必须在一个事务里,否则会出现“课表里有一条选课记录,但课程已选人数没变”的数据不一致。这也是数据库事务最典型的应用场景。
核心代码大致如下:
public void selectCourse(Connection conn, int studentId, int courseId) throws SQLException { try { conn.setAutoCommit(false); CourseDao courseDao = new CourseDao(); Course course = courseDao.findById(conn, courseId); if (course == null) { throw new BizException("课程不存在"); } ElectiveDao electiveDao = new ElectiveDao(); if (electiveDao.exists(conn, courseId, studentId)) { throw new BizException("不能重复选课"); } electiveDao.insert(conn, courseId, studentId); int rows = courseDao.increaseSelectedCount(conn, courseId); if (rows == 0) { throw new BizException("课程已满"); } conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } }这一段把事务的关键动作写全了。setAutoCommit(false) 开启手动提交,commit 和 rollback 分别处理成功和失败两个分支。increaseSelectedCount 这个方法是容量判断的核心,下一节单独说。还有一层细节:Service 层和 DAO 层的 Connection 要传参共享,而不是每个方法内部单独拿连接。如果 DAO 里自己用 Druid 工具类拿连接,那么这里的事务控制全部失效。课程设计里出现“选科成功但人数不变”这种问题,十有八九是连接没传下去,各层各用各的。
退课就是反向操作:删除 elective 记录,再把 selected_count 减一。同样要放在一个事务里,并且删除时也要带 course_id 和 student_id 两个条件,防止误删别人的记录。退课的容量判断简单一些,因为不会发生超退,但事务仍然需要。
4.3 用一条 UPDATE 挡住超选
防超选最稳的写法,不是“先 SELECT 课程容量,再判断是否小于已选人数,然后 UPDATE”,而是把判断条件写进 UPDATE 的 WHERE 子句:
public int increaseSelectedCount(Connection conn, int courseId) throws SQLException { String sql = "UPDATE course SET selected_count = selected_count + 1 " + "WHERE id = ? AND selected_count < capacity"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, courseId); return ps.executeUpdate(); } }这段 SQL 让数据库在更新时原子地完成“检查容量 + 递增人数”两个动作。如果返回影响行数为 0,说明此刻课程已满,直接回滚;如果返回 1,说明这次选课合法。即使两个学生同时在最后一秒提交选课请求,MySQL 的行锁和 WHERE 条件也能保证最多一个人选上,不会出现先查后写导致的超卖。
常见的错误写法是先查询再更新:
select capacity, selected_count from course where id = ? if (selected_count < capacity) { insert into elective ... update course set selected_count = selected_count + 1 where id = ? }这种写法在单用户测试时完全正常,但并发请求一来就出问题。原因在于查询和更新之间有时间差,两个请求可以先后读到同一个已选人数,然后都判断可以选,最终超出容量。所以记住一个原则:容量判断尽量让数据库的原子 UPDATE 来做。selectCourse 方法里的查重也是同理,数据库层的联合唯一索引兜底,代码层的 exists 只是为了让提示更友好。
5. 移植到自己的项目:连接池参数调整与四个高频避坑点
源码能跑通只算入门。往自己的课设题目上迁移,比如把“学生选课”改成“实验室预约”或“教材订购”,表结构要改、页面要换、参数也要调。这一章讲迁移时一定要动的参数,以及几个我见的比较多的翻车现场。
5.1 迁移时必调的三个参数位置
第一个是 db.properties 里的连接串。把 course_system 换成你自己建的库名,username 和 password 换成自己本机 MySQL 的账号。url 里的 characterEncoding 和 serverTimezone 保留,去掉必踩中文乱码坑。第二个是初始化脚本里的初始数据。管理员账号、测试学生、示范课程都要按新业务改一遍,否则演示时页面上还是“张明选 JavaWeb”这种旧数据,显得很假。第三个是 Tomcat 的 Application context。部署名如果带了项目路径,所有 sendRedirect 和 JSP 里的相对链接都可能 404,最简单的办法是把 context 改成 /。
参数参考值如下,按课程设计的并发量级完全够用:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| initialSize | 5 | 启动即建立的连接数 |
| minIdle | 5 | 最小空闲连接数 |
| maxActive | 20 | 最大活跃连接数 |
| maxWait | 60000 | 获取连接最大等待毫秒数 |
| validationQuery | SELECT 1 | 校验连接是否有效 |
注意这些参数没有唯一正确答案。如果你本机内存只有 4G,maxActive 降到 10 更稳妥;如果你用的是 c3p0 而不是 Druid,配置项名称会变成 jdbc 开头的那一套,例如 jdbc.maxPoolSize。核心思路是:连接数宁少勿多,等待超时宁可报错也别让请求无限挂起。
5.2 避坑一:中文全部变成问号
现象:页面能登录能选课,但学生姓名、课程名在页面上显示为 ????,写入数据库的也是乱码。 原因:三层编码不统一。JSP 页面可能是 ISO-8859-1,Servlet 没设置请求编码,数据库连接串也没指定 utf8。 解决:三层各写一处。JSP 顶部加 <%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;Servlet 的 doPost 第一行加 req.setCharacterEncoding("UTF-8");数据库连接串保留 characterEncoding=utf8。如果这三层都改了还是乱码,把表 DROP 后用 utf8mb4 重建,因为表创建时的字符集可能在迁移过程中被覆盖成了 latin1。
5.3 避坑二:启动报端口被占用
现象:IDEA 运行项目,console 里出现 Address already in use: JVM_Bind,或者 Tomcat 启动失败。 原因:8080 端口被其他程序占用,或者上一个 Tomcat 进程没有完全退出。 解决:Windows 下命令行执行 netstat -ano | findstr 8080,找出占用进程的 PID,到任务管理器结束它;也可以改 Tomcat 的 conf/server.xml,把 8080 改成 8083。我一般优先改端口,因为杀进程容易误伤。改完端口后访问地址跟着变,记得让演示机上也一致。
5.4 避坑三:ClassNotFoundException: com.mysql.jdbc.Driver
现象:页面加载或启动时报找不到 MySQL JDBC 驱动类。 原因:mysql-connector-java 8.x 的合法驱动类名是 com.mysql.cj.jdbc.Driver,旧代码或旧笔记里写的是 com.mysql.jdbc.Driver;还有一种情况是 Maven 依赖在编译期存在,但运行时没有打进部署目录。 解决:先确认依赖版本。8.x 就把 driverClassName 改成 com.mysql.cj.jdbc.Driver;5.x 保持旧类名。然后打开 Project Structure -> Artifacts,展开 WEB-INF/lib,确认 mysql 驱动 jar 在里面。IDEA 里 Maven 依赖如果没勾选,源码编译能过,运行时却找不到类,这个坑很隐蔽,排查顺序放在最前面。
5.5 避坑四:选课记录重复但页面没提示
现象:同一个学生反复点击选课按钮,数据库里出现两条相同 course_id + student_id 的记录,或者直接报 Duplicate entry 异常。 原因:页面按钮没做防重复提交,Service 层也没有查重逻辑兜底。 解决:elective 表的联合唯一索引是底线,已经加了就不会真正出现重复数据。再在 Service 层捕获重复键异常,转成友好提示:
try { electiveDao.insert(conn, courseId, studentId); } catch (SQLIntegrityConstraintViolationException e) { throw new BizException("你已经选过这门课程"); }注意这个 catch 要在事务回滚之后抛业务异常,否则提示虽然友好,但事务把其他 SQL 也回滚了,状态会变得不确定。如果源码里没有统一异常处理,至少保证这个业务异常能被外层 catch 到并回滚。
6. 用一条验收 SQL 加三个冒烟用例证明项目可用
课设答辩或交付演示时,对方问得最多的往往不是代码细节,而是“你怎么证明系统是对的”。建议准备两个工具:一条数据一致性 SQL,和三组手工冒烟用例。
先给数据库加一条校验 SQL,专门查 selected_count 和选课明细是否一致:
SELECT c.course_name, c.capacity, c.selected_count AS 记录人数, (SELECT COUNT(*) FROM elective e WHERE e.course_id = c.id) AS 实际选课数, CASE WHEN c.selected_count = (SELECT COUNT(*) FROM elective e WHERE e.course_id = c.id) THEN '一致' ELSE '不一致' END AS 校验结果 FROM course c;跑出全部行的校验结果都是“一致”时,说明选课事务没有留下连带副作用。这条 SQL 会暴露一个非常隐蔽的问题:如果曾经手动在 elective 表插过测试数据,而没有同步更新 course.selected_count,业务代码里看着一切正常,但数据层已经不自洽了。用这条 SQL 能在演示前发现这类隐患。
冒烟用例按角色分三组:用 stu001 登录,选一门容量为 2 的课程,确认选课数加一;再选一次同一门课,确认被拦截;退课后再选,确认能恢复。用 tea001 登录,给选课学生录成绩,确认学生端能查到。用 admin 登录,新增一门课,确认容量字段不能为空。三组都过了,核心链路就算验证完成。
我自己的教训是:不要在演示当天临时改容量参数。有一次为了演示顺畅,我把 capacity 改成了 100,结果忘记了 selected_count 里已经残留旧数据,页面显示人数和明细对不上,临时排查了几分钟才定位到是初始化数据没同步。后来养成了一个习惯,每次移植后都先把 course 表重置,容量写小,跑一遍验收 SQL 再上台。这个习惯帮我省了不少时间。希望帮到你。
本文还有配套的精品资源,点击获取