JSP通识教育选课系统毕业设计:业务逻辑与并发控制实战解析

发布时间:2026/10/21 14:02:30
JSP通识教育选课系统毕业设计:业务逻辑与并发控制实战解析
又到了毕业设计选题最热闹的时候。几乎每周都会有人来问我同一个问题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 9JDK 1.8Maven 构建 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 一条典型请求的完整传递路径用学生点击选课这一个动作可以走通全链路页面用 form 或者链接提交到course?actionselectCoursecourseId5Tomcat 根据注解把请求交给 CourseServlet 处理CourseServlet 从 Session 里取当前学生从参数里拿课程 id构造参数传给 CourseService.selectCourse(studentId, courseId)Service 在同一个事务里完成冲突检测、容量扣减、写选课记录执行成功后Servlet 把提示信息放到 request 里forward 回课程列表页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 持有当前线程的 Connectiondao 层从 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. 时间冲突检测已选课程的时间列表与当前课程对比 ListCourse 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 contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %Servlet 处理 POST 请求前执行request.setCharacterEncoding(UTF-8)数据库连接 URL 带上useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaiMySQL 表自己和库的字符集都用 utf8mb4如果你用 Tomcat 8 以上GET 请求的 URI 默认按 UTF-8 解码问题不大但如果部署环境是 Tomcat 7 或某些定制环境还要在 server.xml 的 Connector 上加URIEncodingUTF-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 脚本每演示一轮就执行一次。这样面对评委的临时提问你随时可以再演示一遍不会因为数据被改乱了而手忙脚乱。我见过太多人功能都做出来了最后栽在演示数据不够配合上。这个脚本花十分钟就能写好收益却很大。