☰
JSP通识教育选课系统毕业设计:业务逻辑与并发控制实战解析
2026/10/21 14:02:14 网站建设 项目流程

又到了毕业设计选题最热闹的时候。几乎每周都会有人来问我同一个问题:JSP 这种技术是不是太老,做jsp通识教育选课系统这种题目会不会被评委嫌弃。我的看法很直接:一个毕业设计拿不拿得到高分,从来不取决于框架有多新,而取决于你有没有把自己系统里的业务逻辑想明白、做到位。选课系统恰恰是一个非常典型的"业务逻辑大于页面效果"的题目,它涉及多角色权限、选课冲突检测、学分控制、并发名额处理,这些内容放到任何一门课程里都不过时。

这篇内容我不打算写"系统说明书",而是把我带一个完整项目从零到答辩的全过程拆开讲。主要面向两类人:一类是已经选定或正在纠结要不要选 jsp通识教育选课系统 这个题目的同学,另一类是手里有类似题目、想把代码写得更有说服力的读者。下面所有的表结构、代码片段、排查思路,都可以直接照着改,不需要多强的框架背景。

1. 选课系统的"通识教育"底色:为什么这个题目值得认真做

1.1 通识教育场景比普通课表管理复杂在哪

很多同学拿到题目第一反应就是"这是普通管理系统吧,无非是课程表的增删改查"。真做下去你会发现,通识教育选课最大的特点是:面向全校各年级、各专业的学生开放,课程种类多、上课时间分散、学分规则不统一。这跟专业课排课完全不是一回事。专业排课往往是教务处统一安排好时间、班级固定,学生没有选择权;而通识教育课是学生主动去选、去抢,系统必须同时保证"时间的唯一性"和"名额的有限性"。

举个例子,某个学生可能已经选了周二的"3-4节"音乐鉴赏,如果又去选同一时段的"跨文化沟通",系统必须拦截;一门限选 40 人的课,第 41 个人提交选课时也要被拦下来。这两个逻辑看起来简单,实际上很多人的实现是一团浆糊:有人只在页面上用 JavaScript 判断,刷新一次就能绕过;有人只查了一次课程表,高并发时几十个人同时选最后一门课,结果给超过容量的人都写了选课记录。这些问题的本质都在于没有把业务规则放到后端去校验,也没有把它放进数据库层面。

1.2 三类角色与一条完整业务闭环

选课系统通常不会少于三类角色:学生、教师、管理员。学生的操作围绕"选课、退课、查看课表、查看成绩"展开;教师需要"申请开课、查看选课学生名单、录入成绩";管理员则负责"账号管理、课程审批与开放、统计分析"。千万别小看这三类角色,它们天然就把系统的权限模块、业务模块、统计模块串起来,正好对应一个完整的管理系统骨架。

而且"通识教育"四个字还可以帮你在展示时加分:你可以把课程按类别划分,比如人文、科学、艺术、体育等;把学生的选课学分规则做成可配置的,比如每个类别最低修读学分是多少。这些工作量大不大?不大,加一个类别字段、写一个统计查询而已,但在答辩的时候,它展现的是你对"需求"的理解,而不是对"代码"的搬运。

1.3 功能范围怎么裁剪才不会被题目拖垮

我的建议是先把核心闭环做透,再谈扩展。核心闭环指:学生能登录、能看到开放课程、能成功选课、能退课;教师能开课、能看到名单、能录分;管理员能审核课程、能看统计。这个闭环做完,系统已经可以演示了。很多同学一上来就想做"智能推荐""自动排课""班级管理",结果数据库越建越复杂,代码还没写完就到了截止日期。

扩展功能可以做成"增量":比如想加推荐,就基于学生已选课程的类别做一个简单排序;想加错峰选课,就给管理员一个按年级分批开放的时间配置。这些都是在核心闭环之后顺手加的内容。不要在一开始就铺开。

2. 技术路线梳理:JSP + Servlet + MySQL 的经典组合怎么搭出层次

2.1 技术栈清单

这个题目几乎就是为 JSP + Servlet + MySQL 准备的,但"用"和"用好"是两码事。我的技术栈固定如下:

  • 页面渲染:JSP + JSTL + EL 表达式,避免在页面里嵌大量 Java 代码块
  • 控制层:Servlet 3.x,通过 @WebServlet 注解配置,不用在 web.xml 里写一堆映射
  • 业务层:Service 类,负责事务边界和业务规则
  • 数据层:JDBC 封装,用 DBUtils 的 QueryRunner 简化结果集转换
  • 数据库:MySQL 8.0,字符集 utf8mb4
  • 服务器:Tomcat 9,JDK 1.8,Maven 构建 war 包

为什么不用 Spring Boot?这个问题基本每个评委都会问。我的建议是:如果题目明确规定 JSP,那就老老实实用 Servlet 当控制层,不要硬套 Spring MVC,因为一旦用了大框架,评委就会往 IoC、AOP、自动配置这些方向追问,反而把你架在火上烤。而 JSP + Servlet + JDBC 这套原生组合,你能把一条请求从 JSP 到 Servlet、Service、Dao 再到数据库的路径讲得清清楚楚,这是很大的答辩优势。

2.2 工程结构怎么分层

用 Maven 建一个 war 项目,结构大致是:

src/main/java ├─ com.example.filter // 登录拦截、编码过滤器 ├─ com.example.servlet // 所有请求入口 ├─ com.example.service // 业务逻辑与事务管理 ├─ com.example.dao // 数据访问层 ├─ com.example.entity // 实体类 └─ com.example.util // JdbcUtils、时间解析工具等 src/main/webapp ├─ login.jsp ├─ static/ │ ├─ css/ │ └─ js/ └─ WEB-INF/jsp/ // 需要登录后才能访问的页面

为什么要手动分这么多层?因为 JSP 的实际能力有限,页面里写 Java 代码虽然方便,但一旦业务规则复杂,页面会变成一坨只能看不能维护的代码。分层之后,选课逻辑写在 service 里,JSP 只负责用 JSTL 把数据渲染出来。这并不会增加代码量,只是把代码放对位置。

2.3 一条典型请求的完整传递路径

用"学生点击选课"这一个动作,可以走通全链路:

  1. 页面用 form 或者链接提交到course?action=selectCourse&courseId=5
  2. Tomcat 根据注解把请求交给 CourseServlet 处理
  3. CourseServlet 从 Session 里取当前学生,从参数里拿课程 id
  4. 构造参数传给 CourseService.selectCourse(studentId, courseId)
  5. Service 在同一个事务里完成冲突检测、容量扣减、写选课记录
  6. 执行成功后,Servlet 把提示信息放到 request 里,forward 回课程列表页
  7. JSP 用 EL 表达式和 JSTL 标签把列表和提示信息渲染出来

这七步你对着代码能说出来,整个项目的"层次感"就出来了。这也是为什么我坚持用原生 Servlet 的原因——框架里封装的 @RequestMapping 背后干的事,本质也是这套流程。

3. 数据库建模:把选课约束下沉到表结构与事务

3.1 四张核心表怎么建

我不建议把学生、教师、管理员都塞进一张 user 表然后用 role 字段区分,虽然那样表数量少,但角色差异化字段多了以后非常别扭。毕业设计更看重清晰,我建议分表。核心表有:student、teacher、admin、course、selection。

-- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, sno VARCHAR(20) NOT NULL UNIQUE, sname VARCHAR(30) NOT NULL, password VARCHAR(60) NOT NULL, major VARCHAR(50), grade VARCHAR(10), credit_limit INT DEFAULT 30, total_credits INT DEFAULT 0 ); -- 教师表 CREATE TABLE teacher ( id INT PRIMARY KEY AUTO_INCREMENT, tno VARCHAR(20) NOT NULL UNIQUE, tname VARCHAR(30) NOT NULL, password VARCHAR(60) NOT NULL, dept VARCHAR(50) ); -- 管理员表 CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE, password VARCHAR(60) NOT NULL ); -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(50) NOT NULL, teacher_id INT NOT NULL, category VARCHAR(20), credit DECIMAL(3,1) DEFAULT 2.0, capacity INT DEFAULT 60, selected_count INT DEFAULT 0, teach_time VARCHAR(100), classroom VARCHAR(30), semester VARCHAR(20), status TINYINT DEFAULT 0 COMMENT '0-待开放,1-开放中,2-已结束' ); -- 选课记录表 CREATE TABLE selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, score DECIMAL(5,2) DEFAULT NULL, UNIQUE KEY uk_stu_course (student_id, course_id) );

这里有几个设计点值得展开。

第一,course 表里冗余了selected_count,而不是每次用SELECT COUNT(*)去统计。原因很简单:列表页要高频展示"已选人数/容量",每次 count 连表统计,数据量一大就慢,而且在并发抢课时,两次查询之间数据可能已经变了,用它做容量判断非常不可靠。冗余字段配合条件更新才是稳定方案。

第二,selection 表加唯一索引(student_id, course_id),这是防止重复选课的兜底。就算代码里漏了校验,数据库也会报 Duplicate entry 错误,事务随即回滚,数据不会脏。

第三,课程状态用 status 字段控制。学生只能看到 status = 1 的课程,这样管理员可以在后台控制选课窗口的开关,比直接把课程删除更合理,也保留了历史数据。

3.2 事务边界应该画在哪

选课这个动作不是一个 INSERT 能完事的。它至少包含:查课程 → 校验冲突 → 扣减名额 → 插入选课记录 → 更新学生学分。任何一步失败,前面成功的步骤都要撤销。比如你已经更新了课程容量,但插入选课记录时因为唯一索引报错,容量减出去了但记录没写进去,学生啥也没选上,名额却少了一个——这种脏数据在答辩时被看出来会很尴尬。

所以整个选课流程必须包在一个事务里。用 JDBC 的话就是:conn.setAutoCommit(false),所有 dao 操作都走同一个 conn,全部成功才 commit,任何异常直接 rollback。千万别在 service 里每个 dao 方法自己去拿连接,那样连接对不上,事务根本串不起来。

我习惯用一个 ThreadLocal 持有当前线程的 Connection,dao 层从 ThreadLocal 里取,这样 service 和 dao 之间天然共享同一个连接。代码量不大,但对事务一致性帮助很大。

3.3 容量控制的具体写法

容量控制最推荐的就是条件更新:

UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity;

这一行 SQL 很关键。如果返回的影响行数是 0,说明课程已满,就不要再写选课记录,直接抛出"课程已满"的提示。这个方案在单服务器场景下是够用的,而且比SELECT ... FOR UPDATE更轻量,也好跟评委解释:数据库的行级锁帮我们在更新瞬间完成了判断,两个并发请求同时执行,只有一个能更新成功。

如果你想让演示更有说服力,可以写一个多线程测试类,模拟 20 个学生对容量为 1 的同一门课发起选课,最后观察只有一个人成功。这个实验不复杂,但答辩时很能说明问题。

4. 核心代码落地:从登录到选课冲突检测的完整链路

4.1 登录、会话与权限拦截

登录模块很多人觉得简单,但做毕业设计时这个模块承担着权限演示的功能,不能马虎。登录成功后,把用户对象和角色类型塞进 Session。我的做法是在 Session 里放一个 loginUser 对象,它至少包含 userId、userType、userName 三个字段,userType 用来做角色判断。

Filter 的用处就体现出来了。我写一个 LoginFilter,拦截所有除 login.jsp、loginServlet 和静态资源外的请求。核心逻辑:

public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); String ctx = request.getContextPath(); // 公开路径放行 if (uri.startsWith(ctx + "/login.jsp") || uri.startsWith(ctx + "/loginServlet") || uri.startsWith(ctx + "/static/")) { chain.doFilter(req, resp); return; } // 登录检查 if (request.getSession().getAttribute("loginUser") == null) { response.sendRedirect(ctx + "/login.jsp"); return; } chain.doFilter(req, resp); } }

这里有一点很重要:JSP 页面如果放在WEB-INF/jsp下,浏览器不能直接访问,只能由 Servlet forward 过去,这样权限控制就不会被绕过。有些人把页面都放在 webapp 根目录下,结果用户直接访问某个 .jsp 也能看到页面,那 Filter 就白写了。

4.2 时间冲突检测的解析与判断

课程时间我建议用自定义字符串存,格式类似1|3|4;3|5|6,分号隔开多个时间段,竖线分隔"星期几|开始节次|结束节次"。字段名就叫 teach_time。为什么不用 datetime?因为课程是每周重复的,不是某一天的时间点,而且跨周的课程需要判断的是周一、周二这种周内重复关系,存成一个标准时间戳反而难处理。

判断冲突时,把当前课程的每个时间段拆成 TimeSlot{day, startSection, endSection},再把这个学生已选全部课程的时间段都拆出来,两两比较。核心判断只有一句:

public class TimeSlot { int day; // 1-7 表示周一到周日 int startSection; // 开始节次 int endSection; // 结束节次 } // 两个时间段是否重叠 public static boolean isOverlap(TimeSlot a, TimeSlot b) { return a.day == b.day && a.startSection <= b.endSection && b.startSection <= a.endSection; }

这和"两段区间是否重叠"是同一个数学问题。用生活化的话说:你开一个 3-4 节的会,就不能再开另一个 3-4 节或 2-3 节的会,因为区间一旦相交就冲突。实际业务中还会有"第 4 节下课赶不到第 5 节教室"这类场景,需要额外配置是否允许相邻节次连选,这块可以作为扩展点,但核心判断就是上面的重叠公式。

4.3 选课方法的关键代码

选课方法的骨架大概是这样的,注意我把事务、冲突检测、容量控制都放进同一个方法里:

public boolean selectCourse(Integer studentId, Integer courseId) throws Exception { Connection conn = JdbcUtils.getConnection(); try { conn.setAutoCommit(false); // 1. 查课程,判断状态 Course course = courseDao.findById(conn, courseId); if (course == null || course.getStatus() != 1) { throw new BizException("课程不存在或未开放"); } // 2. 查是否已选 if (selectionDao.exists(conn, studentId, courseId)) { throw new BizException("你已选过这门课"); } // 3. 时间冲突检测:已选课程的时间列表与当前课程对比 List<Course> selectedCourses = courseDao.findSelectedByStudent(conn, studentId); for (Course selected : selectedCourses) { if (TimeUtil.isTimeConflict(course.getTeachTime(), selected.getTeachTime())) { throw new BizException("与已选课程时间冲突:" + selected.getCourseName()); } } // 4. 学分上限校验 Student student = studentDao.findById(conn, studentId); if (student.getTotalCredits() + course.getCredit() > student.getCreditLimit()) { throw new BizException("超过学分上限"); } // 5. 容量控制:条件更新,影响行数为 0 说明已满 int rows = courseDao.increaseSelectedCount(conn, courseId); if (rows == 0) { throw new BizException("课程已选满"); } // 6. 插入选课记录,更新学生总学分 selectionDao.insert(conn, studentId, courseId); studentDao.addTotalCredits(conn, studentId, course.getCredit()); conn.commit(); return true; } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); JdbcUtils.close(conn); } }

这段代码你要能在答辩时一笔一划讲清楚,比背十篇八股文都有用。尤其是第 5 步的条件更新和第 1 步的状态判断,是防止"超选""乱选"的关键。

4.4 退课与名单管理

退课的逻辑刚好和选课相反:删除 selection 记录、课程 selected_count 减 1、学生总学分减掉对应学分。这里同样要包事务。退课操作在演示中很常见,评委可能会要求"把名额释放出来再选一次",所以一定要确保退课不影响其他已选课程,也不影响课程状态。

教师端和管理端相对常规:教师按自己的 teacher_id 查课程,再按 course_id 查选课学生名单;管理员在课程列表里把 status 从 0 改成 1 开放选课。统计页面用一个 GROUP BY 就能把每个课程的选课人数列出来,再联合 student 表按学院、年级做个分组,就是报表了。

5. 实测中真正会翻车的四个细节

5.1 中文乱码:根源不在页面

中文乱码在 JSP 项目里几乎是必踩的坑,而且症状五花八门:有的是插入数据库变成问号,有的是页面显示乱码。根源往往不在页面本身,而在三层之间字符集不一致。

我的排查顺序是固定的:

  • JSP 文件头写<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>
  • Servlet 处理 POST 请求前执行request.setCharacterEncoding("UTF-8")
  • 数据库连接 URL 带上useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
  • MySQL 表自己和库的字符集都用 utf8mb4

如果你用 Tomcat 8 以上,GET 请求的 URI 默认按 UTF-8 解码,问题不大;但如果部署环境是 Tomcat 7 或某些定制环境,还要在 server.xml 的 Connector 上加URIEncoding="UTF-8"。这个坑我见过不少次,建议提前确认一下。

5.2 事务没按预期回滚

很多人写了 try-catch 和 rollback,但测试时发现数据还是脏了。最常见的原因有两个。

第一,setAutoCommit(false)放错了位置。如果放在 try 里面,一旦前面的代码抛出异常,根本执行不到,连接还是自动提交模式。正确做法是放在try外面,或者在连接获取后立刻设置。

第二,Service 里每个 Dao 方法又自己拿了连接。比如 courseDao.update 内部JdbcUtils.getConnection(),selectionDao.insert 又拿一条新连接,这两个操作根本不在一个数据库会话里,外层事务自然管不住它们。解决办法就是我前面说的:用 ThreadLocal 传递同一个连接,Dao 层通过JdbcUtils.getConnection()拿到的始终是当前线程正在使用的那条。

验证事务是否生效有个笨但有效的办法:在插入选课记录后、commit 前故意抛一个 RuntimeException,然后查数据库,确认容量和选课记录都没有变化。能通过这个测试,说明事务边界是真的画对了。

5.3 时间比较和数据格式的问题

课程时间用自定义字符串存储后,最容易翻车的地方是解析函数本身。比如"1|3|4"中间用了全角竖线,或者前导空格没 trim,都会导致 parse 报错。我的建议是固定一种输入格式,在录入时就做正则校验,不要让脏数据进库。

另一个容易忽略的问题是节次区间。有的课程只有一节课,比如"1|7|7",冲突判断也能正常处理,因为 startSection 和 endSection 相等,重叠公式依然成立。但如果你把格式设计成"星期几|开始节次|持续节数",冲突判断就要多加一次换算,反而复杂。所以我坚持用"开始|结束",别绕弯子。

5.4 部署路径和版本问题

Maven 打 war 包部署到 Tomcat 时,默认的上下文路径可能是项目名_war_exploded这种一长串,页面里所有绝对路径都会出问题。我习惯在 IDEA 的 Deployment 设置里把 Application context 改成/selection,并且代码里统一用${pageContext.request.contextPath}拼接链接,这样不管部署名怎么变都不会 404。

版本上最容易踩的坑是 JDK 和 Tomcat 的配合。JDK 8 配 Tomcat 9 很稳;如果你不小心用了 Tomcat 10,就要注意它把javax.servlet换成了jakarta.servlet,旧项目直接跑不起来。MySQL 驱动也要用com.mysql.cj.jdbc.Driver,老版本的com.mysql.jdbc.Driver在 MySQL 8 下会报警告甚至报错。

6. 答辩前值得打磨的演示思路与加分点

6.1 准备一套有故事的演示数据

这是很多同学忽略的部分。演示不是把系统打开随便点两下,而是要讲一个让别人能记住的故事。我会准备一组这样的数据:

  • 学生账号 A:学分已经修到接近上限,方便演示"超过学分上限"提示
  • 课程 X:容量 40,已选 39,方便演示"满员"提示
  • 课程 Y 和课程 Z:同一时间段,方便演示时间冲突
  • 教师账号 B:名下有两门课程,一门状态待开放,一门开放中

演示顺序顺着业务走:先正常选课成功,再选同一时间冲突课失败,再选满员课失败,然后退掉一门课,回到课程列表看到"已选人数 39→40 但当前学生可以再选"的过程。最后切教师账号看名单,切管理员账号看统计。这一套流程走下来,评委对系统的业务完整性就有了直观印象。

6.2 别让评委觉得你只会"能跑"

演示过程里一定要主动讲"为什么这样设计",而不是等评委追问。我会重点讲三个点:

  • 为什么用冗余字段 selected_count:因为列表页高频展示 + 容量判断需要原子性
  • 为什么用唯一索引:多条路同时进来也不会重复选课
  • 为什么事务要包住整个流程:保证容量、选课记录、学分三者一致

讲的时候别背概念,就对着代码讲。评委看到你对自己的代码每一行都心里有数,分数自然不会低。

6.3 如果还有时间,可以做的小实验

最推荐的加分项是并发抢课小实验。不用引入任何压测工具,就在单元测试里写一个多线程脚本:一门课容量 1,同时 20 个学生线程去选,只有 1 个成功,其余都得到"课程已选满"。这个实验能直接证明系统的容量控制是可靠的,比任何口头说明都有说服力。

另外一个轻松的加分项是把通识教育课程的"类别学分规则"做成页面可配置。比如管理员能设置"人文类至少修 4 学分、艺术类至少修 2 学分",学生选课时实时检测并提示。这个功能本质上是一个配置表 + 一次统计查询,工作量不大,但非常贴题,能体现你对"通识教育"业务的理解。

最后再分享一个小技巧:答辩前把数据库里的演示数据重置到"初始状态"写成一个 SQL 脚本,每演示一轮就执行一次。这样面对评委的临时提问,你随时可以"再演示一遍",不会因为数据被改乱了而手忙脚乱。我见过太多人功能都做出来了,最后栽在演示数据不够配合上。这个脚本花十分钟就能写好,收益却很大。

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

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

立即咨询