简介:一套基于JSP与Servlet的Web成绩管理系统课程设计源码,面向计算机专业学生与Java Web初学者,覆盖学生、教师、管理员三类角色的完整业务闭环。学生端支持个人信息维护与成绩查询,教师端提供成绩录入、修改、查看与分析,管理员端包含学生、教师、班级、课程的增删改查,以及课程安排、用户权限管理等后台功能,适合作为毕业设计或课程设计的参考蓝本。压缩包内共870个文件,以Java源码、JSP页面、JavaScript脚本、CSS样式表为主,另含SQL数据库脚本及PNG、JPG图片资源,整体大小约39.64MB,目录结构清晰,便于按模块定位代码。目前已有173人学习浏览,体现出较高的参考价值。借助该资源,读者可快速掌握JSP+Servlet开发模式下的权限分层设计与增删改查实现思路,并可直接复用成绩统计、班级分配等模块代码,为二次开发或功能扩展打下基础。
1. 一个JSP+Servlet成绩系统,为什么值得自己从头搭一遍?
成绩管理系统这种“老掉牙”的Web项目,在课程设计、毕业设计里出现频率极高,但很多同学只是把别人的代码下载下来改个数据库名就交差,结果答辩时连Servlet生命周期都说不清。实际上,这类项目恰恰是理解Java Web最扎实的载体:没有Spring Boot帮你自动配置,你必须亲手写Filter、写JDBC、处理中文乱码,才能明白框架替你做了什么。这套基于JSP+Servlet的Web成绩管理系统,核心就是教师录入成绩、学生查询成绩、管理员管理课程和学生信息,适合想从底层把Java Web打通的人,也适合需要一个能跑通、能演示、能讲清楚原理的项目作为起步。下面按我实际搭过一遍的路径,把技术栈、数据库、权限、查询链路和踩坑点完整拆开。
2. 先把技术栈和工程结构立住:JSP+Servlet的MVC分工与选型理由
2.1 Servlet负责“干活”,JSP负责“显示”:这套分工到底怎么切?
很多初学者会把Java代码直接写在JSP里,结果一个页面几百行<% %>,改一个SQL就要重新部署,维护起来像拆炸弹。MVC的分工在这套系统里必须明确:Controller是Servlet,Model是JavaBean+DAO,View是JSP。Servlet接收HTTP请求、调用DAO查询数据库、把结果放到request域里,再forward到JSP;JSP只负责用EL和JSTL把数据渲染成HTML。
我一般会再定一条硬规矩:JSP里不允许出现import java.sql.*,不允许直接写Connection,谁违反谁重构。这样一来,后面接Spring MVC时,只需要把Servlet换成Controller,DAO层几乎原样迁移。你要理解,这套系统不是为炫技,而是为了让你看清请求是怎么被处理的。
2.2 用Maven搭一个最省事的Web工程结构
虽然很多人喜欢直接新建Dynamic Web Project,但我更推荐Maven结构,因为依赖管理能少一半麻烦。你只需要一个pom.xml,加上Jetty或Tomcat插件就能启动。
<!-- pom.xml 关键片段 --> <project xmlns="http://maven.apache.org/POM/4.0.0"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>grade-manager</artifactId> <version>1.0.0</version> <packaging>war</packaging> <properties> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <!-- Servlet API 由 Tomcat 提供,scope 用 provided --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSP API --> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <!-- JSTL 标签库 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies> </project>这里最需要注意的是scope=provided:Tomcat自带Servlet和JSP的jar,如果你不设provided,打出来的war会把这些jar也带进去,部署时可能出现类冲突。MySQL驱动选择8.x是因为它兼容MySQL 5.7和8.0,驱动类用的是com.mysql.cj.jdbc.Driver,不再是com.mysql.jdbc.Driver,后者在8.x里已经被标记为过时。
工程目录按Maven标准来,但把web.xml保留在src/main/webapp/WEB-INF下,因为这套系统我打算用传统的web.xml注册Servlet,而不是用注解。注解虽然省事,但线上排查时web.xml里的映射关系一眼就能看全。
2.3 数据库设计:成绩管理最核心的三张表
成绩系统看似简单,但表结构设计不好,后面写查询会很难受。我设计的核心就三张表,加上一张关联表。
-- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', name VARCHAR(50) NOT NULL COMMENT '姓名', password VARCHAR(64) NOT NULL DEFAULT '123456' COMMENT '登录密码', class_name VARCHAR(50) COMMENT '班级' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT '课程编号', course_name VARCHAR(50) NOT NULL COMMENT '课程名称', credit DECIMAL(3,1) DEFAULT 2.0 COMMENT '学分' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 成绩表 CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2) COMMENT '成绩', exam_date DATE COMMENT '考试日期', remark VARCHAR(255) COMMENT '备注', UNIQUE KEY uk_stu_course (student_id, course_id), CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三个细节你可能注意不到:第一,score表里的成绩用DECIMAL(5,2),因为成绩可能有平时分和卷面分加权计算后的小数,用INT会丢失精度;第二,学生表里的password不存明文,先用MD5或BCrypt加密再入库,虽然作业项目没人攻击你,但养成这个习惯不亏——不过要说明,如果只是课程演示,MD5够用,真正上线要换BCrypt;第三,uk_stu_course唯一约束,保证同一个学生同一门课只有一条成绩,如果老师重复录入,要么更新要么拒绝。查询时看这条唯一键,就能快速判断“这条成绩是覆盖还是新增”。
表建好后,我用一个db.properties文件保存连接信息,通过静态代码块读取,不把用户名密码写死在代码里:
// db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/grade_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=your_passwordJDBC URL里最容易漏的是serverTimezone,不写的话MySQL 8.x会直接报“CST”时区识别异常。useSSL=false是因为本地开发不需要SSL加密,省去证书警告。这些参数写清楚,比遇到报错再瞎猜强。
3. 登录与权限:从Filter到Session的落地写法
3.1 用Session保存登录态,用Filter拦所有页面
成绩系统的页面必须按角色隔离:学生只能看自己的成绩,教师能录入成绩,管理员能管账号。如果只在每个JSP里判断session,散落的代码会让你漏掉某个页面。正确做法是写一个统一的AuthFilter,拦截所有.jsp和.do请求。
// AuthFilter.java @WebFilter("/*") public class AuthFilter 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.endsWith("/login.jsp") || uri.endsWith("/login.do") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png")) { chain.doFilter(req, resp); return; } // 未登录则重定向到登录页 Object user = request.getSession().getAttribute("user"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 已登录,继续走原始请求 chain.doFilter(req, resp); } }这里的“放行”逻辑是踩过坑的:登录页本身也是JSP,如果不放行,就会死循环重定向。静态资源放行是因为项目里用了CSS和图片,不让加载页面会很丑。留意到@WebFilter("/*")匹配所有请求,包括login.do,所以在放行条件里必须包含它。
以后要加权限区分,就在这个Filter里继续判断角色,而不是写第二个Filter。Session里存一个对象,在里面放role字段,Filter读出角色后对URL进行前缀匹配:/student/*只允许role=student访问,/teacher/*只允许role=teacher,/admin/*只允许role=admin。这套机制虽然简单,但足够应付99%的课程设计场景。
3.2 登录逻辑:密码校验和Session写入
登录页提交到LoginServlet,核心逻辑是查库比对密码,成功后把用户信息写进Session,并跳转到对应的首页。
// LoginServlet.java 关键方法 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String username = request.getParameter("username"); String password = request.getParameter("password"); UserDao dao = new UserDao(); User user = dao.findByUsernameAndPassword(username, password); if (user != null) { request.getSession().setAttribute("user", user); // 按角色跳转到不同首页 String role = user.getRole(); if ("admin".equals(role)) { response.sendRedirect("admin/index.jsp"); } else if ("teacher".equals(role)) { response.sendRedirect("teacher/index.jsp"); } else { response.sendRedirect("student/index.jsp"); } } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("login.jsp").forward(request, response); } }这里的SendRedirect和Forward是有讲究的:登录成功后必须用sendRedirect,否则刷新页面会重复提交表单;登录失败用forward并把错误信息放到request域里,这样页面能回显错误,且URL不会变成login.do,刷新也安全。很多人在这里混用,导致“登录成功但刷新一下成绩就没了”或者“后退能回到登录前页面”的诡异bug。
另外,密码比对不要用明文where password='...',在DAO里把MD5加密后的密码拼进SQL,或者干脆在SQL里调用MD5(?)。我建议用PreparedStatement的setString传参,不然用户输入单引号就可能被拼出SQL注入。浏览器端只能拿到session里的user对象,密码字段不要放进去,或者取的时候忽略,避免通过页面源码泄露hash。
4. 成绩录入与查询:Servlet+JDBC的完整链路
4.1 从表单到数据库:POST请求怎么走完一遍
成绩录入页面是教师最常用的功能。表单提交到ScoreServlet,Servlet先取值、做简单校验,再调用DAO写入。这里把每一步拆开看。
// ScoreServlet.java doPost 片段 String studentNo = request.getParameter("studentNo"); String courseNo = request.getParameter("courseNo"); String scoreStr = request.getParameter("score"); // 校验数字,防止SQL报错 BigDecimal score; try { score = new BigDecimal(scoreStr); if (score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0) { throw new NumberFormatException(); } } catch (NumberFormatException e) { request.setAttribute("msg", "成绩必须是0-100之间的数字"); request.getRequestDispatcher("teacher/addScore.jsp").forward(request, response); return; } ScoreDao dao = new ScoreDao(); boolean ok = dao.insertScore(studentNo, courseNo, score); if (ok) { response.sendRedirect("listScores.do?studentNo=" + studentNo); } else { request.setAttribute("msg", "录入失败:该学生或课程不存在,或已存在成绩"); request.getRequestDispatcher("teacher/addScore.jsp").forward(request, response); }这里展示了一个容易被忽略的点:查询参数直接拼在重定向URL里。如果再跳转到列表页,studentNo的值必须手动编码,因为如果学号里有中文或特殊字符,浏览器可能截断。更稳妥的做法是跳回一个不依赖参数的列表页,或者在Session里保存“最近查询条件”。我在实际项目里会用前一种,简单但能跑。
BigDecimal而不是double去接收成绩,原因前面提过——浮点计算0.1+0.2不等于0.3,成绩如果作为浮点存数据库,算绩点时会出现莫名其妙的小尾巴。这一层校验放Servlet里,而不是JSP里,是因为JSP适合显示,不适合处理逻辑。
4.2 成绩列表的分页查询:为什么要用LIMIT而不是全量加载
学生成绩列表可能几十条,但全量加载不是个好习惯。尤其当系统运行几年后,一次SELECT * FROM score可能拉回上万行,JSP渲染直接卡死。分页是必做的,我选的是最简单的MySQLLIMIT分页。
// ScoreDao.listPage 方法 public List<ScoreVO> listPage(int pageNum, int pageSize, String keyword) { List<ScoreVO> list = new ArrayList<>(); int offset = (pageNum - 1) * pageSize; String sql = "SELECT s.student_no, s.name, c.course_name, sc.score " + "FROM score sc " + "JOIN student s ON sc.student_id = s.id " + "JOIN course c ON sc.course_id = c.id " + "WHERE s.name LIKE ? OR c.course_name LIKE ? " + "ORDER BY sc.exam_date DESC " + "LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); ps.setString(2, "%" + keyword + "%"); ps.setInt(3, offset); ps.setInt(4, pageSize); ResultSet rs = ps.executeQuery(); while (rs.next()) { ScoreVO vo = new ScoreVO(); vo.setStudentNo(rs.getString("student_no")); vo.setStudentName(rs.getString("name")); vo.setCourseName(rs.getString("course_name")); vo.setScore(rs.getBigDecimal("score")); list.add(vo); } } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } return list; }注意这里的SQL用了JOIN而不是子查询,查出来的字段经过别名映射后直接封装到VO里。LIMIT ?, ?的两个参数必须用setInt绑定,不能拼字符串——因为offset是前端传过来的,恶意传个负数或巨大数字会造成性能问题。PreparedStatement绑定后,MySQL会对参数做类型校验,比字符串拼接安全得多。
分页之后,前端JSP要拿到总页数。通常用一个count查询:
SELECT COUNT(*) FROM score sc JOIN student s ON sc.student_id = s.id JOIN course c ON sc.course_id = c.id WHERE s.name LIKE ? OR c.course_name LIKE ?然后把totalPages = (totalCount + pageSize - 1) / pageSize。这个加pageSize再减1的写法是为了向上取整,避免totalCount=0时出现第0页。切记,count查询里也要带JOIN和WHERE条件,否则分页数量和总数对不上,翻到最后几页会空。
4.3 导出成绩单:JSP页面用表格打印PDF的取舍
成绩系统经常需要把成绩单打印出来或存成PDF。老项目里没有引入POI,最简单的做法是用浏览器自带的打印功能:JSP渲染一个适合打印的页面,然后window.print()。我不建议在JSP+Servlet这种简单项目里强行集成PDF库,因为字体、分页、中文编码的坑能把一个晚上耗光。
<!-- printScore.jsp 关键片段 --> <%-- 用CSS控制打印区域和分页 --%> <style> @media print { body { font-family: SimSun, serif; } .no-print { display: none; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid #000; padding: 6px; } tr { page-break-inside: avoid; } } </style> <div class="no-print"> <button onclick="window.print()">打印成绩单</button> <button onclick="history.back()">返回</button> </div> <table> <tr><th>学号</th><th>姓名</th><th>课程</th><th>成绩</th></tr> <c:forEach var="sc" items="${scoreList}"> <tr> <td>${sc.studentNo}</td> <td>${sc.studentName}</td> <td>${sc.courseName}</td> <td>${sc.score}</td> </tr> </c:forEach> </table>page-break-inside: avoid这一行能防止一个学生的多科成绩被分页拆开;@media print里的字体设为宋体,是因为浏览器打印默认字体在Windows和Mac上完全不同,中文宽度会乱跳。如果你要生成真正可编辑的Excel,那再引入Apache POI,但注意POI依赖众多,war包会大一两倍,课程设计阶段用打印功能完全足够。
5. 避坑:JSP+Servlet项目最常见的5个翻车现场
5.1 页面和数据库全是问号:中文乱码问题
现象:表单提交中文到Servlet,存进数据库变成??,或者页面显示一堆问号。
原因:三层编码不一致——浏览器提交用的是页面编码,Servlet处理参数用的是容器默认编码(Tomcat 8以前是ISO-8859-1),数据库连接和表结构是latin1。最常见的是漏掉request.setCharacterEncoding("UTF-8"),以及JDBC URL里没写characterEncoding=utf8。
解决:第一,在Servlet的doPost第一行写request.setCharacterEncoding("UTF-8"),doGet则需要在Tomcat的server.xml里给Connector加URIEncoding="UTF-8";第二,JDBC URL里带上useUnicode=true&characterEncoding=utf8;第三,建表时统一DEFAULT CHARSET=utf8mb4,不要用默认latin1。检查时按“页面→请求→Servlet→JDBC→MySQL”的顺序排查,别一上来就改代码。还有个血泪经验:JSP文件本身的编码必须是UTF-8,文件头pageEncoding和contentType都写上,用IDE新建时别漏。
5.2 404还能理解,500错得莫名其妙:JDBC驱动没加载
现象:点击查询成绩,控制台报ClassNotFoundException: com.mysql.cj.jdbc.Driver或No suitable driver found for jdbc:mysql://localhost:3306/grade_db。
原因:mysql-connector的jar没有打到war的lib目录。Maven默认打包会把依赖打进去,但如果你用了provided scope,或IDE的Deployment Assembly没加Maven依赖,Tomcat运行时找不到驱动类。
解决:先把pom里mysql驱动的scope改成默认的compile,然后检查Project Facets里的Runtime是否选了Tomcat,再确认Deployment Assembly中有/WEB-INF/lib ← Maven Dependencies。本地用Tomcat启动时,还可以看WEB-INF/lib下是否真有mysql-connector-java-8.0.33.jar。更懒的办法是直接用Tomcat的lib目录放驱动jar,但不推荐,因为换机器就完了。
5.3 明明登录了却被踢回登录页:Session丢失
现象:登录成功跳转首页后,点任何一个链接马上回到login.jsp。
原因:第一种是Filter放行条件写错了,把登录页、静态资源之外所有请求都判定为未登录,而Session里存的对象名不对,比如LoginServlet里存的是"user",Filter里取的是"currentUser";第二种是登录成功后用了forward而不是sendRedirect,浏览器地址栏还是login.do,刷新时又重新进了Filter,此时session还在但逻辑顺序不对;第三种是两台Tomcat之间负载均衡,session没共享,但单机部署不会出现。
解决:统一Session属性名,最好定义一个常量类;检查Filter里的放行路径,登录成功后强制sendRedirect。另外要注意,session的默认超时是30分钟(web.xml里可以配),如果调试时间太长,会被自动清掉,这不算bug,但会让新手以为是代bug。
5.4 页面路径总对不上:Servlet映射乱写
现象:表单action写addScore.do,但Servlet用@WebServlet("/addScore")映射,一提交就404。
原因:URL映射后缀对不上。@WebServlet("/addScore")匹配的是/addScore,而表单提交到/addScore.do。Tomcat 7以后默认会匹配精确路径,不会自动加.do后缀。
解决:要么统一用/addScore.do前缀做映射,要么统一用路径风格。我习惯在web.xml里写:
<servlet> <servlet-name>scoreServlet</servlet-name> <servlet-class>com.example.servlet.ScoreServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>scoreServlet</servlet-name> <url-pattern>*.do</url-pattern> </servlet-mapping>这样所有*.do请求都进入同一个Servlet的service方法,再根据参数分发。这个做法被Spring MVC的DispatcherServlet延续了。提醒一下,用注解就不要再配web.xml,两套同时配会重复执行。
5.5 数据删不掉、查不全:事务和外键的副作用
现象:删除学生时,提示外键约束失败Cannot delete or update a parent row;查询成绩时,某些学生考了0分,但列表里不显示。
原因:学生表被score表外键引用,直接删学生违反约束。第二个问题是查询时用了WHERE sc.score != 0或者WHERE sc.score > 0,把0分和NULL排除了。NULL成绩表示缺考或未录入,和0分本质不同。
解决:删除学生前,先把该生的成绩表记录删掉,或者改用逻辑删除(加一个is_deleted字段)。查询成绩时,把NULL单独处理,要么显示为“缺考”,要么用COALESCE(sc.score, 0)兜底,但不要写死排除0分的条件。
6. 把系统跑稳:部署、日志与后续演进的一个小技巧
项目写完后,别急着提交。先用Maven打war包,确认mvn clean package无报错,再把war放到Tomcat的webapps目录。部署过程中有一个我反复中招的坑:本地IDE跑得好好的,扔到服务器上就报404,原因往往是context path不同。IDE里访问是localhost:8080/grade,服务器上是localhost:8080/ROOT,所以JSP里所有绝对路径必须用${pageContext.request.contextPath}拼前缀,否则换个部署名就全炸。
排查问题时,不要只看浏览器F12。把Tomcat的logs/catalina.out和localhost.*.log打开,后者的异常堆栈比控制台完整得多。我一般会在DAO层打一条log.info,记录每次查询的参数和耗时,配合JSP页面底部显示的查询毫秒数,能快速定位是SQL慢还是页面渲染慢。这个习惯帮我避开过一次经典翻车:用户反馈“查询学生很慢”,我以为SQL没索引,其实是因为每次请求都重新创建数据库连接,连接池没配。后来改成用Tomcat自带的JNDI连接池,查询从800ms降到30ms。
末了说一个我很坚持的收尾动作:把Servlet的异常统一抛到一个ErrorServlet,而不是在每个页面里塞try-catch。这样用户看到的是友好提示,而完整异常只进日志,既保护了信息,也方便排查。可以把ErrorServlet理解成成绩系统的“防汛通道”——平时不起眼,真出事时全靠它。如果你已经照着这套把系统跑起来,试着加一个小功能:把成绩按课程统计平均分和不及格人数。这个改动会逼你写出GROUP BY的SQL,同时也要改列表页的展示逻辑,正好检验你对前面所有知识的掌握程度。这是我的血泪经验:当年我总想直接上Spring Boot,后来静下心把Servlet手工接了一遍,之后看框架源码再也不虚了。希望帮到你。
本文还有配套的精品资源,点击获取