news 2026/9/29 4:46:50

教务管理系统JavaWeb项目实战:从JSP+Servlet到SpringBoot的完整案例

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
教务管理系统JavaWeb项目实战:从JSP+Servlet到SpringBoot的完整案例

简介:这是一份面向JavaWeb初学者与课程设计学习者的教务管理系统项目源码,基于MVC设计模式与DAO模式搭建,采用Servlet、JSP、Filter与MySQL数据库实现,运行于Windows系统下的Tomcat 7与JDK1.8环境。系统按角色划分为登录、学生、教师、教务员与系统管理员四大部分,涵盖登录、找回与修改密码、注销,以及学生成绩查询、选修与考级报名、学籍信息查看修改、教师个人信息维护、教务员成绩管理与报名名单管理、管理员用户管理等功能,结构完整,适合作为课程设计或毕业设计的参考模板。资源包共223个文件,以82个class、82个java、38个jsp为核心,另含6个jar、4个xml及sql脚本等,压缩包约2.6MB,目录层次清晰。目前已有11656人学习下载。下载后建议先阅读README.txt了解启动方式,读者可借此掌握分层开发思路、数据库建表与Servlet跳转逻辑,并在此基础上继续完善待实现功能,扩展新的业务模块。

1. 教务管理系统 JavaWeb 项目:从课程设计到简历项目的分水岭在哪

很多同学做教务管理系统 JavaWeb 项目,最后交出来的东西长一个样:登录、查课表、增删改查,页面能跑,数据库里躺着几张表,然后就没有然后了。答辩时老师问一句“选课冲突怎么处理的”,当场卡壳。问题不在技术栈,而在于大多数人把它当成了 JSP+Servlet 的练习册,而不是一个需要处理并发、权限、数据一致性的真实系统。

教务管理系统的核心难点其实很集中:角色多(管理员、教师、学生)、业务交叉(选课、排课、成绩、学籍)、数据敏感(成绩不能乱改、选课不能超员)。这三个特点决定了它天然适合拿来练权限设计和事务控制,而不是单纯堆 CRUD 页面。如果你正在找 JavaWeb 项目完整案例,或者跟着黑马程序员的笔记学完了 JSP 和 Servlet 想找个东西练手,教务管理系统是个不错的靶子——但前提是你得知道哪些地方该较真,哪些地方可以先用最朴素的方式跑通。

这篇内容面向两类人:一是课程设计需要交东西、但不想交个玩具的在校生;二是自学 JavaWeb 想往 SpringBoot 过渡、需要一个完整项目串知识点的转行者。我会按“先跑通最小闭环,再逐个替换成更靠谱的方案”的思路来讲,中间会给出可复制的配置和代码片段,也会说清楚哪些坑我踩过、哪些参数你最好别照抄。

2. 技术选型与最小可运行环境:别一上来就 SpringBoot

2.1 为什么先用 JSP+Servlet+MySQL 跑通,而不是直接上框架

我见过太多人打开 IDEA 第一件事就是 Spring Initializr,勾一堆依赖,然后卡在配置文件上三天。对于教务管理系统这个体量的项目,如果你对 JavaWeb 的请求流转还没有肌肉记忆,直接上 SpringBoot 会让你把“框架帮你做了什么”和“业务本身要做什么”搅在一起。常见做法是:先用 JSP+Servlet+JDBC 把登录、查课表、选课三个功能跑通,感受一下 request、session、转发重定向、连接池这些概念,然后再用 SpringBoot 重写一遍。这样你对“为什么需要框架”的理解会扎实很多。

热搜词里“idea 运行 javaweb 项目配置”出现频率很高,说明很多人的第一道坎是环境。我一般会建议用 Tomcat 9 + JDK 8 或 11 的组合,MySQL 用 5.7 或 8.0 都行,但驱动包版本要对上。IDEA 里配置 Tomcat 的坑集中在 Artifact 和 Context Path 上,下面会给具体步骤。

2.2 数据库表设计:五张核心表就能撑起最小闭环

教务系统的表可以很多,但最小闭环只需要五张:用户表、角色表、课程表、选课记录表、成绩表。下面是我常用的建表语句,字段做了精简,但约束该有的都有。

-- 用户表:统一存三种角色,用 role 字段区分 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, -- 存 MD5 或 BCrypt,别存明文 real_name VARCHAR(32) NOT NULL, role TINYINT NOT NULL COMMENT '1管理员 2教师 3学生', class_id INT DEFAULT NULL COMMENT '学生所属班级', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:capacity 是选课人数上限,后面并发控制要用 CREATE TABLE t_course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(64) NOT NULL, teacher_id INT NOT NULL, credit DECIMAL(3,1) NOT NULL, capacity INT NOT NULL DEFAULT 50, selected_count INT NOT NULL DEFAULT 0, semester VARCHAR(16) NOT NULL, FOREIGN KEY (teacher_id) REFERENCES t_user(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录表:student_id + course_id 建唯一索引,防止重复选课 CREATE TABLE t_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES t_user(id), FOREIGN KEY (course_id) REFERENCES t_course(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成绩表:和选课记录一对一,用 selection_id 关联 CREATE TABLE t_score ( id INT PRIMARY KEY AUTO_INCREMENT, selection_id INT NOT NULL UNIQUE, score DECIMAL(5,2) DEFAULT NULL, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, FOREIGN KEY (selection_id) REFERENCES t_selection(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有几个参数值得说明。t_selection上的uk_student_course唯一索引是防止同一学生重复选同一门课的第一道防线,比在 Java 代码里查一遍再插入要可靠得多。t_course里的selected_count是冗余字段,用空间换查询性能,但必须和t_selection的插入放在同一个事务里更新,否则会出现“记录插进去了但计数没加”的脏数据。字符集统一用utf8mb4,不然后面存学生姓名里的生僻字会出问题。

2.3 IDEA 里跑通第一个 Servlet:配置步骤和常见报错

在 IDEA 里新建一个 Java Enterprise 项目,勾选 Web Application,然后配置 Tomcat。具体路径是 Run → Edit Configurations → 加号 → Tomcat Server → Local,在 Deployment 标签页里把 Artifact 加进去,Application context 设成/edu。这一步如果 Artifact 是空的,说明你没在 Project Structure → Artifacts 里创建 Web Application: Exploded。

// LoginServlet.java:最朴素的登录逻辑,先跑通再说 @WebServlet("/login") public class LoginServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username = req.getParameter("username"); String password = req.getParameter("password"); // 实际项目里这里要做 MD5 或 BCrypt,先明文跑通流程 User user = userDao.findByUsernameAndPassword(username, password); if (user == null) { req.setAttribute("msg", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } req.getSession().setAttribute("currentUser", user); // 按角色跳不同首页 switch (user.getRole()) { case 1: resp.sendRedirect(req.getContextPath() + "/admin/index.jsp"); break; case 2: resp.sendRedirect(req.getContextPath() + "/teacher/index.jsp"); break; default: resp.sendRedirect(req.getContextPath() + "/student/index.jsp"); } } }

这段代码的逻辑很直白:拿参数、查库、写 session、按角色跳转。参数说明上,req.getContextPath()返回的是/edu,拼在重定向路径前面能避免部署路径变化后 404。forward和sendRedirect的区别在这里体现得很清楚:登录失败用 forward 是为了保留 request 里的错误提示,登录成功用 redirect 是为了防止刷新页面重复提交表单。这个细节答辩时经常被问。

跑起来之后如果报ClassNotFoundException: com.mysql.cj.jdbc.Driver,检查WEB-INF/lib下有没有 mysql-connector-java 的 jar 包,或者 Maven 里 scope 是不是写成了 provided。如果报 404,先看 Tomcat 启动日志里 Context path 是不是/edu,再看@WebServlet的路径有没有拼错。

3. 选课并发与权限拦截:教务系统真正值得较真的两个地方

3.1 选课超员问题:从“查了再插”到“更新时判断”

选课是教务系统里唯一有并发压力的场景。假设一门课容量 50 人,第 50 个和第 51 个学生同时点选课,如果你先SELECT selected_count FROM t_course WHERE id=?查出 49,然后判断小于 50,再UPDATE加一,两个线程可能都查到 49,都判断通过,最后变成 51 人。这就是典型的竞态条件。

常见做法有两种。第一种是用数据库的行锁,在事务里用SELECT ... FOR UPDATE锁住课程行,再判断再更新。第二种是用一条带条件的 UPDATE 语句,把判断和更新合并成原子操作。

// 方案二:原子更新,推荐 public boolean selectCourse(int studentId, int courseId) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 先插选课记录,唯一索引会拦住重复选课 String insertSql = "INSERT INTO t_selection(student_id, course_id) VALUES(?, ?)"; try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, studentId); ps.setInt(2, courseId); ps.executeUpdate(); } // 再原子更新已选人数,只有 selected_count < capacity 时才成功 String updateSql = "UPDATE t_course SET selected_count = selected_count + 1 " + "WHERE id = ? AND selected_count < capacity"; try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, courseId); int rows = ps.executeUpdate(); if (rows == 0) { conn.rollback(); // 人数已满,回滚掉刚才插入的选课记录 return false; } } conn.commit(); return true; } catch (SQLException e) { // 唯一索引冲突也会走到这里,说明重复选课 if (conn != null) try { conn.rollback(); } catch (SQLException ignored) {} return false; } finally { if (conn != null) try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } }

这段代码的关键在于UPDATE语句里的AND selected_count < capacity。数据库在执行这条 UPDATE 时会对目标行加写锁,两个并发事务只有一个能先拿到锁并完成更新,另一个会等锁释放后再执行,此时selected_count已经等于capacity,更新影响行数为 0,触发回滚。参数上,capacity和selected_count都必须是 NOT NULL 且有默认值,否则比较会出意外。另外,t_selection的唯一索引在这里也起了作用:如果同一个学生重复提交,INSERT 会直接抛异常,被 catch 捕获后返回 false,不会走到后面的更新。

注意:如果你的 MySQL 隔离级别是 REPEATABLE READ(默认),上面的 UPDATE 行为是可靠的。但如果有人把隔离级别改成 READ COMMITTED,逻辑依然成立,因为 UPDATE 本身会加锁。真正要避免的是在 Java 代码里先查后改,那种写法在任何隔离级别下都不可靠。

3.2 权限拦截:用 Filter 统一处理,别在每个 Servlet 里写 if

教务系统有三种角色,每个角色能访问的页面不一样。新手容易在每个 Servlet 开头写if (user.getRole() != 1) { resp.sendRedirect(...); return; },代码重复且容易漏。正确做法是用 Filter 做统一拦截。

// AuthFilter.java:拦截所有请求,按角色和路径判断 @WebFilter("/*") public class AuthFilter implements Filter { // 不需要登录就能访问的路径 private static final String[] WHITE_LIST = {"/login", "/login.jsp", "/css/", "/js/"}; @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(); String contextPath = request.getContextPath(); String path = uri.substring(contextPath.length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(req, resp); return; } } User user = (User) request.getSession().getAttribute("currentUser"); if (user == null) { response.sendRedirect(contextPath + "/login.jsp"); return; } // 按角色限制路径前缀 if (path.startsWith("/admin/") && user.getRole() != 1) { response.sendError(403, "无权访问"); return; } if (path.startsWith("/teacher/") && user.getRole() != 2) { response.sendError(403, "无权访问"); return; } if (path.startsWith("/student/") && user.getRole() != 3) { response.sendError(403, "无权访问"); return; } chain.doFilter(req, resp); } }

这个 Filter 的逻辑分三层:白名单放行、未登录跳转、已登录按路径前缀校验角色。参数上,WHITE_LIST里的路径要写全,比如/css/后面的斜杠不能少,否则/cssfoo也会被放行。path.startsWith("/admin/")这种判断方式要求你的目录结构必须规整,管理员页面全放在/admin/下,教师页面全放在/teacher/下。如果目录结构乱了,这个 Filter 就形同虚设。

提示:Filter 的@WebFilter("/*")会拦截所有请求,包括静态资源。如果你发现 CSS 加载不出来,检查白名单里有没有把/css/和/js/加进去。另一个常见问题是 Filter 里用了request.getSession()但没配置 session 超时,导致用户登录后长时间不操作再点页面就跳回登录页,这个在web.xml里配<session-config><session-timeout>30</session-timeout></session-config>就行。

4. 从 JSP 到前后端分离:什么时候该换,换了之后怎么改

4.1 判断信号:出现这三个迹象就该动手了

JSP 不是不能用,但当你发现页面里<% %>脚本块比 HTML 标签还多、同一个查询逻辑在三个 JSP 里各写了一遍、前端同学想调你的接口但只能拿到渲染好的 HTML 时,就该考虑前后端分离了。教务管理系统里最典型的信号是成绩录入页面:教师需要动态加载学生名单、批量提交分数、实时校验分数范围,这些用 JSP 写会非常别扭。

换的路径不是推倒重来,而是渐进式替换。先把 Servlet 改造成返回 JSON 的接口,JSP 页面暂时保留但改成用 AJAX 调接口。这样你可以一个模块一个模块地迁移,不会出现“改到一半项目跑不起来”的尴尬。

4.2 用 Servlet + Gson 返回 JSON:最小改造示例

不需要引入 SpringMVC,用最朴素的 Servlet 加 Gson 就能返回 JSON。下面以“查询某课程的学生名单”为例。

// CourseStudentServlet.java:返回 JSON 而不是转发到 JSP @WebServlet("/api/course/students") public class CourseStudentServlet extends HttpServlet { private SelectionDao selectionDao = new SelectionDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 设置响应类型和编码,这两行顺序不能反 resp.setContentType("application/json;charset=UTF-8"); resp.setCharacterEncoding("UTF-8"); int courseId = Integer.parseInt(req.getParameter("courseId")); List<StudentScoreVO> list = selectionDao.findStudentsByCourse(courseId); // 用 Gson 序列化,日期格式要单独处理 Gson gson = new GsonBuilder() .setDateFormat("yyyy-MM-dd HH:mm:ss") .create(); resp.getWriter().write(gson.toJson(list)); } }

参数说明:setContentType必须在getWriter()之前调用,否则编码设置不生效,返回的中文会变成乱码。GsonBuilder里的setDateFormat是为了让java.util.Date类型的字段按可读格式输出,默认 Gson 会输出时间戳。StudentScoreVO是一个普通的 JavaBean,字段名就是 JSON 的 key,前端拿到的数据结构是[{studentName: "张三", score: 85.5}, ...]。

前端用 fetch 调这个接口的代码大概长这样:

// 教师端成绩录入页面的加载逻辑 async function loadStudents(courseId) { const resp = await fetch(`/edu/api/course/students?courseId=${courseId}`); if (!resp.ok) { alert('加载失败,请检查登录状态'); return; } const data = await resp.json(); const tbody = document.querySelector('#studentTable tbody'); tbody.innerHTML = data.map(s => ` <tr> <td>${s.studentName}</td> <td><input type="number" min="0" max="100" step="0.5" value="${s.score ?? ''}"># application.yml 关键配置 spring: datasource: url: jdbc:mysql://localhost:3306/edu_system?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver servlet: session: timeout: 30m mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.edu.entity

serverTimezone=Asia/Shanghai这个参数在 MySQL 8.0 里必须加,否则连接会报时区错误。session.timeout设成 30 分钟,和之前web.xml里的配置对应。MyBatis 的mapper-locations指向resources/mapper目录,XML 文件里的 namespace 要对应 Mapper 接口的全限定名。

重写完之后,你会发现原来 JSP 里那些<% %>脚本块全变成了 Controller 返回 JSON、前端用 Vue 或 React 渲染。这时候你的项目就从“课程设计”变成了“能写进简历的前后端分离项目”。面试官问起来,你可以从 JSP 的痛点讲到为什么要分离,从选课的竞态条件讲到数据库行锁和原子更新,从 Filter 讲到 Spring 的 Interceptor 和 AOP。这些细节比“我用了 SpringBoot”有说服力得多。

我自己的习惯是:每做完一个版本,把踩过的坑和关键决策记在一个 Markdown 文件里,答辩前翻一遍,比背稿子管用。教务管理系统这个题目看起来普通,但把并发、权限、事务、前后端分离这几块讲透,足够撑起一场不心虚的答辩。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/29 4:45:30

嵌入式软件量产检查清单:从样机到产线的可靠性设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 4:44:30

深度学习毕设开题避坑指南:数据、算力与可行性验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 4:44:17

Ubuntu上安装Anaconda3全指南:环境变量配置与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 4:42:48

ROS机械臂MoveIt规划配置与执行链路实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/29 4:42:24

Qoder安装配置实战:从AI编程环境搭建到C++项目高效开发

市面上 AI 编程工具多到看不过来&#xff0c;但我还是想单独写一篇 Qoder 的安装和使用教程。原因很简单&#xff1a;我把它当作主力编辑器从第一行代码开始用过一段时间&#xff0c;从最初的"这不就是个带聊天的编辑器"到后面的"干活确实顺手"&#xff0c…

作者头像 李华