简介:学生选课管理系统是一套面向教育机构及Java初学者的完整项目,覆盖数据库设计、Java Web开发与学生信息管理三大环节。压缩包共15个文件,约8.56MB,其中SQL脚本提供学生表、课程表、选课表等核心表结构及示例数据,两个ZIP分别封装项目源码与模板素材,另含8张运行截图、Word设计文档和3个TXT说明,便于对照环境配置与操作流程。系统具备学生注册登录、课程浏览、选课冲突检测、选课记录查询及成绩查询等功能,并涉及数据备份、权限管理和性能优化思路。已有5365人学习下载,适合正在做课程设计、毕业设计或想通过实际案例理解Java Web分层架构的开发者。通过这份资料不仅能掌握关系型数据库建表与CRUD操作,还能学习如何将JDBC、Servlet和JSP整合进一个真实可用的管理系统。 又到课程设计的高峰期了。每年这个时候,我都会收到不少关于“学生选课管理系统”的咨询:表怎么建、代码怎么分层、演示的时候怎么讲才能不被老师问倒。这个题目的名字看起来普通,但做完你就会发现,数据库设计、JDBC事务、角色权限、并发控制、异常排查全都要碰一遍,是一个浓缩度很高的综合型作业。这篇文章就基于我实际带人复现过的一套完整方案写出来,数据库建表脚本、源码组织思路、核心代码片段都会讲到。
如果你正准备用这个题目交一次高质量的课程设计,或者单纯想搞懂一个“数据库 + JavaWeb”应用是怎么从零落到代码上的,这篇会比较对路。我先说结论:这个项目想拿高分,重点不在页面有多花哨,而在选课的时候会不会“超容量”、删数据的时候会不会破坏完整性、事务回滚是不是真的生效。下面我按从设计到实现的顺序,一层一层拆开讲。
1. 开始之前:选课管理系统到底要管什么
1.1 三个角色与两条业务主线
选课管理系统表面上就是“学生选课、老师看学生、管理员管课程”,但落到具体业务上,角色之间是有明确的权限边界的。
- 学生:登录系统、浏览当前学期可选的课程列表、选课、退课、查看自己已选课程和成绩。
- 教师:查看自己负责的课程、查看选课学生名单、录入和修改学生成绩。
- 管理员:维护学生和教师信息、开设课程、调整课程容量、删除课程、查看全局选课统计。
两条核心业务主线,一条是“学生与课程之间”的选课/退课关系,另一条是“教师与课程之间”的归属关系。很多初做这个系统的人容易一上来就写增删改查,结果写着写着发现逻辑乱掉,就是因为没有先把这两条线的数据流捋清楚。我的建议是动手写代码前,先拿一张纸,把每个角色的行为路径画出来,再对照数据库表去落字段。
1.2 需求文档里被忽略的四个设计点
课程设计的任务书通常只有几句话:“实现学生在线选课、退课、成绩管理”。但真正写的时候,下面这些细节才是区分高分和及格的关键:
第一,选课容量。课程有容量上限,比如60人。学生选课时如果已选人数等于容量,必须拦截。第二,重复选课。同一个学生不能选同一门课两次。第三,先选课、后给成绩。成绩字段在选课记录表里,一开始是空的,只有教师录入后才非空。第四,删除保护。一个课程如果已经有学生选了,不能直接物理删除,否则外键会报错,业务上也说不通。
这些点在需求描述里往往没有,但它们直接决定了数据库表结构怎么设计、代码里事务边界画在哪里。我见过不少人做完以后演示时踩中“重复选课没拦截”或者“删课程直接报红”,就是因为前期没有把这些规则固化成设计约束。下面我会展示怎么把这几条规则直接写进建表语句和业务逻辑里。
2. 数据库设计:把约束写进建表语句,而不是留着后面补
2.1 核心表结构与字段说明
这套系统我用了四张核心表:学生表、教师表、课程表、选课记录表。如果你还需要更完整的权限,可以再拆一张管理员表,不过一般课程设计里,学生、教师、管理员三个角色共有四张表就够了。
学生表t_student:
CREATE TABLE t_student ( sid VARCHAR(20) PRIMARY KEY, sname VARCHAR(50) NOT NULL, gender CHAR(2), major VARCHAR(50), grade VARCHAR(10), password VARCHAR(64) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;教师表t_teacher:
CREATE TABLE t_teacher ( tid VARCHAR(20) PRIMARY KEY, tname VARCHAR(50) NOT NULL, title VARCHAR(20), dept VARCHAR(50) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;课程表t_course:
CREATE TABLE t_course ( cid VARCHAR(20) PRIMARY KEY, cname VARCHAR(100) NOT NULL, credit DECIMAL(3,1), type VARCHAR(20), tid VARCHAR(20), capacity INT NOT NULL, selected_count INT NOT NULL DEFAULT 0, semester VARCHAR(20), schedule VARCHAR(100), CONSTRAINT fk_course_teacher FOREIGN KEY (tid) REFERENCES t_teacher(tid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;选课记录表t_sc:
CREATE TABLE t_sc ( sid VARCHAR(20), cid VARCHAR(20), score DECIMAL(5,2), select_time DATETIME, PRIMARY KEY (sid, cid), CONSTRAINT fk_sc_student FOREIGN KEY (sid) REFERENCES t_student(sid), CONSTRAINT fk_sc_course FOREIGN KEY (cid) REFERENCES t_course(cid) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;如果你用的是 MySQL 8,上面的脚本基本直接能跑。注意建表时统一指定ENGINE=InnoDB,只有 InnoDB 才支持外键和事务;utf8mb4是为了让中文不乱码,后面我会专门讲乱码排查。
2.2 联合主键和外键为什么必须加
我在t_sc表里用了PRIMARY KEY (sid, cid),也就是学生和课程两个字段组成联合主键。这样做的直接效果是:同一个学生对同一门课的选课记录在数据库层面只能存在一条,哪怕应用层代码漏写了重复判断,数据库也会在插入时直接报主键冲突。这是第一道防重复的防线,也是最可靠的一道。
外键的作用是保护数据完整性。比如t_course里的tid外键指向教师表,那么录入课程时如果填了一个不存在的教师工号,数据库直接拒绝写入。t_sc里的两个外键则保证“选课记录里的学生和课程必须真实存在”。这三个外键加完之后,应用层代码可以少写很多无意义的过滤判断。
有同学担心外键会影响性能,在课程设计这个量级的数据下,这种担心完全是多余的。我反而见过不少人为了“性能”去掉了外键,结果演示时删除教师导致选课记录变得无主无凭,场面非常尴尬。课程设计阶段请保留外键,它能让你的系统解释起来更有底气。
2.3 容量字段:统计列与范式之间的权衡
注意我在t_course里放了一个selected_count,表示已选人数。从严格的第三范式角度看,这个字段是冗余的,因为它可以通过SELECT COUNT(*) FROM t_sc WHERE cid = ?实时统计出来。
但这里我故意保留冗余。原因是选课场景下,“当前还剩多少个名额”是个高频查询,学生打开选课列表就要看一次。如果每次都用 Count 统计,数据量稍大一点,列表页就会变慢。用selected_count字段每次做一次原子自增或自减,成本极低,页面加载速度也快。这就是典型的“用可控的冗余换性能”,也是课程设计的答辩加分点。只要你能够解释清楚为什么这么设计,老师通常都会认可。
当然,冗余字段的前提是必须保证它和真实数据一致。怎么保证?答案是把selected_count的更新和t_sc的插入放到同一个事务里,要么一起成功,要么一起失败。这个逻辑我放到第4部分重点讲。
3. 源码组织:课程设计场景下的技术选型与分层
3.1 为什么我坚持用 Servlet + JSP + JDBC
这个题目如果用 Spring Boot + MyBatis 来做,代码写起来确实舒服,但那意味着你把大量精力花在了学习框架上,而不是理解业务本身。课程设计的核心评价标准是:数据库设计是否合理、业务逻辑是否完整、能否讲清楚原理。Servlet + JSP + JDBC 虽然“原始”,但每一行代码都落到了实处,老师问你什么你都能答出来,因为你亲手卷过轮子。
话虽如此,如果你已经熟练掌握了 Spring Boot,用它来做也没有问题,只要你能把事务管理和数据库连接池的原理讲清楚。这篇文章后面的业务逻辑是通用的,换成任何技术栈都一样。我的源码组织建议是基于 Servlet + JSP 的经典三层结构,它最直观。
3.2 三层结构怎么分包,每个类干什么
我把源码按entity、dao、service、servlet、filter五个包组织。不要小看分包,这是你代码能不能让老师一眼看懂的起点。
entity:对应数据库表的实体类,Student、Teacher、Course、SC。dao:负责和数据库交互,每个实体一个 DAO,只写 SQL 和执行 SQL。service:业务逻辑层,选课、退课、成绩录入这些有规则的操作都放这里。servlet:接收 HTTP 请求,调用 Service,控制页面跳转。filter:登录拦截器和字符编码过滤器。
一个典型请求的流转过程是:JSP 页面点击“选课”→ 请求到SelectCourseServlet→ Servlet 调用CourseService.selectCourse(sid, cid)→ Service 内部调用CourseDao和ScDao操作数据库 → 返回结果 → Servlet 转发到选课列表页并提示结果。
这样做的好处是,某一天你要改数据库字段,只需要动 DAO;要改业务规则,只需要动 Service;要调整页面跳转,只需要动 Servlet。每一层职责单一,答辩时被问到某个功能在哪个类实现,你三秒钟就能指给对方看。
3.3 数据库连接与字符编码的工程细节
导航栏清单:
- 数据库连接用 JDBC 的
DriverManager获取连接,但注意在整个应用启动时只加载一次驱动。 - 写一个
DBUtil工具类,统一封装getConnection()、close()方法,避免每个 DAO 里重复写一堆雷同的样板代码。 - 所有带用户输入的 SQL 一律用
PreparedStatement,不要用字符串拼接。比如登录查询写成SELECT * FROM t_student WHERE sid = ? AND password = ?而不是"SELECT * ... WHERE sid = '" + sid + "'"。前者能有效防止 SQL 注入,后者会在答辩时被问得很难看。 - 字符编码方面,建议写一个
EncodingFilter,对所有请求统一设置request.setCharacterEncoding("UTF-8"),对所有响应统一设置response.setContentType("text/html;charset=UTF-8")。同时,JDBC 连接串里要加上characterEncoding=UTF-8。
关于数据库连接池,课程设计不强制要求,但如果你愿意用一个内置连接池,效果会更好。比如DruidDataSource,它不但能管理连接,还自带监控页面。你可以这样初始化:
DruidDataSource dataSource = new DruidDataSource(); dataSource.setUrl("jdbc:mysql://localhost:3306/student_course?characterEncoding=UTF-8&serverTimezone=Asia/Shanghai"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); dataSource.setMaxActive(10);这几个参数的设置思路是:初始连接数 5,不够用再增长,最多不超过 10。对于一个几十人同时访问的课程设计系统,这个池子完全够用。等你讲清楚连接池的原理——为什么要复用连接而不是每次新建,答辩印象分又能往上走一截。
4. 选课和退课的核心实现:事务边界就是业务边界
4.1 第一版代码为什么会写出“超容量选课”
选课这个场景最容易翻车的就是并发。我先用一个反例来说明问题。
很多新手写完的业务逻辑是这样的:
// 1. 查询当前已选人数 int count = courseDao.getSelectedCount(cid); // 2. 如果没满,就插入选课记录 if (count < capacity) { scDao.insert(sid, cid); courseDao.increaseCount(cid); return "选课成功"; } return "课程已满";这段代码在单用户测试的时候完全没问题,但在多人同时选课时就会出大问题。假设课程容量是 1,当前已选 0,学生 A 和学生 B 同时发起了选课请求。两个请求都先查到了 count=0,都判定“没满”,然后都执行插入,最后课程里就有了两个学生。这就是经典的“超卖”问题,本质上是检查和更新之间没有做原子保护。
4.2 用乐观锁改造选课流程
解决并发选课超容量,课程设计阶段我推荐用乐观锁,也就是把“检查容量”和“占坑”合并成一条 SQL,让数据库来做这一步的原子判断:
UPDATE t_course SET selected_count = selected_count + 1 WHERE cid = ? AND selected_count < capacity;这条 UPDATE 会返回一个受影响行数。如果返回 1,说明当前课程还有余量,占坑成功,继续插入选课记录;如果返回 0,说明课程刚好满了,直接回滚并提示“课程已满”。
我把完整的选课事务代码放在CourseService里:
public String selectCourse(String sid, String cid) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); ScDao scDao = new ScDao(); CourseDao courseDao = new CourseDao(); // 第一步:检查是否重复选课 if (scDao.exists(conn, sid, cid)) { conn.rollback(); return "请勿重复选课"; } // 第二步:乐观锁占坑,容量不足返回0 int rows = courseDao.decreaseStock(conn, cid); if (rows == 0) { conn.rollback(); return "课程容量已满"; } // 第三步:插入选课记录 scDao.insert(conn, sid, cid); conn.commit(); return "选课成功"; } catch (Exception e) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return "选课失败,请稍后重试"; } finally { DBUtil.close(conn); } }DecreaseStock 对应的 DAO 方法就是执行上面那条 UPDATE SQL。这种做法的巧妙之处在于,并发请求同时到达时,selected_count < capacity这个条件由数据库的行锁保证只会有一个请求成功,不用你写一堆synchronized或者分布式锁。课程设计阶段能讲清楚这一点,已经超过绝大多数人了。
4.3 退课和成绩录入的注意事项
退课是选课的逆操作,同样要走事务。它的逻辑是:先删除t_sc里的选课记录,再把t_course里的selected_count减一。删除和自减必须放同一个事务,否则会出现“课表没了但容量没还”的数据不一致。这里还有一个小细节:退课前要判断成绩是否为 NULL,如果教师已经录了成绩,一般不允许再退课,否则成绩就凭空消失了。
成绩录入的权限在教师端。教师只能对自己负责的课程录入成绩,这里做了一个权限校验:先把教师 ID 和课程 ID 关联起来,确认这堂课属于该教师,才允许执行UPDATE t_sc SET score = ? WHERE sid = ? AND cid = ?。录入时可以加上正则校验,成绩范围 0 到 100,超出直接拒绝。这种小校验不复杂,但能让系统显得严谨很多。
5. 上线前必踩的三个坑:从报错信息反推问题根因
5.1 重复选课:唯一约束报错与应用层提示的对接
第一次演示重复选课时,很多人会看到类似这样的报错:
Duplicate entry '20230001-CS101' for key 'PRIMARY'这是好事,因为说明联合主键真的在起作用。但这个报错直接抛到页面上很难看,用户也看不懂。正确做法是在 Service 层捕获数据库异常,识别主键冲突的类型,然后转换成友好的业务提示。我的做法是在catch (Exception e)里判断异常信息是否包含Duplicate entry,包含则返回“该课程已在你的课表中,请勿重复选择”。你可以根据自己的数据库驱动类型做处理,但核心思路是一致的:数据库约束负责兜底,应用层负责给出合适的人话。
5.2 删除课程被外键拦截:不能删时要怎么给用户解释
另一条高频报错长这样:
Cannot delete or update a parent row: a foreign key constraint fails出现这个报错,说明你要删的课程在t_sc里已经有选课记录了。物理删除被外键成功拦住,数据完整性保住了,但页面直接五百万报错,用户体验很差。我对这个问题的处理方案是“三级策略”。第一步,删除前先查t_sc有没有关联记录,有则提示“该课程已有学生选课,不能直接删除,可以调整容量或对课程做停用处理”。第二步,如果只是想让课程不在选课列表上出现,就把课程表加一个status字段,停用的课程对普通学生隐藏。第三步,如果确实要物理删除,同时也想清空选课记录,可以在业务代码里先删t_sc再删t_course,并放在同一个事务里。课程设计阶段,前两种方案已经足够。
5.3 中文乱码:从URL、请求到数据库的全链路梳理
乱码问题我自己写这套系统时也踩过,但乱码的根因其实只有一个:数据在传输和存储的每一个环节,编码必须统一。排查的时候按这个链路走基本能定位:检查 JSP 页面是否设置了pageEncoding="UTF-8";检查请求过滤器是否设置了request.setCharacterEncoding("UTF-8");检查响应是否设置了response.setContentType("text/html;charset=UTF-8");检查数据库连接 URL 是否带了characterEncoding=UTF-8;检查数据库和表的字符集是否为utf8mb4。
这一个链条上只要有一个环节不是 UTF-8,就会出现“页面显示正常、存进数据库就乱”或者“数据库正常、页面显示乱”的怪象。我见过很多同学只改了一个地方就以为解决了,结果换台电脑部署又乱了。所以我的建议是把五个环节全部固定成统一编码,做成规范,而不是等出问题再逐个试。
另外还有一个小技巧:如果使用 GET 请求传递中文参数,Tomcat 默认对 URL 参数的编码是 ISO-8859-1,需要在连接器配置里把URIEncoding="UTF-8"加上。否则你用 GET 提交中文时,过滤器管不到 URL 里那一段。
5.4 演示和答辩准备:几个能让你讲得更从容的小细节
最后我忍不住想多说几句答辩和演示的把控问题,因为这项目我在实际带人的过程中太有体会了。
演示之前,一定要准备一份量级合适的数据脚本。我一般会生成 5 个教师、30 个学生、15 门课程的数据,容量有大有小,确保演示时能看到“有课程已满、有课程还剩很多”的对比效果。选几门课给固定学生先选好,这样登录进去就能看到已有课表和成绩,不用现场现点现等,稳定性更高。
答辩问得最多的几个问题基本是固定的:为什么选课表要用联合主键?并发情况下怎么防止超容量选课?为什么selected_count不直接由 Count 计算?删除课程时被外键拦住了怎么办?成绩字段放在选课表里的原因是什么?这些我在上面的章节里已经全部覆盖。你能用自己的话说清楚这些,并且现场把代码指给老师看,这个项目的分数大概率差不了。
我自己的体会是,这类课程设计最大的价值不在“做出来”,而在“每一个按钮背后都讲得出道理”。数据库约束、事务回滚、并发控制,这些东西在面试和后续项目里全部会反复用到,你现在亲手把它们跑通一遍,后面比其他人省下的时间远不止十倍。如果你按这个思路把系统做出来,遇到具体报错不知道怎么改,欢迎带着日志来聊,排查这一类问题比排雷有意思多了。
本文还有配套的精品资源,点击获取