☰
Servlet+JSP+MySQL构建教务管理系统:从课设到二次开发实战
2026/10/17 10:53:02 网站建设 项目流程

简介:这是一套基于J2EE的教务管理系统JavaWeb项目,采用MVC设计模式与DAO、Servlet、JSP、Filter等技术栈,配合MySQL数据库实现。项目围绕高校教务场景,划分为登录、学生、教师、教务员、系统管理员五大功能模块,涵盖成绩查询、选修与考级报名、学籍信息维护、成绩管理、用户管理及密码找回等完整业务流程,既可作为JavaWeb课程设计或毕业设计参考,也适合初学者对照学习分层架构与请求过滤等核心机制。资源包共223个文件,压缩后约2.6MB,以82个Java源码、82个编译后的class文件、38个JSP页面及6个JAR依赖库为主,另含SQL数据库脚本、XML配置、项目描述文件与README说明,结构清晰,便于直接导入Eclipse等环境运行调试。已有11657人浏览学习。下载后建议先阅读README.txt了解启动方式,再结合源码和数据库脚本逐步拆解;系统预留了可扩展功能点,学习者可以在现有基础上继续完善选修、考级报名等模块,对理解JavaWeb项目完整开发流程有切实帮助。

1. 教务管理系统JavaWeb实战:从课设到可二次开发的完整骨架

每年期末选课高峰,教务系统崩在“学生同时提交选课”这种并发场景是家常便饭。这份资源不是花架子演示项目,而是一个用Servlet、JSP、MySQL、Tomcat搭起来的完整教务管理系统:学生能登录看课表、选课、查成绩,教师能录成绩、看自己开的课,管理员管学生、教师、课程和选课记录。它是典型的JavaWeb课设项目,代码结构清晰,能跑通主流程,作为毕业设计和面试项目素材都很合适。适合刚学完JavaWeb、想找一个不绕弯的实战项目的人。我会把项目从架构、表结构到核心代码拆开讲,最后给出能直接用的改造方案。

2. 技术选型与工程结构:Servlet+JSP+MySQL的经典MVC落地

2.1 为什么选Servlet和JSP而不是Spring Boot

项目采用的是Web应用最经典的MVC分层:JSP负责展示(View),Servlet负责接收请求和转发(Controller),业务逻辑抽到Service层,数据库操作集中在DAO层。用Servlet而不用Spring Boot,一方面是因为课设场景里追求的是把HTTP请求怎么进来、参数怎么取、数据库怎么查这条链路的每个环节搞清楚;另一方面,这个技术栈在本地跑起来几乎没有版本地狱,一个Tomcat 8.5版本加一个JDK 1.8就能完事。

常见做法是用Servlet 3.0以上的注解方式配置,不需要写一大堆XML。比如登录接口直接标一个@WebServlet("/login"),URL映射跟着注解走,项目里少了web.xml里各种servlet-mapping的配置,新人看着舒服很多。我一般会把web-app的metadata-complete设为false,这样Tomcat启动时会自动扫描注解,避免“明明配了Servlet却404”的怪事。

2.2 工程目录:src下的包结构与WebRoot的页面分工

这个项目的目录结构是下面这样的,Java源码和前端页面分得比较开:

webapps/edu-admin ├── src │ ├── com.edu.dao // 数据库访问层 │ │ ├── BaseDao.java // JDBC基础封装,获取连接、关闭资源 │ │ ├── StudentDao.java │ │ ├── TeacherDao.java │ │ ├── CourseDao.java │ │ └── GradeDao.java │ ├── com.edu.entity // 实体类 │ │ ├── Student.java │ │ ├── Teacher.java │ │ ├── Course.java │ │ ├── Grade.java │ │ └── Admin.java │ ├── com.edu.service // 业务层,处理选课、成绩录入等逻辑 │ ├── com.edu.servlet // 控制层,按模块分LoginServlet、CourseServlet等 │ └── com.edu.util // 工具类,MD5加密、分页封装 ├── WebRoot │ ├── login.jsp // 登录页,按角色区分入口 │ ├── index.jsp // 登录成功后跳转的首页 │ ├── student // 学生端页面:课表、选课、成绩查询 │ ├── teacher // 教师端:课程管理、学生名单、成绩录入 │ └── admin // 管理端:学生管理、院系管理、课程审批 └── sql └── edu_admin.sql // 建库建表脚本和初始数据

DAO层是整个项目的地基。BaseDao里封装了获取数据库连接的方法,用一个静态代码块读db.properties配置文件,这样换数据库地址不用改Java代码。实体类是严格按照数据表字段写的,属性名和列名一一对应,后续用MyBatis迁移时几乎不用改实体。

2.3 配置文件里容易被忽略的细节

项目根目录的lib文件夹里放着MySQL驱动jar包和JSTL的jar包,这里有一个值得说的细节:JSTL版本和Servlet版本不匹配,会出现org.apache.jasper.JasperException。这个项目用的是JSP 2.x,JSTL要选1.2版本,不要选老掉牙的1.1。数据库连接串建议写jdbc:mysql://localhost:3306/edu_db?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai,前两个参数是中文不乱码的关键,useSSL=false能避免MySQL 8的SSL握手警告刷屏,serverTimezone是MySQL 8以后必须加的。

Tomcat的conf/server.xml里没有特殊配置,保持默认的8080端口就行。这个项目对Java版本不挑食,JDK 8往上都能编译。要注意的是MySQL的驱动jar包版本和数据库版本要对得上,我在这上面翻过车:连MySQL 5.7用8.x的驱动也能跑,但连MySQL 8用5.x的驱动会报Communications link failure,看日志死活找不到原因,很玄学。

3. 数据库设计:六张业务表如何撑起教务主流程

3.1 核心表结构:不会过度设计但刚好覆盖业务

这个项目的数据库叫edu_db,一共六张业务表加两张关联表。表设计走的是实用路线,不搞花里胡哨的范式,每张表都有明确的业务归属:

表名职责关键字段说明
t_student学生信息stu_no(学号)、stu_name、dept_name、class_name学号作主键,不用自增ID
t_teacher教师信息teacher_no(工号)、teacher_name、dept_name教师与课程是一对多
t_course课程信息course_id、course_name、credit、teacher_no、max_num一个老师开多门课
t_admin管理员admin_name、password默认初始账号
t_choose选课记录id、stu_no、course_id、choose_time、status学生和课程的多对多关系
t_grade成绩记录id、stu_no、course_id、score、exam_date一门课一个学生一行成绩

选课和成绩都设计成了独立的关联表,没有把课程冗余到学生表里,这样后续统计“某老师这学期带了多少学生”或者“某门课的平均分”,一条SQL的JOIN就能查出来,不需要在Java代码里做内存计算。

3.2 建表SQL里值得照抄的写法

sql/edu_admin.sql里初始化脚本写得比较完整,建完表之后插入了测试账号:一个学生账号、一个教师账号、一个管理员账号,密码统一为123456的MD5值。选课表有一个联合唯一索引uk_stu_course,这个索引很关键,它约束了“同一个学生不能重复选同一门课”。在录入成绩的时候,成绩表也是以学生和课程为维度,用INSERT INTO ... ON DUPLICATE KEY UPDATE来处理重复提交,这个后面讲Servlet的时候会说。

3.3 外键为什么没用:牺牲约束换项目运行稳定

表之间没有设物理外键,只保留了逻辑上的关联字段。原因很实际:课设项目最怕的是删除数据时冒出来一个外键约束错误,学生毕业了要删掉,结果系统提示“该学生在成绩表有记录,无法删除”,这就很干戈。项目里所有数据一致性靠Service层保证——比如删学生之前,先查选课表和成绩表有没有关联记录,有就提示先清退再删除。

数据操作统一走DAO层,没有在JSP里直接拼SQL。这是这个项目里我最喜欢的一点,说明写项目的人是有工程意识的。改数据时只要找DAO层的对应方法,不用满项目搜SQL字符串。

4. 核心链路拆解:从登录鉴权到成绩录入的一次完整请求

4.1 登录接口:MD5加密、Session会话与角色跳转

登录是系统的入口,也是理解这个项目的最佳切入口。登录页面提交的请求最终会走到LoginServlet的doPost方法。看一下核心代码:

@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); String role = req.getParameter("role"); User user = userService.login(username, password, role); if (user != null) { HttpSession session = req.getSession(); session.setAttribute("loginUser", user); session.setAttribute("role", role); // 按角色跳转到对应首页 String target = "/index.jsp"; if ("admin".equals(role)) { target = "/admin/home.jsp"; } else if ("teacher".equals(role)) { target = "/teacher/home.jsp"; } resp.sendRedirect(req.getContextPath() + target); } else { req.setAttribute("error", "用户名或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } }

参数里username和password对应JSP表单里的name属性,role是登录页下拉框选的角色,三个角色使用同一个登录接口。userService.login()内部会根据角色去不同的DAO查询:学生查t_student表,教师查t_teacher表,管理员查t_admin表,查询时先用MD5Utils.md5()把明文密码做一次加密再和数据库比对。密码不存明文,这个习惯值得保留。登录成功后把整个用户对象放进session,后面任何页面都能通过${sessionScope.loginUser.stuName}拿到当前登录人姓名,这是JSP页面里最常见的取值方式。

这里有个细节:退款失败和登录失败都用了forward跳回登录页而不是sendRedirect,区别在于forward是服务器内部转发,URL地址栏不会变,报错信息存在request域里能渲染到页面上;sendRedirect是302重定向,跳转后request域就没了。在这个场景下forward是正确的选择。

4.2 权限拦截过滤器:一个Filter挡住未登录请求

登录成功后如果直接手敲URL访问管理端页面,比如浏览器输入/admin/grade/list,系统必须拦截。这个项目用的是Filter过滤器,代码很薄也很典型:

@WebFilter("/admin/*") public class AuthFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; HttpSession session = req.getSession(false); // 不主动创建session Object loginUser = (session == null) ? null : session.getAttribute("loginUser"); if (loginUser == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); return; } String role = (String) session.getAttribute("role"); if (!"admin".equals(role)) { resp.sendError(HttpServletResponse.SC_FORBIDDEN, "无权限访问"); return; } chain.doFilter(request, response); } }

过滤器映射路径是/admin/*,能匹配到所有以/admin/开头的请求,包括页面和Servlet路径。注意我用了req.getSession(false)——参数false表示如果当前没有session就直接返回null而不是新建一个,如果没有登录还自动创建session,那么所有未登录请求都会产生垃圾会话数据,这是资源浪费。这个Filter只拦截了/admin/*,学生端的/student/*和教师端的/teacher/*路径也各自有Filter,代码结构一致,只是角色判断条件不同。

4.3 选课与成绩录入:并发场景下的事务处理

选课是系统里对实时性要求最高的操作。CourseServlet处理选课的核心代码如下:

@WebServlet("/student/chooseCourse") public class ChooseCourseServlet extends HttpServlet { private CourseService courseService = new CourseService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); Student student = (Student) session.getAttribute("loginUser"); String courseId = req.getParameter("courseId"); Map<String, Object> result = courseService.chooseCourse(student.getStuNo(), courseId); if ((boolean) result.get("success")) { resp.sendRedirect(req.getContextPath() + "/student/courseList.jsp?msg=选课成功"); } else { resp.sendRedirect(req.getContextPath() + "/student/courseList.jsp?msg=" + URLEncoder.encode((String) result.get("message"), "UTF-8")); } } }

真正的选课逻辑在CourseService.chooseCourse()方法里,我看一下它的实现思路:先开启一个数据库事务,第一步检查课程剩余名额,第二步检查这个学生是否已经选过,第三步往t_choose表插入记录,同时把t_course表的已选人数selected_num加一,最后提交事务。

这四步必须在一个事务里完成,否则会出现“查的时候有名额,点提交的时候名额没了”的超卖问题。实际代码里用的是Connection.setAutoCommit(false)配合commit()和rollback(),任何一步失败都会回滚。虽然相比Spring的@Transactional注解显得硬核,但它让你清楚地看到事务边界在哪里。如果你是自己写代码,我建议在这个方法里加一个SELECT ... FOR UPDATE锁住课程行,这样同一门课的并发选课请求会串行执行,能保证不会超选。

4.4 成绩录入:一个方法搞定新增和修改

教师录入成绩的逻辑在GradeServlet里,它用一个save方法同时兼容新增和修改。典型代码如下:

@WebServlet("/teacher/grade/save") public class GradeSaveServlet extends HttpServlet { private GradeDao gradeDao = new GradeDao(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String stuNo = req.getParameter("stuNo"); String courseId = req.getParameter("courseId"); String scoreStr = req.getParameter("score"); double score; try { score = Double.parseDouble(scoreStr); } catch (NumberFormatException e) { req.setAttribute("error", "成绩必须是数字"); req.getRequestDispatcher("/teacher/gradeInput.jsp").forward(req, resp); return; } if (score < 0 || score > 100) { req.setAttribute("error", "成绩范围必须在0~100之间"); req.getRequestDispatcher("/teacher/gradeInput.jsp").forward(req, resp); return; } Grade grade = new Grade(); grade.setStuNo(stuNo); grade.setCourseId(courseId); grade.setScore(score); boolean success = gradeDao.insertOrUpdate(grade); if (success) { resp.sendRedirect(req.getContextPath() + "/teacher/gradeList?courseId=" + courseId); } else { req.setAttribute("error", "保存失败,请重试"); req.getRequestDispatcher("/teacher/gradeInput.jsp").forward(req, resp); } } }

这段代码我特别想强调两个细节。第一个是insertOrUpdate这个命名,它是整个DAO层设计得比较聪明的地方:底层用INSERT ... ON DUPLICATE KEY UPDATE score=VALUES(score),利用成绩表上(stu_no, course_id)的唯一索引,同一门课重复提交成绩时不是报错,而是覆盖旧成绩,这正好符合教务场景“重新打分”的需求。第二个是参数解析要放在业务判断之前,Double.parseDouble抛异常时如果不catch,Tomcat会直接给你一个500页面,用户看一脸懵,但这里把异常转成了友好的表单提示,体验好得多。

5. 部署与避坑:Tomcat下跑通的五个高频问题

5.1 从解压到看到登录页的完整流程

我不管拿到什么JavaWeb项目,第一步永远是先看sql目录和lib目录,确认数据库脚本和依赖jar包在不在。这个项目的部署流程很顺,简单走一遍:

# 1. 导入数据库 mysql -u root -p < sql/edu_admin.sql # 2. 修改 db.properties 里的账号密码 # jdbc.username=root # jdbc.password=你的密码 # 3. 用IDEA打开项目,配置Tomcat # Add Configuration -> Tomcat Server -> Local # Deployment -> 选择 war exploded 模式 # 4. 启动Tomcat,浏览器访问 # http://localhost:8080/edu-admin/

IDEA里跑这个项目时最容易出错的是Tomcat配置里的Application context。这个值决定了访问路径的前缀,我见过有人把它配成/edu-admin/,结果http://localhost:8080/edu-admin/edu-admin/login.jsp,路径直接套娃。正确做法是Application context填/edu-admin,访问http://localhost:8080/edu-admin/,登录页就能正常出来。登录时用初始化脚本里的测试账号,学生账号stu001,教师账号tea001,管理员账号admin,密码都是123456。

5.2 避坑记录:现象、原因、解决

坑一:启动Tomcat报ClassNotFoundException: com.mysql.jdbc.Driver

现象:Tomcat启动正常,但一访问登录接口就报驱动类找不到。原因:MySQL驱动jar包没有打进WEB-INF/lib目录,IDEA里把jar包放在外部lib目录并不等于部署时会被带上。解决:把mysql-connector-java-8.0.x.jar拷贝到项目的WEB-INF/lib文件夹下,再重新构建。如果用的依赖管理工具,就直接引入坐标,不要手动管理jar包路径。

坑二:JSP页面中文全是问号

现象:页面上的中文正常显示,但往数据库一存就变???。原因:JDBC连接串里没有characterEncoding参数。解决:数据库连接串加上useUnicode=true&characterEncoding=UTF-8,同时确保每个Servlet的doPost方法第一行有req.setCharacterEncoding("UTF-8")。POST请求的编码是由request.setCharacterEncoding决定的,不加这个,Tomcat默认用ISO-8859-1解析,中文必乱。

坑三:明明写了@WebServlet("/courseList"),访问却404

现象:在浏览器里输入/courseList找不到资源。原因:抛开路径大小写问题,最常见的是Servlet类上的注解用的@WebServlet(name=“...”, urlPatterns="/courseList")这种写法,但项目里的web.xml存在并且metadata-complete="true",这会关掉注解扫描。解决:要么把web.xml删除或把metadata-complete改成false,要么全部在web.xml里配servlet-mapping。这个项目默认是注解驱动的,拿到代码后不要画蛇添足去加web.xml配置。

坑四:选课成功后页面报IllegalStateException: getOutputStream() has already been called

现象:打印PDF或者导出Excel时偶尔报这个错。原因:同一个请求里JSP先用了<%= %>输出内容,然后又调用了response.getOutputStream(),二者互斥。解决:不要在一个请求里同时走JSP的out.println和Servlet的getOutputStream,导出功能单独走一个Servlet接口,不要跟页面渲染混在一起。

坑五:释放数据库连接没写finally,导致连接耗尽

现象:跑一段时间后系统越来越慢,后台报Too many connections。原因:项目里的DAO方法中如果查询抛了异常,conn.close()没执行到。解决:按BaseDao里的写法,用try-catch-finally关闭连接,并且Connection、Statement、ResultSet三个资源分开关。这个项目的BaseDao里封装好了一个closeAll()方法,直接用就行,不要自己另写一套JDBC。

6. 二次开发思路:从传统JSP升级到前后端分离的第一步

拿到这类项目,最值得做的改造不是推翻重写,而是做“有感知的进化”。我先说一个最常见的改造路径:把JSP里的数据渲染拆成JSON接口,用Vue或者其他前端框架去接。第一步找出项目里所有以request.getRequestDispatcher(...).forward()结尾的Servlet,把forward改成返回JSON,用Fastjson或Jackson序列化数据列表,这就是一个标准的API接口雏形。改完登录页面后,fetch('/login', {method:'POST', body: formData})配合history.pushState('/index')跳转,前端页面和业务就完全解耦了,后续接小程序都能直接用同一套接口。

第二步是数据库操作层的替换。项目现有DAO还是纯JDBC,我一般会引入MyBatis做持久层改造,但不要一次性把所有DAO都换掉。把BaseDao的getConnection()方法改成用DruidDataSource连接池,db.properties里加initialSize=5,maxActive=20参数,连接复用带来的响应速度提升在选课场景里特别明显。然后挑GradeDao先改造成Mapper接口,跑通一个功能验证没问题,再按模块逐个替换。你不需要推翻实体类,因为实体类和表字段本来就一一对应,MyBatis的resultMap几乎不用配。

第三步是引入Redis优化选课。在ChooseCourseServlet里先查Redis的课程剩余名额缓存,jedis.decr("course:remain:" + courseId),减到负数就返回“已满”,提前挡住无效请求,只有通过的请求才去操作MySQL事务。这个优化思路在教师端录成绩的场景同样适用。验证方式很简单,用JMeter同时发50个选课请求,观察最终选择记录数和课程剩余名额是否一致,如果一致说明超卖问题解决得干净。

还有一个针对权限模块的改造建议:现有Filter靠session.getAttribute("role")判断角色,这够用但不灵活。愿意折腾的话,把角色判断抽成一个PermissionUtil.check(user, "/admin/gradeList"),权限配置外置成一个auth.properties文件,这样你不需要改Java代码就能调整访问控制,对要演示“可维护性”的面试场景是加分项。

最后说个我的血泪经验:改这类项目一定要先备份sql目录的初始化脚本,每次数据库结构动了就重新导出一版,标注好版本号。人最容易被“我再改一下表”这种操作埋坑里,等数据乱了再想起有备份,已经晚了。从那以后我每次接到这种JavaWeb项目,都强制先画一遍请求流转图,把每个Servlet的访问路径、方法、参数列成清单再动手,省下大量排查时间,希望帮到你。

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

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

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

立即咨询