简介:一套基于Java与MySQL的JavaWeb学生成绩管理系统项目包,面向正在学习Servlet/JSP、MVC分层开发、数据库设计与Web前端协作的开发者,也适合用作课程设计、毕业设计或实训项目的参考范本。压缩包共244个文件、约17.15MB,包含Java源码、class编译文件、JSP页面、JS交互脚本、CSS样式、XML与properties配置、SQL数据库脚本,以及jar依赖包和gif演示文件,能够看到从后端逻辑到前端展示的完整实现。系统覆盖用户登录与角色权限、学生信息与课程管理、成绩录入与查询、统计报表和成绩排名等核心模块,并配有数据库表结构与页面资源,便于梳理数据流转和权限控制思路。包内目录结构清晰,前端静态资源、后端业务代码与数据库脚本分离,方便对照阅读。已有1438人学习下载,适合希望借助完整工程掌握JavaWeb全流程开发、部署与调试的初学者。 最近帮一个学弟把他的JavaWeb课设“学生成绩管理系统”从头到尾捋了一遍,从项目结构到数据库设计再到部署,踩了不少典型的坑。这类系统算是JavaWeb入门阶段最经典的练手项目之一,麻雀虽小五脏俱全,正好能用上Servlet、JSP、JDBC、MySQL这些基础技术栈,也能把三层架构、会话管理、权限控制这些概念串起来。
我基于帮他调试的整个过程,把整个项目的设计思路、核心实现和实际操作中容易出问题的地方整理成这篇文章,给正在做课设或者想练手JavaWeb的朋友一个完整的参考。
1. 系统设计与技术选型
1.1 核心需求拆解
学生成绩管理系统,核心诉求就是管理学生成绩数据。但先把角色理清楚很重要,不同角色的需求完全不一样:
- 管理员:维护班级信息、维护教师账号、维护学生账号、分配班级。
- 教师:录入成绩、修改成绩、查看自己所授课程的成绩统计。
- 学生:查询自己的各科成绩,查看总分和排名。
把这三个角色理清楚之后,功能边界自然就画出来了。这种角色划分其实不只是课设需要,真实企业的权限系统也是这个思路,只是会更细,比如加个教务处角色、年级组长角色之类的。
对于课设项目来说,功能做太多容易失控,做太少又显得单薄。我建议按这个优先级来安排:登录认证是最基础的能力,然后是学生信息的增删改查,再到成绩的录入和查询,最后才是排名统计这种锦上添花的功能。把前三个做扎实,这个项目就能拿得出手了。
1.2 技术栈选择的“为什么”
不少同学纠结要不要上框架,比如SSM或者SpringBoot。我的建议是,如果时间充足且只是想做一个能跑的项目,直接用SpringBoot确实省事,但对于JavaWeb课设来说,建议还是老老实实用Servlet + JSP + JDBC + MySQL这套组合。
- Servlet是JavaWeb的基石,把Servlet的运行机制搞明白,后面学什么框架都事半功倍;直接用框架会把这些核心机制包装掉,课设答辩时被问到请求流程反而容易露怯。
- 不用框架,整个项目完全在自己的掌控中,出现Bug时能顺着调用链一步步排查,这对调试能力的锻炼是框架给不了的。
- 课设的评分通常更看重你对基础原理的理解,而不是用了多新的框架。
服务器选择上,我是用Tomcat 9.x版本,JDK 1.8,开发工具IDEA。这些凑在一起兼容性最成熟,网上资料也最多,遇到问题基本都能查到解决方案。
2. 数据库设计与核心表结构
2.1 表结构设计思路
这一步是整个系统最关键的部分,表设计得好,后面写代码就顺畅;表设计得烂,后面全是血泪。核心表我建议做五张:
- t_user:用户表,存登录账号密码和角色类型。
- t_student:学生信息表,关联用户表和班级表。
- t_teacher:教师信息表,关联用户表。
- t_course:课程表。
- t_score:成绩表。
具体的建表SQL我写出来供参考:
CREATE DATABASE student_grade DEFAULT CHARSET utf8mb4; USE student_grade; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL COMMENT '1-管理员 2-教师 3-学生' ); CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, class_name VARCHAR(50) NOT NULL, student_name VARCHAR(50) NOT NULL, gender CHAR(2), FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_teacher ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, teacher_name VARCHAR(50) NOT NULL, FOREIGN KEY (user_id) REFERENCES t_user(id) ); CREATE TABLE t_course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(50) NOT NULL, teacher_id INT, FOREIGN KEY (teacher_id) REFERENCES t_teacher(id) ); CREATE TABLE t_score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2), exam_date DATE, FOREIGN KEY (student_id) REFERENCES t_student(id), FOREIGN KEY (course_id) REFERENCES t_course(id), UNIQUE KEY uk_student_course (student_id, course_id) );2.2 关键表设计说明
几个容易忽略的点我单独拿出来说:
用户表单独建,而不是把登录信息直接塞进学生表和教师表。这算一个比较经典的设计决策。后续如果要做账号绑定手机号、修改密码、冻结账号这些功能,都只在t_user表上操作,完全不影响业务表。登录认证统一查t_user表,拿到用户ID后根据角色再去查对应的学生表或教师表补全信息。用户表用role字段区分角色,这个方案虽然简单但很实用,权限判断也清晰。
t_score表加了唯一索引(student_id, course_id),这个细节一定要加。不加的话,同一个学生同一门课就可能录进去两条成绩,后面查出来自己都不知道哪条是对的。有了这个唯一索引,重复录入时数据库直接报错,代码里捕获异常再提示用户“该学生这门课已有成绩,请选择修改”。
成绩字段用DECIMAL(5,2),最高9999.99,对于百分制成绩完全够用,也用不着用FLOAT或DOUBLE,容易出精度问题。
3. 后端分层结构与核心逻辑实现
3.1 包结构与分层思想
代码组织上,我采用的是JavaWeb最经典的分层架构,每层的职责非常清晰:
src/ ├── com.grade.entity —— 实体类,对应数据表 ├── com.grade.dao —— 数据访问层,JDBC操作 ├── com.grade.service —— 业务逻辑层 ├── com.grade.servlet —— 控制层,接收请求和响应 ├── com.grade.filter —— 过滤器 ├── com.grade.util —— 工具类 ├── web/WEB-INF/jsp —— JSP页面 └── web/static —— CSS、JS文件太小型的项目分五层确实会显得有点重,所以我把service和dao合并也是可以的,视代码量而定。但我强烈建议至少把Servlet和数据库操作分开,不然所有代码都堆在Servlet里,改一个功能要找半天。
分层的好处其实不只是结构清晰,还有一个很实际的好处——分工。课设如果是两个人做,一个写Servlet控制层,一个写DAO和Service,各改各的代码,冲突少,联调也简单。
3.2 Servlet核心代码示例
控制层我用HttpServlet实现,下面是登录验证的代码片段:
@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); // 参数非空校验 if (username == null || username.trim().isEmpty() || password == null || password.trim().isEmpty()) { request.setAttribute("msg", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } User user = userService.login(username, password); if (user != null) { // 登录成功,将用户信息存入Session HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); // 设置30分钟超时 // 按角色跳转到不同首页 if (user.getRole() == 1) { response.sendRedirect(request.getContextPath() + "/adminIndex"); } else if (user.getRole() == 2) { response.sendRedirect(request.getContextPath() + "/teacherIndex"); } else if (user.getRole() == 3) { response.sendRedirect(request.getContextPath() + "/studentIndex"); } } else { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }几个细节值得说明:
request.setCharacterEncoding("UTF-8")必须放在读取参数之前,否则中文参数直接乱码,这个顺序错了后面写再多都是白费。- 用了
sendRedirect做页面跳转,而不是forward,是为了防止用户刷新页面时重复提交登录表单。 - 登录成功后用户信息放进Session,后面所有页面都可以通过Session获取当前用户。
3.3 会话管理和登录过滤器的实现
如果每一个Servlet都判断用户是否登录,那代码得重复到怀疑人生。用Filter统一处理才是优雅的解法:
@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 放行登录页面和登录接口 String uri = req.getRequestURI(); if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.contains("/static/") || uri.endsWith(".css") || uri.endsWith(".js")) { chain.doFilter(request, response); return; } // 检查Session中是否有用户信息 HttpSession session = req.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(request, response); } else { // 未登录,跳转到登录页 resp.sendRedirect(req.getContextPath() + "/login.jsp"); } } }过滤器里这个“哪些请求放行”的白名单,建议直接写在web.xml配置里,用<init-param>维护,或者直接硬编码在web.xml里,这样调整起来比较方便。
req.getSession(false)这个写法要注意,传false表示如果session不存在就不创建新的,避免攻击者用任意session ID“造”出大量空session占用服务器内存。
3.4 成绩录入与事务处理
成绩系统的核心操作里,录入和修改成绩是最容易出问题的。直接看Service层代码:
public boolean addOrUpdateScore(Score score) throws SQLException { ConnUtils conn = null; PreparedStatement ps = null; ResultSet rs = null; boolean isUpdate = false; try { conn = JdbcUtil.getConnection(); // 开启事务 conn.setAutoCommit(false); // 先查询是否已有成绩 String querySql = "SELECT id FROM t_score WHERE student_id=? AND course_id=?"; ps = conn.prepareStatement(querySql); ps.setInt(1, score.getStudentId()); ps.setInt(2, score.getCourseId()); rs = ps.executeQuery(); if (rs.next()) { // 已有记录,走更新逻辑 isUpdate = true; String updateSql = "UPDATE t_score SET score=?, exam_date=? WHERE student_id=? AND course_id=?"; ps = conn.prepareStatement(updateSql); ps.setBigDecimal(1, score.getScore()); ps.setDate(2, new java.sql.Date(score.getExamDate().getTime())); ps.setInt(3, score.getStudentId()); ps.setInt(4, score.getCourseId()); ps.executeUpdate(); } else { // 没有记录,走新增逻辑 String insertSql = "INSERT INTO t_score(student_id, course_id, score, exam_date) VALUES(?,?,?,?)"; ps = conn.prepareStatement(insertSql); ps.setInt(1, score.getStudentId()); ps.setInt(2, score.getCourseId()); ps.setBigDecimal(3, score.getScore()); ps.setDate(4, new java.sql.Date(score.getExamDate().getTime())); ps.executeUpdate(); } // 提交事务 conn.commit(); return isUpdate; } catch (SQLException e) { // 回滚事务 if (conn != null) { conn.rollback(); } throw e; } finally { JdbcUtil.close(rs, ps, conn); } }一个UpdateOrInsert的逻辑,先select后update或insert,中间用事务包住。这个在并发场景下可能有一点点窗口问题,但作为课设和一般的管理系统完全够用了。这里连接和流资源一定要记得关,我之前见过很多同学的程序明明功能对了但跑一段时间就报Too many connections,基本都是没关连接导致的。
4. JSP页面与前端交互
4.1 页面结构与样式方案
JSP页面这一层,我推荐一个思路——统一布局。用<%@ include %>把页头、页尾、菜单栏都抽离成公共片段,每个功能页只需要写中间内容区域,代码量直接砍半,改起来也方便。
这里是我常用的公共导航片段:
<%-- common/nav.jsp --%> <% User loginUser = (User) session.getAttribute("loginUser"); if (loginUser == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 学生和教师看到不同的菜单 %> <nav class="navbar navbar-default"> <!-- 根据角色显示不同菜单项 --> <ul class="nav navbar-nav"> <% if (loginUser.getRole() == 1) { %> <li><a href="<%=request.getContextPath()%>/adminIndex">首页</a></li> <li><a href="<%=request.getContextPath()%>/studentList">学生管理</a></li> <li><a href="<%=request.getContextPath()%>/teacherList">教师管理</a></li> <li><a href="<%=request.getContextPath()%>/courseList">课程管理</a></li> <% } else if (loginUser.getRole() == 2) { %> <li><a href="<%=request.getContextPath()%>/teacherIndex">首页</a></li> <li><a href="<%=request.getContextPath()%>/scoreInput">成绩录入</a></li> <li><a href="<%=request.getContextPath()%>/scoreStatistic">成绩统计</a></li> <% } else if (loginUser.getRole() == 3) { %> <li><a href="<%=request.getContextPath()%>/studentIndex">首页</a></li> <li><a href="<%=request.getContextPath()%>/myScore">我的成绩</a></li> <% } %> </ul> </nav>菜单里有个小坑——这种写在公共页里的Java代码,如果页面被直接访问时改路径没走Servlet,session.getAttribute就可能是null,建议还是在过滤器维护统一权限再渲染,不要依赖JSP里这段判断。
4.2 成绩录入页面交互设计
成绩录入页面用动态行比较好用,点击“添加一行”按钮就会新增一行输入框,教师在表格里填成绩。因为是多个学生一次录入,表单的name要带上学生ID,例如score[5]=88这种。Servlet端收到的是一组带同名key的参数,用request.getParameterValues("score")获取会简单很多,配合学生ID一起遍历保存就行。
前端校验也建议做一下——成绩范围要限定0~100,非数字直接拦截,不然非法数据传到后端,数据库校验报错再回滚,体验很差。但前端校验只是第一步,后端必须做二次校验,因为请求可以绕过页面直接构造。这块的思路跟登录时非空判断是一样的,别信前端传过来的任何东西。
4.3 成绩查询的SQL拼接
在查询模块,经常遇到多条件组合查询的情况,比如教师想查“某门课、某个班、分数大于等于60分”的学生列表。最开始做的时候容易犯的一个错误是一直用Statement拼SQL,遇到单引号就崩。后来统一改成PreparedStatement和动态拼接参数的方式,用ArrayList<Object>收集参数值,然后展开设值,清晰又安全。
public List<ScoreVO> queryScores(Integer courseId, String className, Double minScore) { StringBuilder sql = new StringBuilder(); sql.append("SELECT s.student_name, sc.class_name, c.course_name, sc.score "); sql.append("FROM t_score sc "); sql.append("JOIN t_student s ON sc.student_id = s.id "); sql.append("JOIN t_course c ON sc.course_id = c.id "); sql.append("WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (courseId != null) { sql.append("AND sc.course_id = ? "); params.add(courseId); } if (className != null && !className.isEmpty()) { sql.append("AND s.class_name LIKE ? "); params.add("%" + className + "%"); } if (minScore != null) { sql.append("AND sc.score >= ? "); params.add(minScore); } // 执行查询,动态设置参数 return jdbcTemplate.query(sql.toString(), params.toArray()); }这里用WHERE 1=1拼接很常见,虽然看起来有点笨但确实好使,后面的AND条件写起来就不用再去判断是不是第一个条件了。多表JOIN查询在成绩管理这种系统里是常态,要养成用别名(s、sc、c)的习惯,SQL一长别名就是救命稻草。
5. 项目部署与发布流程
5.1 部署到Tomcat的两个常见方式
用IDEA内置Tomcat直接跑Web应用是最快的,适合开发阶段调试。但答辩或者交付的时候,一般是导出一个WAR包扔到独立的Tomcat里。步骤如下:
- IDEA菜单Build,Build Artifacts,选
grade:war exploded或grade:war。 - Build完在输出目录里找到
xxx.war文件。 - 把这个WAR包复制到Tomcat的
webapps目录下。 - 启动Tomcat,它会自动解压WAR包,就能通过
http://localhost:8080/项目名/xxx访问了。
如果配置了数据源,要确认context.xml里数据库连接的账号密码对得上,不然启动不报错,但一登录就报数据库连接失败。这个问题坑过很多人,建议在init方法里或者某个专门测试的JSP页面打印出连接是否成功,几分钟就能定位。
5.2 常见部署问题解决:HTTP 404与500
部署时最经典的报错有两个:
- 404表示资源不存在,大概率是访问路径写错了,或者WAR包没有成功部署。排查思路是先确认项目有没有成功解压,再确认Web.xml里配置的映射路径和实际访问路径是否一致。
- 500表示服务器内部异常,多半是代码抛了运行时异常。看Tomcat的
logs/catalina.out日志最有效,哪一行报错直接就能看到。
还有一个隐蔽的问题:JSP编译失败也会显示500,但日志里不会直接显示JSP文件路径,而是在logs/localhost.yyyy-MM-dd.log里。曾经遇到一个JSP文件里多写了一个%>字符,编译报错的位置不在自己写的代码里,排查了十几分钟,后来一查localhost日志秒定位。
6. 开发过程中的典型问题与排查技巧
6.1 中文乱码问题全解决
乱码基本上是JavaWeb新手遇到的第一个大坎,核心就一句话:所有环节的编码必须统一成UTF-8。
按出现的位置拆成三类,排查就方便了:
| 乱码类型 | 产生原因 | 解决方法 |
|---|---|---|
| JSP页面显示乱码 | JSP文件编码不是UTF-8 | 在<%@ page pageEncoding="UTF-8" %>基础上,确认文件本身是UTF-8保存 |
| 请求参数乱码 | 客户端提交的编码和服务器解析不一致 | POST请求在读取参数前request.setCharacterEncoding("UTF-8");GET请求修改Tomcat的server.xml,给Connector加URIEncoding="UTF-8" |
| 数据库存储乱码 | 数据库或表不是UTF-8 | 建库时加DEFAULT CHARSET utf8mb4,连接URL后面加useUnicode=true&characterEncoding=UTF-8 |
GET请求的中文参数乱码,在较新版本的Tomcat里默认编码是UTF-8所以一般没问题,老版本则必须加URIEncoding="UTF-8"。建议还是用POST传中文,省心。
6.2 数据库连接不够用与驱动加载失败
程序跑一段时间就提示Too many connections,十有八九是凌晨或循环里忘了关连接。我之前调试的时候因为一个finally里漏了conn.close(),连续发了100次请求,数据库连接数直接飙到上限,教学环境里的MySQL直接罢工。排查方法也简单:看MySQL的show processlist;命令,如果一大片Sleep和Sending data状态,基本就是连接泄漏。
另一个高频错误是ClassNotFoundException: com.mysql.jdbc.Driver,一般出现在部署阶段而不是开发阶段,原因是开发环境里IDEA自动把MySQL的JAR包带进来了,但WAR包构建时没包含。排查方式很简单——打开WAR包,看WEB-INF/lib里有没有mysql的JAR,没有就加上。
6.3 数据权限控制容易越权
有时候学生点了“成绩管理”菜单,居然能看到其他学生的成绩,甚至能访问/studentListURL直接拉到所有学生信息。这个问题的本质就是权限控制只做了菜单显示层,没做接口访问层。
解决办法是在Filter里增加角色-URL的映射校验:管理员只能访问/admin*下的路径,教师只能访问/teacher*和成绩相关的接口,学生只能访问/student*和自己的成绩查询接口。这个校验写进过滤器里,统一管理。
Servlet中再通过session.getAttribute("loginUser")拿到的User对象里的role字段二次判断,双保险。这个点在课设答辩时是加分项,因为说明你考虑到了安全问题。
7. 总结与体会:这些坑我帮你踩过了
最后说几个实际开发中的心得,都是从我和学弟一起改代码的过程里总结出来的。
不要一上来就写代码。先把表结构想清楚、画好ER图,哪些字段需要唯一约束,哪些字段可能要做模糊查询,先设计后编码,能省掉后面重写的大量时间。
学会看日志。很多人报错第一反应是把报错贴到搜索引擎,但实际上Tomcat日志里已经写得很清楚了,哪一行、哪个类、哪个方法出的错,日志读明白了比搜半天页面有用得多。排查问题最快路径永远是:先看日志,再想原因,最后修代码。
测试数据要真实。别只用test1、test2这种数据测试,至少造一两套接近真实的数据(真实学生姓名+多门成绩+跨班级),才能发现排序、分组、联查这些问题。
提交之前把Tomcat重启一遍。开发时代码热部署可能会掩盖掉一些状态残留问题,重启之后如果一切正常,才算真的没问题。
后端方面,基础不牢的话,推荐把“List遍历、Map存放、字符串拼接、日期处理”这些基本功先补扎实,因为写Servlet和JSP真的处处都用得到。前端方面,能就着Bootstrap写个看得过去的页面就行,不用刻意花大量时间在前端美化上,重点还是业务逻辑和数据流转。
这套项目做完,Servlet请求流程、Session管理、JDBC操作、MVC分层这些JavaWeb的核心能力基本就都掌握了,后面去学SpringBoot会轻松得多。
本文还有配套的精品资源,点击获取