news 2026/10/4 6:15:57

JavaWeb实训选课系统:从建库到答辩的完整工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb实训选课系统:从建库到答辩的完整工程实践

简介:这份JavaWeb实训资源面向计算机相关专业学生与Java Web初学者,围绕学生选课系统这一经典课程设计场景,提供从需求分析到代码落地的完整参考。系统按角色划分功能:学生可注册登录、浏览课程、选课退课并查询已选结果;教师可发布与删除课程、查看选课情况,涵盖注册登录、学生选课、选课结果浏览、教师浏览结果、教师添加课程五大模块。资源包共111个文件,约20.84MB,包含java源码、jsp页面、class编译文件、css与scss样式、sql建库脚本,以及实训报告docx和答辩pptx,另有jar依赖与图片等素材,结构完整、便于直接运行与二次修改。目前已有4575人学习下载,适合用作课程设计、期末大作业或答辩准备的参考方案,帮助读者快速理解Servlet、DAO分层与数据库交互的实现思路。

1. 从一份“交作业”的选课系统说起:为什么它值得你认真做一遍

每年学期末,总有一批 JavaWeb 实训卡在同一个地方:功能能跑,但代码一团乱;答辩能讲,但一问 SQL 就露馅。学生选课系统几乎是所有高校 JavaWeb 实训的默认题目,原因很现实——它同时踩中了三件必须练的事:多表关联的业务逻辑、会话状态的管理、以及并发场景下的数据一致性。你如果只是把它当成一次“交作业”,那它确实没什么价值;但如果你把它当成一次完整的工程演练,从建库、写 DAO、配 Tomcat 到答辩时能讲清楚“为什么选课要加唯一索引”,那这套东西的含金量会完全不同。

这篇笔记面向三类人:正在做 JavaWeb 实训、需要一份能跑通且能讲明白的选课系统的同学;想拿一个完整案例练手 MySQL 多表操作和 Servlet 流程的初学者;以及需要准备答辩、但不知道怎么把技术点讲出深度的开发者。我会按“建库建表 → 后端分层 → 前端交互 → 并发与事务 → 答辩与调试”的顺序,把一套可复现的方案拆开讲。代码和 SQL 都会给到关键片段,参数怎么设、坑在哪、为什么这么选,都会说清楚。你照着做,至少能拿到一个结构清晰、能经得起追问的实训成果。

2. 建库建表:选课系统的 SQL 文件到底该怎么写

2.1 四张核心表的关系与字段设计

选课系统的数据模型不复杂,但很多同学一上来就建一张大表,把学生、课程、选课记录全塞在一起,后面查询和更新都会出问题。常见做法是拆成四张表:学生表、课程表、教师表、选课记录表。选课记录表是典型的中间表,用来表达学生和课程之间的多对多关系。

先看建表语句的关键部分。下面这段 SQL 可以直接放进你的schema.sql文件里,在 MySQL 中执行:

-- 学生表:学号作为业务主键,id 作为物理主键 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', sname VARCHAR(50) NOT NULL COMMENT '姓名', password VARCHAR(64) NOT NULL COMMENT '密码,存哈希值', major VARCHAR(50) DEFAULT NULL COMMENT '专业', grade INT DEFAULT NULL COMMENT '年级' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:课程容量和已选人数分开存,方便做并发控制 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, cno VARCHAR(20) NOT NULL UNIQUE COMMENT '课程号', cname VARCHAR(100) NOT NULL COMMENT '课程名', credit DECIMAL(3,1) NOT NULL DEFAULT 0 COMMENT '学分', capacity INT NOT NULL DEFAULT 50 COMMENT '容量上限', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数', teacher_id INT DEFAULT NULL COMMENT '授课教师' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录表:学生和课程的多对多中间表 CREATE TABLE sc ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', status TINYINT DEFAULT 1 COMMENT '1 已选 0 已退', UNIQUE KEY uk_student_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(id), FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段 SQL 里有几个设计点值得展开。第一,student表的sno加了UNIQUE,因为学号是业务上的唯一标识,登录和查询都靠它,不能重复。第二,course表把capacity和selected_count分开存,而不是每次去sc表里COUNT(*),这是为了在选课高峰期减少聚合查询的压力。第三,sc表上的UNIQUE KEY uk_student_course是整套系统里最关键的一行——它从数据库层面保证同一个学生不能重复选同一门课,后面讲并发的时候会反复提到它。

提示:如果你的 MySQL 版本是 5.7 以下,utf8mb4的默认排序规则可能不一样,建表时最好显式写上COLLATE=utf8mb4_general_ci,避免中文乱码。

2.2 初始化数据与自增主键的坑

建完表之后,你需要插入一些测试数据。很多同学在这里会犯一个错误:手动指定id值,比如INSERT INTO student (id, sno, sname) VALUES (1, '2021001', '张三')。这样做在单次测试时没问题,但如果你后面用AUTO_INCREMENT继续插入,或者做数据迁移,主键冲突就会找上门。我一般会让学生直接省略id,让数据库自己分配:

INSERT INTO student (sno, sname, password, major, grade) VALUES ('2021001', '张三', 'e10adc3949ba59abbe56e057f20f883e', '计算机科学与技术', 2021), ('2021002', '李四', 'e10adc3949ba59abbe56e057f20f883e', '软件工程', 2021), ('2021003', '王五', 'e10adc3949ba59abbe56e057f20f883e', '网络工程', 2022); INSERT INTO course (cno, cname, credit, capacity, selected_count, teacher_id) VALUES ('C001', 'JavaWeb 开发', 3.0, 60, 0, 1), ('C002', '数据库原理', 2.5, 50, 0, 2), ('C003', '数据结构', 3.0, 55, 0, 1);

密码字段存的是 MD5 值,e10adc3949ba59abbe56e057f20f883e对应的是123456。这里不要存明文,答辩时老师如果问“为什么密码不存明文”,你要能答出“防止数据库泄露后直接暴露用户凭证,实际项目中还会加盐”。虽然 MD5 现在也不够安全,但作为实训,用它来演示“不存明文”这个意识是够的。

还有一个容易翻车的点:外键约束的顺序。如果你先插sc表数据,再插student或course,会因为外键找不到引用而报错。正确的顺序永远是先插主表,再插关联表。退课的时候如果直接DELETE掉sc记录,selected_count不会自动减一,这个逻辑要在 Java 代码里手动处理,后面会讲。

3. 后端分层:Servlet + DAO + Service 的最小可运行结构

3.1 项目目录与依赖配置

一个能跑起来的 JavaWeb 项目,目录结构比代码本身更重要。很多同学把所有类都扔在src根目录下,编译能过,但答辩时一看结构就扣分。我一般会按下面这样组织:

src/ main/ java/ com/example/selection/ dao/ -- 数据访问层 service/ -- 业务逻辑层 servlet/ -- 控制层 entity/ -- 实体类 util/ -- 工具类(数据库连接等) resources/ db.properties -- 数据库配置 webapp/ WEB-INF/ web.xml -- 部署描述符 login.jsp course_list.jsp

依赖方面,如果你用 Maven,pom.xml里至少需要javax.servlet-api和mysql-connector-java。注意 Servlet 的版本要和 Tomcat 匹配:Tomcat 9 用javax.servlet,Tomcat 10 用jakarta.servlet,这个不匹配会导致ClassNotFoundException,是新手最常见的翻车点之一。

<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>

scope设为provided是因为 Tomcat 自带 Servlet API,打包时不需要重复包含。MySQL 驱动版本要和你的数据库版本对应,8.0 以上的驱动类名是com.mysql.cj.jdbc.Driver,连接 URL 里要加serverTimezone=Asia/Shanghai,否则会报时区错误。

3.2 数据库连接工具类与 DAO 的写法

数据库连接不要在每个 Servlet 里都写一遍DriverManager.getConnection,那样代码重复且难以维护。常见做法是写一个DBUtil类,从db.properties读取配置:

public class DBUtil { private static String url; private static String username; private static String password; static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { Properties props = new Properties(); props.load(in); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); Class.forName(props.getProperty("jdbc.driver")); } catch (Exception e) { throw new RuntimeException("数据库配置加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } }

db.properties的内容大致如下,注意 URL 里的参数:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/selection_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=your_password

DAO 层负责所有 SQL 操作。以选课为例,核心方法有两个:查询可选课程列表、插入选课记录。插入的时候要同时更新course表的selected_count,并且这两步必须在同一个事务里:

public boolean selectCourse(int studentId, int courseId) { String checkSql = "SELECT capacity, selected_count FROM course WHERE id = ? FOR UPDATE"; String insertSql = "INSERT INTO sc (student_id, course_id) VALUES (?, ?)"; String updateSql = "UPDATE course SET selected_count = selected_count + 1 WHERE id = ?"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement checkStmt = conn.prepareStatement(checkSql)) { checkStmt.setInt(1, courseId); ResultSet rs = checkStmt.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int capacity = rs.getInt("capacity"); int selected = rs.getInt("selected_count"); if (selected >= capacity) { conn.rollback(); return false; } } try (PreparedStatement insertStmt = conn.prepareStatement(insertSql)) { insertStmt.setInt(1, studentId); insertStmt.setInt(2, courseId); insertStmt.executeUpdate(); } try (PreparedStatement updateStmt = conn.prepareStatement(updateSql)) { updateStmt.setInt(1, courseId); updateStmt.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { // 唯一索引冲突会走到这里,说明重复选课 return false; } }

这段代码里有三个关键点。第一,SELECT ... FOR UPDATE在事务中锁住了课程行,防止两个学生同时选最后一个名额时超卖。第二,conn.setAutoCommit(false)开启了手动事务,插入和更新要么都成功,要么都回滚。第三,捕获SQLException时没有打印堆栈而是直接返回false,因为重复选课会触发唯一索引冲突,这是预期内的业务异常,不需要当成系统错误。参数方面,studentId和courseId都从 Session 中获取,不要从前端表单传,否则学生可以伪造 ID 选别人的课。

4. 前端交互与 Session 管理:登录、选课、退课的完整链路

4.1 登录验证与 Session 存什么

登录是选课系统的入口。前端login.jsp提交学号和密码到LoginServlet,后端查库比对密码哈希,成功后把学生信息写入HttpSession:

@WebServlet("/login") public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String sno = req.getParameter("sno"); String password = req.getParameter("password"); String md5 = DigestUtils.md5Hex(password); // 假设用了 commons-codec StudentDao dao = new StudentDao(); Student student = dao.findBySnoAndPassword(sno, md5); if (student != null) { req.getSession().setAttribute("student", student); resp.sendRedirect("course_list"); } else { req.setAttribute("msg", "学号或密码错误"); req.getRequestDispatcher("login.jsp").forward(req, resp); } } }

Session 里存的是完整的Student对象,而不是只存一个studentId。这样做的好处是后续页面可以直接从 Session 取姓名、专业等信息展示,不用每次查库。但要注意,Session 默认 30 分钟过期,如果学生选课中途去吃了顿饭回来,Session 失效,点击选课会跳回登录页。这个体验问题可以在前端加一个定时心跳,或者把 Session 超时时间调长,在web.xml里配置:

<session-config> <session-timeout>60</session-timeout> </session-config>

单位是分钟。实训环境里调到 60 分钟足够,生产环境要根据安全要求权衡。

4.2 选课列表的渲染与退课逻辑

课程列表页面用 JSTL 遍历后端传来的List<Course>,每一行显示课程名、学分、已选人数和操作按钮。这里有一个细节:如果当前学生已经选了某门课,按钮应该显示“退课”而不是“选课”。实现方式是在后端查询时,用LEFT JOIN sc判断当前学生是否已选:

SELECT c.id, c.cno, c.cname, c.credit, c.capacity, c.selected_count, CASE WHEN sc.id IS NULL THEN 0 ELSE 1 END AS selected FROM course c LEFT JOIN sc sc ON c.id = sc.course_id AND sc.student_id = ? AND sc.status = 1 ORDER BY c.cno;

这个查询把“是否已选”作为一个字段返回,前端根据selected的值决定按钮文案和链接。退课操作不是物理删除sc记录,而是把status更新为 0,同时course.selected_count减一。这样做的好处是保留选课历史,答辩时如果老师问“学生退课后记录还在吗”,你可以答“逻辑删除,方便统计和审计”。

退课的 SQL 同样需要事务:

public boolean dropCourse(int studentId, int courseId) { String updateSc = "UPDATE sc SET status = 0 WHERE student_id = ? AND course_id = ? AND status = 1"; String updateCourse = "UPDATE course SET selected_count = selected_count - 1 WHERE id = ? AND selected_count > 0"; try (Connection conn = DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 = conn.prepareStatement(updateSc); PreparedStatement ps2 = conn.prepareStatement(updateCourse)) { ps1.setInt(1, studentId); ps1.setInt(2, courseId); int rows = ps1.executeUpdate(); if (rows == 0) { conn.rollback(); return false; // 没有有效的选课记录 } ps2.setInt(1, courseId); ps2.executeUpdate(); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); return false; } } catch (SQLException e) { return false; } }

注意updateCourse里加了AND selected_count > 0,这是防止并发退课时把人数减成负数。虽然概率低,但一旦出现负数,答辩演示时被看到就很尴尬。

5. 避坑与排查:选课系统实训里最容易翻车的 5 个地方

5.1 中文乱码:从 JSP 到数据库的全链路排查

现象:登录后页面显示“å¼ ä¸‰”这样的乱码,或者选课成功后数据库里的课程名变成问号。

原因:乱码可能出现在三个环节——JSP 页面编码、Servlet 请求编码、数据库连接编码。任何一个环节没统一成 UTF-8,中文就会出问题。

解决:JSP 文件头部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>;Servlet 里在doPost第一行加req.setCharacterEncoding("UTF-8");数据库连接 URL 里加characterEncoding=utf8;建表时指定CHARSET=utf8mb4。四个地方都检查一遍,基本能解决 95% 的乱码问题。

5.2 选课超卖:为什么最后一个名额被两个人同时选到

现象:课程容量 50,已选 49,两个学生同时点击选课,结果两个人都选上了,selected_count变成 51。

原因:没有加锁或者事务隔离级别不够。两个线程同时执行SELECT查到selected_count=49,都认为还有名额,然后各自执行UPDATE。

解决:在事务中使用SELECT ... FOR UPDATE锁住课程行,或者直接用UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity,根据executeUpdate的返回值判断是否选课成功。后者更轻量,不需要显式锁。

5.3 重复选课:唯一索引报错被当成系统异常

现象:学生快速双击选课按钮,第二次请求抛出SQLIntegrityConstraintViolationException,页面显示 500 错误。

原因:sc表上的唯一索引阻止了重复插入,但代码没有捕获这个异常,直接抛到了容器。

解决:在 DAO 的selectCourse方法里捕获SQLException,判断错误码是否为 1062(MySQL 唯一键冲突),如果是就返回false,前端提示“您已选过这门课”。同时前端按钮加disabled防止双击。

5.4 Tomcat 启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver

现象:项目在 IDE 里跑得好好的,部署到 Tomcat 后启动就报驱动类找不到。

原因:MySQL 驱动的 jar 包没有放到WEB-INF/lib目录下。IDE 的编译 classpath 和 Tomcat 运行时的 classpath 是两回事。

解决:确认pom.xml里 MySQL 依赖的scope不是provided,然后执行mvn package,把生成的 war 包部署到 Tomcat。或者直接在项目结构里把驱动 jar 复制到webapp/WEB-INF/lib下。

5.5 Session 失效后选课跳转登录页,数据丢失

现象:学生填了一半的选课信息,Session 过期,点击提交后跳回登录页,之前选的课全没了。

原因:Session 超时后,getAttribute("student")返回null,Servlet 没有做空判断,直接抛出NullPointerException或者重定向到登录页。

解决:写一个LoginFilter,拦截所有需要登录的请求,检查 Session 中是否有student对象。如果没有,保存当前请求的 URL 到 Session,登录成功后跳回原页面。这样至少不会丢失操作意图。

6. 答辩前怎么把代码讲出深度:三个能加分的验证技巧

答辩的时候,老师不会只看你功能跑没跑通,更想听你讲清楚“为什么这么做”和“如果出问题你怎么查”。下面三个技巧是我带学生时反复验证过的,能让你在答辩现场从“背代码”变成“讲设计”。

第一个技巧:用EXPLAIN验证你的 SQL 有没有走索引。选课列表的查询涉及course和sc两张表的连接,如果sc表的student_id没有索引,数据量一大就会全表扫描。你可以在 MySQL 客户端里执行:

EXPLAIN SELECT c.id, c.cname, c.selected_count FROM course c LEFT JOIN sc ON c.id = sc.course_id AND sc.student_id = 1001 WHERE c.capacity > c.selected_count;

看type列是不是ref或eq_ref,看key列有没有用到索引。如果type是ALL,说明走了全表扫描,需要在sc表的student_id上建索引。答辩时把这个执行计划截图放出来,比你说十句“我做了优化”都有用。

第二个技巧:用SHOW ENGINE INNODB STATUS看最近一次死锁信息。如果你在事务里用了FOR UPDATE,并发测试时可能会遇到死锁。MySQL 会把最近一次死锁的详细信息记录在 InnoDB 状态里,包括两个事务分别持有什么锁、等待什么锁。你不需要完全看懂,但能指出“这里显示事务 A 在等课程行的锁,事务 B 持有该锁并在等选课记录的锁”,就足以证明你理解并发控制。

第三个技巧:把selected_count和sc表的实际记录数做一次对账查询。系统跑了一段时间后,可能因为异常退课或手动改库导致selected_count和实际选课人数不一致。写一个对账 SQL:

SELECT c.id, c.cname, c.selected_count, (SELECT COUNT(*) FROM sc WHERE sc.course_id = c.id AND sc.status = 1) AS actual_count FROM course c HAVING c.selected_count <> actual_count;

如果查出来有差异,说明你的退课或选课逻辑有漏洞。答辩时主动展示这个对账过程,老师会觉得你不仅写了功能,还考虑了数据一致性。我自己的习惯是每次改完选课相关的代码,都跑一遍这个对账查询,确认没有偏差再提交。这个习惯帮我省了很多次“演示时数据对不上”的后悔药。

希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 6:14:17

从Git仓库到研发效能:华为云CodeHub代码托管实战指南

1. 为什么代码托管会从“网盘”变成研发效能的核心枢纽很多团队对代码托管的理解停留在“给代码找个地方放着”&#xff0c;直到某天合并冲突此起彼伏、发布版本找不到对应commit、新人入职半天拉不下来工程、线上出问题不知道是谁改的&#xff0c;才意识到代码托管根本不是存代…

作者头像 李华
网站建设 2026/10/4 6:10:59

MOSFET栅介质与栅极材料选型实战:从SiO₂到高K金属栅的工程权衡

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/4 6:09:14

CMT2300A射频测试软件实现:频率配置、CW发射与PER统计

搞射频产品测试&#xff0c;尤其是手里这块板子用的还是CMT2300A的时候&#xff0c;很多工程师误以为只要把寄存器配一遍、能发出数据包就算“驱动完成”。但真正到了硬件测试阶段&#xff0c;测灵敏度和发射功率时你会发现&#xff0c;软件要配合的事情远比想象中多&#xff1…

作者头像 李华
网站建设 2026/10/4 6:09:09

框架3.0单列智能体风险:企业安全建设落地实操指南

《框架3.0》把“智能体风险”作为独立风险类别首次单列&#xff0c;这消息在企业管理层和安全圈里都炸开了锅。作为长期做企业安全建设的人&#xff0c;我的第一反应不是“又多了一个合规条目”&#xff0c;而是“该来的终于来了”。智能体&#xff08;AI Agent&#xff09;从实…

作者头像 李华
网站建设 2026/10/4 6:07:23

粒子群算法在配电网经济调度中的实战应用与参数调优

1. 为什么配电网调度会盯上粒子群算法老实说&#xff0c;我第一次把粒子群优化&#xff08;Particle Swarm Optimization&#xff0c;PSO&#xff09;用在配电网调度上时&#xff0c;心里是打了个问号的。那时候项目组拿到的课题是“含分布式电源的配电网经济调度”&#xff0c…

作者头像 李华
网站建设 2026/10/4 6:06:38

发一个这两天抓涨停板的公式 换手积极

一字涨停:C>REF(C,1) AND LH; BB:C<REF(C,1) AND LH; 去一字涨停:NOT(一字涨停) AND NOT(BB); P:90; D:10; 单峰密集:SCR(P)<D; 换手10天50:SUM(VOL/CAPITAL*100,10)>50; 去ST:IF(NAMELIKE(S),0,1) AND IF(NAMELIKE(*),0,1) AND DYNAINFO(17)>0; 流通盘:CAP…

作者头像 李华