☰
MySQL+Java学生选课系统:事务控制与业务约束实战
2026/11/4 10:53:21 网站建设 项目流程

简介:这是一套面向高校数据库与Java课程设计实践的完整CS架构学生选课信息管理系统,适用于计算机相关专业本科生开展数据库建模、JDBC编程与多角色权限管理综合实训。系统严格遵循三层用户逻辑:学生可完成选课退课、成绩查询与成绩单打印;教师支持课程管理与成绩录入;管理员覆盖院系、课程、师生、奖惩等全维度后台操作,配套SQL脚本建表规范,设计报告详述需求分析与ER模型。资源包共98个文件,含21个核心Java源码、66个编译后class文件、1个educate.sql数据库脚本、1个本地SQLite数据库文件(educateSystem.db)、1个设计报告.doc及必要配置文件,整体仅1.98MB,轻量易部署。目前已有11834人学习下载,读者可直接导入Eclipse运行调试,快速掌握MySQL+Java桌面应用开发全流程,包括连接池配置、事务控制、GUI界面布局与角色权限分层实现。

1. 这不是又一个“增删改查”Demo:它真能跑通学生选课全流程的 MySQL+Java 实战系统

某高校数据库课程设计里,90% 的学生交上来的是带登录框的 Swing 界面 + 四张表硬编码 CRUD——点“添加学生”就弹窗输学号姓名,点“选课”就手动填学号+课号+学期,后台连事务都不开。但真实教务系统里,“学生选课”从来不是原子操作:它要校验学分上限、检查课程容量、判断先修课是否已通过、冲突课表自动拦截、选课成功后还要触发成绩初始化……这个「学生选课信息管理系统」源码包,是某导师带学生用一整个学期打磨出的闭环实现:MySQL 8.0 建模含 7 张核心表(学生、课程、教师、选课记录、开课计划、先修关系、课表冲突规则),Java 后端用 JDBC 原生封装事务控制,Swing 客户端所有按钮行为都对应真实业务约束。它不炫技,但每处报错提示都带着业务语义(比如“您已修满本学期 24 学分上限,无法再选《编译原理》”);它不抽象,但所有 SQL 都带注释说明为什么用SELECT ... FOR UPDATE而非普通查询。适合正在啃《数据库系统概念》第六章、被隔离级别和死锁搞晕的新手,也适合想把课程设计答辩讲出技术深度的高年级同学——你拿到的不是脚手架,是能对着 ER 图逐行调试的业务逻辑黑匣子。

2. 从 ER 图到可运行 jar:建库、建表、初始化数据三步落地

2.1 为什么这 7 张表结构是经过业务推演的,而不是拍脑袋定的

很多同学建完“学生表”“课程表”“选课表”就以为完工了,结果在实现“查看某学生本学期课表”时卡住:课表需要显示上课时间、地点、教师姓名、是否已结课……这些字段散落在不同实体里。本系统采用显式建模业务事实的方式,拆解出关键中间表:

  • course_offering(开课计划):记录某门课在某学期由哪位教师在哪间教室几点上,解决“同一门课多个班次”的问题;
  • prerequisite(先修关系):用(course_id, prerequisite_id)复合主键,支持一门课有多个先修课(如《操作系统》需先修《C语言》和《数据结构》);
  • schedule_conflict_rule(课表冲突规则):存time_slot_id和room_id组合,用于实时校验——不是简单比对“星期几+第几节”,而是抽象成时间槽 ID,方便后期扩展在线预约场景。

提示:prerequisite表未设外键级联,因为先修课删除时业务要求人工审核(不能自动解除依赖),所以 Java 层用SELECT COUNT(*) FROM prerequisite WHERE course_id = ?主动拦截。

2.2 MySQL 8.0 建库脚本:字符集、排序规则与严格模式必须显式声明

课程设计常被忽略的细节:本地 MySQL 默认是latin1,而学生姓名含中文会乱码;更隐蔽的是sql_mode缺失导致INSERT INTO student (name) VALUES ('')竟然成功——空字符串在业务中应视为非法输入。本系统建库脚本强制声明:

CREATE DATABASE IF NOT EXISTS course_selection CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs; USE course_selection; SET sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION';
  • utf8mb4_0900_as_cs是 MySQL 8.0 推荐的大小写敏感排序规则,确保SELECT * FROM student WHERE name = '张三'不会误匹配'张三 '(带空格);
  • STRICT_TRANS_TABLES让插入空值或超长字段直接报错,逼你在 Java 层做校验,而不是让数据库默默截断。

2.3 初始化数据:用 INSERT SELECT 替代硬编码,保证测试数据自洽

系统附带init_data.sql,但关键不在“插了多少条”,而在如何保证数据逻辑自洽。例如:student表插入 50 条后,course_offering表必须确保每门课至少有 1 个开班,且该班容量 ≥ 30;enrollment(选课记录)表的数据必须满足“每个学生选课数 ≤ 6 门”。脚本用子查询生成:

-- 插入 30 条选课记录,确保每条记录的 student_id 和 course_offering_id 真实存在 INSERT INTO enrollment (student_id, course_offering_id, enrollment_time, status) SELECT s.student_id, co.course_offering_id, NOW(), 'CONFIRMED' FROM student s CROSS JOIN course_offering co WHERE s.student_id <= 30 AND co.course_offering_id <= 30 LIMIT 30;
  • CROSS JOIN生成笛卡尔积再LIMIT,比INSERT ... VALUES (1,1), (1,2)...更易维护;
  • 所有INSERT后紧跟SELECT COUNT(*)校验,避免因主键冲突导致部分数据未插入却无报错。

2.4 编译打包:为什么用 javac + jar 命令而非 IDE 一键构建

IDE(如 IntelliJ)默认将src下所有.java文件编译进out目录,但本系统要求明确分离业务逻辑与 UI 层:com.example.dao包下是纯 JDBC 操作,com.example.ui包下是 Swing 组件。若用 IDE 自动编译,容易把ui包里的测试类(如TestLoginFrame.java)也打进最终 jar,增大体积且暴露内部结构。我们坚持命令行构建:

# 1. 创建编译输出目录 mkdir -p build/classes # 2. 编译所有 .java 文件,指定输出路径和依赖jar javac -d build/classes \ -cp "lib/mysql-connector-java-8.0.33.jar:." \ src/com/example/dao/*.java \ src/com/example/service/*.java \ src/com/example/ui/*.java # 3. 打包,MANIFEST.MF 指定主类和Class-Path echo -e "Manifest-Version: 1.0\nMain-Class: com.example.ui.LoginFrame\nClass-Path: lib/mysql-connector-java-8.0.33.jar" > build/MANIFEST.MF jar cfm course_selection.jar build/MANIFEST.MF -C build/classes .
  • -cp参数显式声明 MySQL 驱动路径,避免运行时报ClassNotFoundException;
  • MANIFEST.MF中Class-Path用相对路径lib/xxx.jar,这样双击 jar 或java -jar course_selection.jar都能加载驱动;
  • src/com/example/ui/*.java编译时若报package com.example.dao does not exist,说明未加-cp build/classes,这是新手最常翻车的环节。

3. JDBC 事务怎么控?看懂 enrollCourse() 方法里的四层防御

3.1 业务入口:enrollCourse() 方法签名暴露了什么约束

EnrollmentService.enrollCourse(int studentId, int offeringId)方法看似简单,但参数类型暴露了关键设计:

  • int studentId:不接受String,因为学号在数据库是INT UNSIGNED,前端输入框做了type="number"限制;
  • 返回值是EnrollmentResult枚举(SUCCESS,CAPACITY_FULL,PREREQ_NOT_MET,TIME_CONFLICT),而非boolean——业务错误必须分类,不能只用false模糊掩盖。

注意:方法未声明throws SQLException,所有异常被封装为RuntimeException并在 UI 层捕获显示友好提示,符合课程设计“不暴露技术细节给用户”的要求。

3.2 第一层防御:SELECT ... FOR UPDATE 锁定开课计划行

选课前必须检查余量,但普通SELECT会导致超卖。本系统在checkCapacityAndLock()方法中:

// 使用 FOR UPDATE 显式加行锁,防止并发选课时余量判断失效 String sql = "SELECT capacity, enrolled_count FROM course_offering WHERE course_offering_id = ? FOR UPDATE"; try (PreparedStatement ps = connection.prepareStatement(sql)) { ps.setInt(1, offeringId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) throw new IllegalArgumentException("开课计划不存在"); int capacity = rs.getInt("capacity"); int enrolledCount = rs.getInt("enrolled_count"); if (enrolledCount >= capacity) { throw new BusinessRuleException("CAPACITY_FULL"); } // 余量充足,继续后续校验 } }
  • FOR UPDATE在 RR(可重复读)隔离级别下会对course_offering_id = ?这一行加写锁,其他事务对该行的SELECT ... FOR UPDATE或UPDATE会被阻塞;
  • 关键点:enrolled_count字段在course_offering表中,而非在enrollment表里COUNT(*)——前者是冗余字段,但换来了 O(1) 查询,这是课程设计允许的合理妥协。

3.3 第二层防御:用 EXISTS 子查询校验先修课

先修课校验不能只查“学生是否修过该课”,而要查“是否修过且成绩 ≥ 60”。本系统用关联子查询:

String prereqSql = """ SELECT 1 FROM prerequisite p WHERE p.course_id = ? AND NOT EXISTS ( SELECT 1 FROM enrollment e WHERE e.student_id = ? AND e.course_offering_id IN ( SELECT co.course_offering_id FROM course_offering co WHERE co.course_id = p.prerequisite_id ) AND e.status = 'COMPLETED' AND e.final_score >= 60 ) """; // 若返回结果集非空,说明存在未满足的先修课
  • NOT EXISTS比LEFT JOIN ... IS NULL更易读,且 MySQL 对EXISTS有优化(找到一条即停);
  • 子查询里e.status = 'COMPLETED'是硬性业务规则,未完成的选课记录(如ENROLLING状态)不计入先修课完成。

3.4 第三层防御:课表冲突检测用时间槽 ID 而非字符串拼接

冲突检测最易出错:有人用CONCAT(day_of_week, start_time)拼字符串比较,结果Monday08:00和Monday08:00:00不等。本系统在schedule_conflict_rule表中预定义时间槽:

time_slot_idday_of_weekstart_timeend_time
101108:00:0009:40:00
102110:00:0011:40:00

检测逻辑:

// 获取当前选课的时间槽ID String getSlotSql = "SELECT time_slot_id FROM course_offering WHERE course_offering_id = ?"; // 获取学生已选课程的时间槽ID列表 String getStudentSlotsSql = """ SELECT DISTINCT co.time_slot_id FROM enrollment e JOIN course_offering co ON e.course_offering_id = co.course_offering_id WHERE e.student_id = ? AND e.status = 'CONFIRMED' """; // 若两个集合取交集非空,则冲突
  • 时间槽 ID 是整数,比较效率远高于字符串;
  • DISTINCT防止同一时间槽多门课重复计算。

3.5 第四层防御:事务提交前的最终一致性快照校验

即使前三层都通过,仍可能因极短时间内其他事务修改导致状态漂移。本系统在commit()前执行快照校验:

// 在 connection.commit() 前,重新查询关键字段 String finalCheckSql = """ SELECT capacity, enrolled_count, status FROM course_offering WHERE course_offering_id = ? AND status = 'OPEN' -- 开课状态未变 """; // 若 capacity 或 enrolled_count 变化,说明并发冲突,回滚并重试
  • 这是典型的“乐观锁”思想,虽增加一次查询,但避免了死锁;
  • 课程设计中不实现重试逻辑(太复杂),而是抛出ConcurrentModificationException提示“请重试”。

4. Swing 界面不是摆设:按钮状态、输入校验、错误反馈全链路闭环

4.1 登录界面:为什么用 JPasswordField 而非 JTextField

JPasswordField返回char[]而非String,这是安全基本功。本系统在LoginFrame.loginButtonActionPerformed()中:

char[] passwordChars = passwordField.getPassword(); String password = new String(passwordChars); // ...校验逻辑 Arrays.fill(passwordChars, '\0'); // 立即清空内存中的密码明文
  • Arrays.fill()清空char[]是必须步骤,否则密码可能留在 JVM 堆内存中被 dump 出来;
  • JTextField会把密码存为String,而String在 Java 中不可变,无法主动清空。

4.2 选课界面:JTable 的动态列宽与右对齐数字列

JTable显示选课列表时,学分列需右对齐(符合阅读习惯),课程名列需自动调整宽度。本系统重写getColumnClass():

public Class<?> getColumnClass(int columnIndex) { if (columnIndex == 3) return Integer.class; // 学分列 return super.getColumnClass(columnIndex); }
  • Integer.class触发DefaultTableCellRenderer自动右对齐;
  • 配合table.setAutoResizeMode(JTable.AUTO_RESIZE_LAST_COLUMN),让最后一列撑满剩余空间。

4.3 输入校验:DocumentFilter 拦截非法字符,而非 onblur 时弹窗

学生学号只能是 8 位数字,课程号是字母+数字组合(如 CS101)。本系统用DocumentFilter实时过滤:

((AbstractDocument) studentIdField.getDocument()).setDocumentFilter( new DocumentFilter() { @Override public void insertString(FilterBypass fb, int offset, String string, AttributeSet attr) throws BadLocationException { if (string != null && !string.matches("\\d{0,8}")) return; // 只允许最多8位数字 super.insertString(fb, offset, string, attr); } @Override public void replace(FilterBypass fb, int offset, int length, String text, AttributeSet attrs) throws BadLocationException { if (text != null && !text.matches("\\d{0,8}")) return; super.replace(fb, offset, length, text, attrs); } } );
  • insertString和replace双重覆盖,防止粘贴非法内容;
  • 正则\\d{0,8}允许空输入(便于删除),但阻止输入第9位。

4.4 错误反馈:JOptionPane 不是万能的,要用 JDialog 嵌套组件

JOptionPane.showMessageDialog()只能显示纯文本,但业务错误需带操作按钮(如“去查看已选课程”)。本系统用JDialog:

JDialog errorDialog = new JDialog(this, "选课失败", true); errorDialog.setLayout(new BorderLayout()); JLabel msgLabel = new JLabel("<html><b>时间冲突:</b>您已选《数据结构》,其上课时间为周一 08:00-09:40,与《算法分析》冲突</html>"); JButton viewBtn = new JButton("查看我的课表"); viewBtn.addActionListener(e -> showMySchedule(studentId)); errorDialog.add(msgLabel, BorderLayout.CENTER); errorDialog.add(viewBtn, BorderLayout.SOUTH); errorDialog.pack(); errorDialog.setLocationRelativeTo(this); errorDialog.setVisible(true);
  • <html>标签支持粗体和换行,比纯文本更清晰;
  • setLocationRelativeTo(this)让对话框居中于父窗口,而非屏幕中心。

5. 避坑 / 常见问题 / 排查:血泪经验总结的 4 个真实翻车现场

5.1 现象:启动 jar 报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver

原因:MANIFEST.MF中Class-Path路径写错,或mysql-connector-java-8.0.33.jar未放在lib/目录下。常见错误是把驱动 jar 放在lib同级目录,或Class-Path写成lib/mysql-connector-java-8.0.33.jar(缺了开头的空格)。
解决:检查jar tf course_selection.jar | grep MANIFEST确认MANIFEST.MF内容;运行java -cp "lib/mysql-connector-java-8.0.33.jar:course_selection.jar" com.example.ui.LoginFrame测试类路径。

5.2 现象:选课成功后,course_offering.enrolled_count未增加

原因:UPDATE course_offering SET enrolled_count = enrolled_count + 1语句未在同一个事务中执行,或enrolled_count字段类型是TINYINT(最大值 127)导致溢出。
解决:在enrollCourse()方法末尾添加日志System.out.println("Updated enrolled_count to: " + newCount);检查表结构DESCRIBE course_offering,将enrolled_count改为SMALLINT UNSIGNED。

5.3 现象:输入学号00123456,查询不到学生

原因:MySQL 中student_id是INT类型,00123456被转为123456存储,前端传参时未补零。但业务要求学号固定 8 位,含前导零。
解决:将student_id字段改为CHAR(8),并在INSERT时用LPAD(?, 8, '0')补零;Java 层String.format("%08d", id)格式化后再传参。

5.4 现象:Swing 界面在高 DPI 屏幕上字体模糊、按钮错位

原因:Java 8 默认不启用高 DPI 支持,JFrame未设置setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)导致关闭后进程仍在。
解决:启动 jar 前加 JVM 参数-Dsun.java2d.uiScale=1.25(根据缩放比例调整);在LoginFrame构造函数末尾添加setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE)。

6. 验证你的系统是否真闭环:用这 3 个边界测试用例压测核心流程

6.1 测试用例 1:并发选同一门满员课(验证事务隔离)

准备:用course_offering表将某门课capacity=1,当前enrolled_count=0。
操作:开两个终端,同时运行以下命令(模拟两个学生点击“选课”):

# 终端1 java -cp "lib/mysql-connector-java-8.0.33.jar:." com.example.test.ConcurrentEnrollTest 101 201 # 终端2(几乎同时执行) java -cp "lib/mysql-connector-java-8.0.33.jar:." com.example.test.ConcurrentEnrollTest 101 202

预期结果:一个终端输出SUCCESS,另一个输出CAPACITY_FULL;检查数据库enrolled_count应为1,且两条enrollment记录中仅一条status='CONFIRMED'。

关键观察点:若两条记录都是CONFIRMED,说明FOR UPDATE未生效,检查 MySQL 隔离级别是否为REPEATABLE-READ(默认)。

6.2 测试用例 2:先修课链式依赖(验证 EXISTS 子查询)

建模:课程 A(ID=1001)依赖课程 B(ID=1002),课程 B 依赖课程 C(ID=1003)。学生只修过课程 C。
操作:调用enrollCourse(studentId, 1001)。
预期结果:抛出PREREQ_NOT_MET,且错误消息明确指出“未修《数据结构》(课程B)”。
验证方法:在PrerequisiteService.checkPrerequisites()方法中打断点,观察prereqSql执行的ResultSet是否为空——若为空,说明EXISTS逻辑正确;若不为空,检查enrollment表中course_offering_id是否关联到正确的course_id(易错点:course_offering.course_id与prerequisite.prerequisite_id比对)。

6.3 测试用例 3:课表冲突的“隐形”时间重叠(验证时间槽模型)

场景:课程 X 时间槽 ID=101(周一 08:00-09:40),课程 Y 时间槽 ID=102(周一 10:00-11:40),表面不冲突。但课程 Z 时间槽 ID=103(周一 09:00-10:30)——它与 X 重叠 10 分钟,与 Y 重叠 30 分钟。
操作:学生先选 X,再选 Z,应失败;先选 Z,再选 X,也应失败。
验证表格:

学生已选尝试选预期结果数据库schedule_conflict_rule应含
X (101)Z (103)TIME_CONFLICT(101,103)和(103,101)两条记录
Z (103)X (101)TIME_CONFLICT同上

从那以后我每次重构schedule_conflict_rule表,都强制走一遍这个三元组测试——哪怕只是改了个字段注释。因为课表冲突是学生最敏感的体验点,任何漏判都会在答辩时被导师当场揪出。希望帮到你

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询