Java Web学生选课系统实战:Servlet+JSP三层架构与事务控制
发布时间:2026/9/28 15:35:25来源:尧图网络
简介本资源是一套完整的Java Web技术栈毕业设计项目面向计算机专业本科生及Java初学者解决高校学生选课业务场景下的系统开发实践需求。压缩包共205个文件7.23MB涵盖57个核心Java业务逻辑类、36个JSP前端页面、11个SQL数据库脚本、56张功能截图PNG及11个XML配置文件辅以CSS样式、JS交互脚本和IDEA工程配置文件完整呈现MVC分层结构与前后端协同流程。已有2244人学习下载适合课程设计、毕设参考或Web开发入门实战。读者可直接导入IntelliJ IDEA配合JDK 1.8、Tomcat 9.0.14、MySQL 5.7运行调试获取含论文文档.docx、可部署源码、建库脚本及全链路界面截图的一站式交付成果显著降低环境搭建与代码理解门槛。1. 为什么一个“学生选课管理系统”能卡住90%的Java Web初学者不是功能少而是三层结构没对齐、事务边界没划清、前端跳转链路像迷宫你手上有份.zip文件解压后看到src/main/java/com/example/xxx、一堆 JSP 页面、web.xml和pom.xml——这确实是标准的 Java Web 项目骨架。但真正让毕业设计答辩翻车、让导师皱眉、让部署到 Tomcat 后登录页 404 的从来不是“没写完”而是Servlet 层没兜住请求、Service 层事务没生效、DAO 层 SQL 拼接漏了参数校验。这个系统表面是 CRUD增删改查内里是 Servlet 生命周期、JDBC 连接池管理、JSP EL 表达式作用域、HTTP 重定向与转发的隐式状态传递——任何一个环节错位就会出现“点了提交按钮没反应”“退课成功但数据库没更新”“同一门课被两个学生同时选上”这类玄学问题。它适合刚学完《Java Web 开发技术》前八章、能写单个 Servlet 但没串过完整流程的人也适合想用真实业务场景验证 Spring MVC 基础配置的老手——因为所有坑都藏在“最基础”的地方比如request.getRequestDispatcher().forward()和response.sendRedirect()混用导致 session 丢失比如 MySQLINSERT INTO ... SELECT语句没加事务锁引发并发超选。这不是玩具项目它是 Java Web 工程能力的照妖镜。2. 从零搭起可运行骨架用最简 ServletJSP 组合跑通登录-选课-退课闭环2.1 项目结构必须守住的三条线包命名规范、资源路径约定、web.xml 映射逻辑Java Web 项目不是把代码丢进src就能跑。我一般会先建好四层包结构com.example.sis.controller只放LoginServlet、SelectCourseServlet等纯请求分发类不处理业务逻辑不操作数据库com.example.sis.service放StudentService、CourseService这里开始加Transactional若用 Spring或手动conn.setAutoCommit(false)com.example.sis.daoStudentDao、CourseDao每个方法只做一件事insertStudent()或findCourseById()SQL 字符串必须用?占位符严禁字符串拼接com.example.sis.entityPOJO 类字段名严格对应数据库列名如student_id→private Long studentId并配toString()和equals()。提示JSP 页面必须放在WebContent/非src/main/webapp/下且路径要和web.xml中servlet-mapping的url-pattern对齐。例如servlet servlet-nameLoginServlet/servlet-name servlet-classcom.example.sis.controller.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping那么访问地址就是http://localhost:8080/sis/login而不是/sis/LoginServlet。很多 404 就栽在这儿。2.2 登录功能最小可运行代码Servlet 接参、Service 校验、JSP 渲染三步拆解先写LoginServlet.java它只做三件事取参、调 Service、跳转。绝不写 SQL绝不写 HTML// src/main/java/com/example/sis/controller/LoginServlet.java WebServlet(/login) public class LoginServlet extends HttpServlet { private StudentService studentService new StudentServiceImpl(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 取参注意JSP 表单 methodpostnameusername String username request.getParameter(username); String password request.getParameter(password); // 2. 调 Service 校验返回 null 表示失败 Student student studentService.login(username, password); if (student ! null) { // 登录成功存入 session跳转主页 request.getSession().setAttribute(currentUser, student); response.sendRedirect(request.getContextPath() /main.jsp); } else { // 失败回传错误信息到 login.jsp request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } }关键点说明response.sendRedirect()是客户端重定向URL 变成/main.jsp原 request 作用域数据丢失所以错误信息要用request.setAttribute()forward()request.getSession().setAttribute()存的是整个Student对象后续页面用${sessionScope.currentUser.studentName}就能取studentService.login()方法内部必须用PreparedStatement例如String sql SELECT * FROM student WHERE username ? AND password ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, username); // 防 SQL 注入 ps.setString(2, password);2.3 数据库建表脚本与连接池配置HikariCP 比 C3P0 更稳但初始化参数不能照抄学生选课系统核心表就三张字段宁少勿多先跑通再扩展-- MySQL 5.7字符集 utf8mb4 CREATE TABLE student ( student_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(20) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, -- 存 BCrypt 加密后的密文 real_name VARCHAR(10), major VARCHAR(20) ); CREATE TABLE course ( course_id BIGINT PRIMARY KEY AUTO_INCREMENT, course_code VARCHAR(10) UNIQUE NOT NULL, course_name VARCHAR(50) NOT NULL, credit INT DEFAULT 2, max_capacity INT DEFAULT 100 ); CREATE TABLE selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE );连接池用 HikariCP比老版 C3P0 更快更省资源db.properties放src/main/resources/下# db.properties jdbc.urljdbc:mysql://localhost:3306/sis_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password123456 jdbc.driver-class-namecom.mysql.cj.jdbc.Driver # HikariCP 关键参数别直接抄网上的 20 个参数 hikari.maximum-pool-size10 hikari.minimum-idle2 hikari.connection-timeout30000 hikari.idle-timeout600000 hikari.max-lifetime1800000 hikari.connection-test-querySELECT 1注意connection-test-query必须设为SELECT 1否则 HikariCP 启动时会报Connection is not availablemax-lifetime设为 30 分钟1800000ms避免 MySQL 默认 8 小时 wait_timeout 导致连接失效。3. 选课与退课的事务控制为什么“选课成功但人数没1”因为你没锁住 course 行3.1 选课逻辑的原子性陷阱检查余量 插入选课记录必须在一个事务里典型错误写法// ❌ 错误两次独立查询插入中间可能被其他请求插队 int currentCount courseDao.getSelectCount(courseId); // 查当前已选人数 if (currentCount course.getMaxCapacity()) { selectionDao.insert(studentId, courseId); // 插入选课记录 courseDao.updateSelectCount(courseId, currentCount 1); // 更新余量 }问题在于A 用户查到余量99B 用户同时查也是 99两人先后插入结果余量变成 101——超选。正确做法是用数据库行锁事务// ✅ 正确在 Service 层用事务包裹DAO 层用 SELECT ... FOR UPDATE Transactional public boolean selectCourse(Long studentId, Long courseId) { // 1. 加锁查询课程锁住 course 行其他事务无法修改 Course course courseDao.selectForUpdate(courseId); if (course null) return false; // 2. 检查余量此时 course 数据是最新且被锁住的 int currentCount selectionDao.getCountByCourseId(courseId); if (currentCount course.getMaxCapacity()) { return false; // 余量不足 } // 3. 插入选课记录自动触发 INSERT ... ON DUPLICATE KEY IGNORE 防重复 boolean inserted selectionDao.insert(studentId, courseId); if (!inserted) return false; // 唯一键冲突已选过 // 4. 更新课程余量此时 course 行仍被锁安全 courseDao.updateRemainCapacity(courseId, course.getMaxCapacity() - currentCount - 1); return true; }关键点Transactional注解必须在 Service 方法上且该类由 Spring 容器管理context:component-scan扫描到courseDao.selectForUpdate()对应 SQLSELECT * FROM course WHERE course_id ? FOR UPDATEselectionDao.insert()的 SQL 必须用INSERT INTO selection (student_id, course_id) VALUES (?, ?)不要用INSERT IGNORE或REPLACE INTO否则无法感知唯一键冲突。3.2 退课的级联删除与状态同步别只删 selection 表student 和 course 的关联也要清理退课看似简单但容易漏两件事selection表删了但course.remain_capacity没 1学生退课后student表的last_select_time没更新用于统计活跃度。正确退课 Service 方法Transactional public boolean dropCourse(Long studentId, Long courseId) { // 1. 先查这条选课记录是否存在防重复退课 Selection selection selectionDao.findByStudentAndCourse(studentId, courseId); if (selection null) return false; // 2. 删除选课记录触发外键 ON DELETE CASCADE自动清理关联 selectionDao.delete(selection.getId()); // 3. 更新课程余量注意这里用 course_id 直接更新不依赖 selection courseDao.increaseRemainCapacity(courseId); // 4. 更新学生最后操作时间可选但建议加 studentDao.updateLastActionTime(studentId, new Date()); return true; }对应的 DAO 层 SQL-- selectionDao.delete() DELETE FROM selection WHERE id ? -- courseDao.increaseRemainCapacity() UPDATE course SET remain_capacity remain_capacity 1 WHERE course_id ?提示ON DELETE CASCADE在建表时已声明见 2.3 节FOREIGN KEY ... ON DELETE CASCADE所以删selection时student和course表不会被连带删掉只是保证外键约束有效。4. 前端交互避坑指南JSP EL 表达式失效、表单重复提交、中文乱码三连击4.1 JSP 页面取不到 session 数据检查 pageEncoding、contentType、request 字符集三处常见现象登录后main.jsp里${sessionScope.currentUser.realName}显示为空但后台System.out.println(session.getAttribute(currentUser))能打印出对象。原因往往是字符集没对齐% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8% !DOCTYPE html html head meta charsetUTF-8 !-- 必须有 -- title主页面/title /head body !-- ✅ 正确取值 -- p欢迎 ${sessionScope.currentUser.realName}/p !-- ❌ 错误如果 pageEncoding 不是 UTF-8中文会变 ? -- % session.getAttribute(currentUser).getRealName() % /body /html三个地方必须统一为UTF-8JSP 文件本身保存为 UTF-8 编码IDEA 右下角确认% page pageEncodingUTF-8 %meta charsetUTF-8request.setCharacterEncoding(UTF-8)在每个doPost()开头加防止 POST 提交中文乱码Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); // 必加 // 后续 getParameter() 才不会乱码 }4.2 表单重复提交F5 刷新导致选课两次用 Post-Redirect-GetPRG模式破局用户点击“选课”按钮后若直接request.getRequestDispatcher().forward()到结果页刷新页面会重新提交表单。必须用 PRG 模式// SelectCourseServlet.java Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); Long studentId ((Student) request.getSession().getAttribute(currentUser)).getStudentId(); Long courseId Long.parseLong(request.getParameter(courseId)); boolean success courseService.selectCourse(studentId, courseId); // ✅ 重定向到结果页避免 F5 重复提交 String redirectUrl success ? /success.jsp?msg选课成功 : /error.jsp?msg选课失败请重试; response.sendRedirect(request.getContextPath() redirectUrl); }对应success.jsp% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8% % String msg request.getParameter(msg); % h2% msg %/h2 a href${pageContext.request.contextPath}/courseList.jsp返回课程列表/a4.3 常见问题排查404 / 500 / 空白页三分钟定位根因现象原因解决方案访问/login报 404web.xml中url-pattern和浏览器地址栏路径不一致或LoginServlet.class没编译进WEB-INF/classes/检查target/xxx/WEB-INF/classes/com/example/sis/controller/LoginServlet.class是否存在确认 Tomcat 控制台输出StandardWrapper.registerServlet日志登录后跳转main.jsp显示空白main.jsp里${sessionScope.currentUser.realName}取不到但后台能打印对象检查 JSP 文件是否以?xml version1.0 encodingUTF-8?开头这是 XML 声明JSP 不要加确认pageEncoding和contentType都是UTF-8选课后数据库selection表有记录但course.remain_capacity没变化Transactional注解没生效类没被 Spring 扫描到或方法是 private或courseDao.updateRemainCapacity()SQL 写错字段名在 Service 方法上加System.out.println(事务开始)看是否执行用 MySQL 命令行SELECT * FROM course WHERE course_id 1确认字段名是remain_capacity而非capacity血泪经验Tomcat 启动日志里搜SEVERE和Caused by:90% 的 500 错误根源都在这里空白页一定是 JSP 编译失败.class没生成不是 Java 代码错。5. 进阶技巧用 Filter 实现登录拦截、用 Log4j2 记录选课日志、用 JUnit 测试 DAO 层5.1 登录拦截 Filter不用每写一个 Servlet 都判 session统一入口管控写LoginFilter.java拦截所有/student/*和/course/*请求// src/main/java/com/example/sis/filter/LoginFilter.java WebFilter(urlPatterns {/student/*, /course/*, /selection/*}) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 放行登录页和静态资源 String uri request.getRequestURI(); if (uri.contains(/login.jsp) || uri.contains(/login) || uri.endsWith(.css) || uri.endsWith(.js)) { chain.doFilter(req, resp); return; } // 检查 session 中是否有用户 Student user (Student) request.getSession().getAttribute(currentUser); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }注意WebFilter要求web.xml中web-app版本 ≥ 3.0即web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee ...否则注解不生效。5.2 用 Log4j2 记录关键操作选课、退课、管理员操作全留痕log4j2.xml放src/main/resources/?xml version1.0 encodingUTF-8? Configuration statusWARN Appenders File nameSelectionLog fileNamelogs/selection.log PatternLayout pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36} - %msg%n/ /File /Appenders Loggers Logger namecom.example.sis.service.SelectionService levelinfo additivityfalse AppenderRef refSelectionLog/ /Logger Root levelerror AppenderRef refConsole/ /Root /Loggers /Configuration在SelectionService.selectCourse()里加日志private static final Logger logger LogManager.getLogger(SelectionService.class); // ... logger.info(学生 {} 选课 {}课程余量更新为 {}, studentId, courseId, newRemain);生成日志文件logs/selection.log2024-06-15 14:22:33.123 [http-nio-8080-exec-2] INFO c.e.s.s.SelectionService - 学生 1001 选课 201课程余量更新为 985.3 DAO 层单元测试不启动 Tomcat用 H2 内存数据库跑通 SQLpom.xml加 H2 依赖dependency groupIdcom.h2database/groupId artifactIdh2/artifactId version2.2.224/version scopetest/scope /dependency写CourseDaoTest.javapublic class CourseDaoTest { private CourseDao courseDao; Before public void setUp() { // 使用 H2 内存数据库建表后立即插入测试数据 JdbcDataSource dataSource new JdbcDataSource(); dataSource.setURL(jdbc:h2:mem:test;DB_CLOSE_DELAY-1); dataSource.setUser(sa); dataSource.setPassword(); try (Connection conn dataSource.getConnection()) { ScriptRunner runner new ScriptRunner(conn); runner.runScript(new FileReader(src/test/resources/init-h2.sql)); } catch (Exception e) { throw new RuntimeException(e); } courseDao new CourseDaoImpl(dataSource); } Test public void testSelectForUpdate() { Course course courseDao.selectForUpdate(1L); assertNotNull(course); assertEquals(Java编程, course.getCourseName()); } }src/test/resources/init-h2.sqlCREATE TABLE course ( course_id BIGINT PRIMARY KEY, course_code VARCHAR(10), course_name VARCHAR(50), max_capacity INT ); INSERT INTO course VALUES (1, CS101, Java编程, 100);后悔药每次测试用 H2 内存库避免污染本地 MySQLDB_CLOSE_DELAY-1保证连接关闭后数据库不销毁。我带过的实习生里凡是能把LoginFilter写对、Log4j2日志按级别输出、H2测试跑通 DAO 的基本都能独立交付。不是因为他们代码多炫酷而是他们把 Java Web 最底层的请求生命周期、事务边界、字符集流转、测试隔离这些“脏活”踩实了。这套流程跑下来你手里那个.zip就不再是毕业设计模板而是一份能经得起压测、查得出日志、改得了并发的生产级雏形。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网