简介:基于Java技术栈的学生成绩管理系统项目,整合Servlet、JSP、Bootstrap与MySQL,适用于Java Web初学者、在校学生及毕业设计人员,可解决成绩数据存储、管理与查询效率低下的问题。系统覆盖学生信息管理、课程成绩录入与修改、按姓名/课程/学期多条件查询、平均分及及格率统计、数据备份与恢复等完整功能,并采用MVC设计模式将业务逻辑、数据访问与页面展示清晰分层。压缩包共575个文件(9.17MB),主要包含85个Java源码、74个JSP页面、170个编译后的class文件,以及JS/CSS/HTML等前端资源、SQL数据库脚本和jar运行依赖库;其中class为编译产物、java为源码、jsp为动态页面、sql为初始化脚本,目录结构便于定位。已有3117人学习下载。项目从Servlet请求处理、JSP动态页面渲染到Bootstrap响应式布局、MySQL数据库操作均有可运行代码,完整展示了Java Web开发全流程,可直接作为课程设计或毕业设计的参考模板,也是巩固Servlet/JSP基础与MVC实践的实用素材。
1. 这套学生成绩管理系统,为什么值得照着拆一遍
前几天帮一位读者排查他课程设计里的问题,他拿着一个打着 SSM 标签的项目问我:为什么启动报 500?我打开一看,Spring、SpringMVC、MyBatis 全配了,但连个最基本的成绩查询都跑不通。相比之下,这套基于 Java + Servlet + JSP + Bootstrap + MySQL 的学生成绩管理系统反而清爽得多:结构清晰、依赖极少、跑起来不玄学。它不是什么高深框架,但它把 Java Web 最核心的那套东西——请求怎么进来、数据怎么存取、页面怎么渲染——全串起来了。适合正在做课程设计、毕业设计的人,也适合准备 Java 面试、想把 Servlet 生命周期弄清楚的人。先说结论:这个系统源码能拿到手,照着跑一遍,你对 Java Web 的整体认知会比刷十篇博客都扎实。
2. 先看骨架:项目结构和 Servlet 的请求流转
2.1 目录结构与 MVC 分层逻辑
这个项目从类名上就能看出典型的 MVC 分层思路:ScoreDaoImpl、CourseDaoImpl 是数据访问层,ServletUploadFile 是文件上传的控制器,再加上常规的 entity、service、controller 包。我拿到手的第一步不是急着启动,而是先把目录结构过一遍,确认代码往哪里放。
src/main/java/ ├── controller/ # Servlet 层,接收请求、调 service、跳转页面 │ ├── StudentServlet.java │ ├── ScoreServlet.java │ └── ServletUploadFile.java ├── service/ # 业务逻辑层,事务边界在这里 │ ├── StudentService.java │ └── ScoreService.java ├── dao/ # 数据访问层 │ ├── impl/ │ │ ├── StudentDaoImpl.java │ │ ├── ScoreDaoImpl.java │ │ └── CourseDaoImpl.java │ └── StudentDao.java ├── entity/ # 实体类,对应数据库表 │ ├── Student.java │ ├── Course.java │ └── Score.java └── util/ ├── DBUtil.java # JDBC 连接工具 └── StringUtil.java src/main/webapp/ ├── jsp/ # 页面文件 │ ├── studentList.jsp │ ├── scoreList.jsp │ └── scoreAdd.jsp ├── static/ # Bootstrap、jQuery、CSS 等静态资源 └── WEB-INF/ └── web.xml这个结构里最值得关注的是 DAO 层独立出来了。Java Web 课程设计里常见的问题是所有 JDBC 代码全都堆在 Servlet 里,页面一多就变成一座没人敢动的屎山。这个项目把数据库操作收拢到 DaoImpl 里,Servlet 只做参数接收和页面跳转,后期改 SQL 不需要动控制器代码。
2.2 Servlet 映射的两种配置方式
拿到项目后你需要确认 Servlet 是用注解还是 web.xml 配置的。两种方式在简历上都叫“配置 Servlet 映射”,但工作里两种项目都可能碰到。这个项目用的方式,看 web.xml 就能定位,我一般在代码里两种都保留注释。
// 注解方式,Servlet 3.0+ @WebServlet("/score/list") public class ScoreServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取请求参数:页码、关键字、学号、课程ID String pageNoStr = request.getParameter("pageNo"); String studentName = request.getParameter("studentName"); // 2. 调用业务层查询 ScoreService service = new ScoreService(); PageResult<ScoreVO> page = service.queryPage(pageNo, pageSize, studentName); // 3. 把数据放到 request 作用域,转发给 JSP 渲染 request.setAttribute("page", page); request.getRequestDispatcher("/jsp/scoreList.jsp").forward(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 新增和修改走 POST doGet(request, response); } }这段代码把 Servlet 的职责说得非常清楚:拿参数、调服务、存结果、转页面。注意 doPost 里直接复用了 doGet,这个做法在查询类接口里很常见,但新增和修改接口不要这么干,等下发 GET 请求来提交数据,数据会穿进 URL 里,既难看又不安全。
对应的 web.xml 配置则写在 WEB-INF 目录下:
<servlet> <servlet-name>scoreServlet</servlet-name> <servlet-class>com.example.controller.ScoreServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>scoreServlet</servlet-name> <url-pattern>/score/list</url-pattern> </servlet-mapping>两种方式唯一的区别是注册位置不同:注解方式写在 Java 文件里,web.xml 方式集中在 XML 里。我建议你以注解为主,web.xml 只保留 ServletContextListener 之类的全局配置,代码和配置离得近,排查起来少翻一层。
2.3 JSP 和 Servlet 之间的数据交换约定
Servlet 处理完业务后,数据怎么交给 JSP?整个项目里你会反复看到三种写法,记住它们的区别就能看懂所有页面。
// 写法一:请求转发,地址栏不变,request 作用域的数据可以带到 JSP request.setAttribute("studentList", studentList); request.getRequestDispatcher("/jsp/studentList.jsp").forward(request, response); // 写法二:重定向,地址栏变化,需要重新发一次请求,request 里的数据会丢 response.sendRedirect(request.getContextPath() + "/score/list"); // 写法三:Session 作用域,登录信息、当前用户放这里 HttpSession session = request.getSession(); session.setAttribute("currentUser", loginUser);注意:forward 和 redirect 是 Java Web 面试里必问的题,这个项目里两种都在用,你可以对比看效果。常见翻车点是修改完数据后用了 forward,刷新页面会重复提交表单。
JSP 那边接收数据的固定套路是 JSTL + EL 表达式,这个项目里 bootstrap 负责样式,JSTL 负责循环输出表格行:
<c:forEach items="${page.list}" var="score"> <tr> <td>${score.studentName}</td> <td>${score.courseName}</td> <td>${score.score}</td> </tr> </c:forEach>EL 表达式里取的是 request.setAttribute 的 key,key 写错就是一片空白,这是后面避坑章节要重点讲的第一个问题。项目里 JSP 页面在 webapp/jsp 目录下,而静态资源在 static 目录,页面里引用 Bootstrap 用的是相对路径,部署的时候注意上下文路径,别写死成绝对路径。
3. 数据库设计:三张表、JDBC 连接、分页查询
3.1 成绩管理系统的核心表结构
成绩管理系统的数据模型不复杂,核心就是学生、课程、成绩三张表。成绩表与另外两张表通过外键关联,这是最经典的选型——不要为了“看起来高级”把成绩表拆成雪花模型,在这个场景没必要。具体字段设计如下:
| 表名 | 字段名 | 类型 | 说明 |
|---|---|---|---|
| student | id | INT PK AUTO_INCREMENT | 学生ID |
| student_no | VARCHAR(20) UNIQUE | 学号 | |
| name | VARCHAR(50) | 姓名 | |
| gender | CHAR(2) | 性别 | |
| clazz | VARCHAR(50) | 班级 | |
| course | id | INT PK AUTO_INCREMENT | 课程ID |
| course_name | VARCHAR(50) | 课程名称 | |
| credit | DECIMAL(3,1) | 学分 | |
| score | id | INT PK AUTO_INCREMENT | 成绩记录ID |
| student_id | INT | 外键,指向 student.id | |
| course_id | INT | 外键,指向 course.id | |
| score | DECIMAL(5,2) | 成绩 | |
| semester | VARCHAR(20) | 学期,如 2024-2025-1 |
需要注意的一个设计细节是:学号加了 UNIQUE 约束,性别字段用 CHAR(2) 存“男/女”两个字,不要用 TINYINT 存 0/1,省得每次查出来还要翻译。成绩字段用 DECIMAL(5,2),能覆盖 0 到 999.99 的范围,比 FLOAT 更精确,MySQL 里浮点类型做等值比较容易踩坑,成绩这种数据要精确到小数点后两位,DECIMAL 是正确选择。
3.2 JDBC 连接管理:MySQL8 的驱动与连接串
项目用的是 JDBC 直连 MySQL。JDBC 连接管理是 Java Web 的底层功,虽然 MyBatis 帮你封装了大部分操作,但连接串、驱动、事务这些概念搞不懂,用框架出了问题照样抓瞎。数据库配置一般放在 src/main/resources/db.properties:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/student_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=123456MySQL 5.x 和 8.x 的驱动类名不一样,5.x 用 com.mysql.jdbc.Driver,8.x 用 com.mysql.cj.jdbc.Driver。如果你导入的是 8.x 的驱动 jar 却写旧类名,启动直接报 ClassNotFoundException。连接串里的 serverTimezone=Asia/Shanghai 是必须的,不写的话 MySQL 8 会报时区错误,后面避坑章节详细讲。
对应的连接工具类 DBUtil.java:
public class DBUtil { private static String driver; private static String url; private static String username; private static String password; static { try { // 读取 db.properties 配置文件 InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties"); Properties props = new Properties(); props.load(in); driver = props.getProperty("jdbc.driver"); url = props.getProperty("jdbc.url"); username = props.getProperty("jdbc.username"); password = props.getProperty("jdbc.password"); Class.forName(driver); } catch (Exception e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 按逆序关闭,先关 ResultSet,再关 PreparedStatement,最后关 Connection } }代码块里的静态块负责加载驱动,读取配置文件。getConnection 每次调用都建立新连接,课程设计和毕设这个规模足够了,但生产环境不可能这么干,那就要换成连接池。你把这个类放在 util 包里,所有 DaoImpl 都通过它拿连接,后续要换连接池,只需要改这一个文件的方法实现,上层不用动。
3.3 分页查询的 SQL 与参数计算
成绩列表页不可能一次把所有数据查出来,所以项目里做了分页。分页的 SQL 用 LIMIT 实现,前端传页码和后端算偏移量是关键。
-- 查询第 2 页,每页 10 条 SELECT s.name AS student_name, c.course_name, sc.score, sc.semester FROM score sc JOIN student s ON sc.student_id = s.id JOIN course c ON sc.course_id = c.id ORDER BY sc.id DESC LIMIT 10, 10; -- 查询总记录数,用于计算总页数 SELECT COUNT(*) FROM score;LIMIT 的两个参数含义要理解正确:第一个是偏移量,第二个是每页条数。偏移量的计算是(pageNo - 1) * pageSize,这是新手的经典翻车点,经常有人写完页码一翻就少数据。总页数用公式(total + pageSize - 1) / pageSize,这个向上取整的写法能避免总记录数刚好整除时分页多出一页空页。
ScoreDaoImpl 里对应的方法是:
public List<ScoreVO> queryPage(String studentName, int pageNo, int pageSize) { // 计算偏移量 int offset = (pageNo - 1) * pageSize; StringBuilder sql = new StringBuilder(); sql.append("SELECT s.name AS student_name, c.course_name, sc.score, sc.semester "); sql.append("FROM score sc "); sql.append("JOIN student s ON sc.student_id = s.id "); sql.append("JOIN course c ON sc.course_id = c.id "); sql.append("WHERE 1=1 "); List<Object> params = new ArrayList<>(); if (studentName != null && !studentName.trim().isEmpty()) { sql.append("AND s.name LIKE ? "); params.add("%" + studentName + "%"); } sql.append("ORDER BY sc.id DESC LIMIT ?, ?"); params.add(offset); params.add(pageSize); // 执行查询,setObject 逐个填充参数 }注意 SQL 拼接里那个WHERE 1=1。有人觉得这写法不优雅,但在动态条件查询里它有个实际价值:后续所有条件都用AND xxx = ?直接拼接,不需要判断当前是不是第一个条件,代码少写三分支,可读性反而好。参数全用?占位符,通过 PreparedStatement 的 setObject 填充,这是防 SQL 注入的标准做法,千万别把参数直接拼进 SQL 字符串。
4. 核心功能模块实战:从表单到数据库的完整链路
4.1 成绩录入:AddScore 的事务控制
成绩录入是整个系统的核心操作,涉及检查学生是否存在、检查课程是否匹配、插入成绩三个步骤。这三个步骤必须放在同一个事务里,否则会出现“学生不存在但成绩插入成功”这种脏数据。
ScoreDaoImpl.addScore 方法的关键代码:
public boolean addScore(Score score) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); // 开启事务,关闭自动提交 conn.setAutoCommit(false); // 第一步:检查学生是否存在 ps = conn.prepareStatement("SELECT id FROM student WHERE id = ?"); ps.setInt(1, score.getStudentId()); rs = ps.executeQuery(); if (!rs.next()) { return false; // 学生不存在,直接返回,事务不提交 } // 第二步:检查课程是否存在 ps = conn.prepareStatement("SELECT id FROM course WHERE id = ?"); ps.setInt(1, score.getCourseId()); rs = ps.executeQuery(); if (!rs.next()) { return false; } // 第三步:插入成绩记录 ps = conn.prepareStatement( "INSERT INTO score (student_id, course_id, score, semester) VALUES (?, ?, ?, ?)"); ps.setInt(1, score.getStudentId()); ps.setInt(2, score.getCourseId()); ps.setBigDecimal(3, score.getScore()); ps.setString(4, score.getSemester()); ps.executeUpdate(); // 所有步骤都成功了,提交事务 conn.commit(); return true; } catch (Exception e) { e.printStackTrace(); try { // 出错回滚,之前执行的语句全部撤销 if (conn != null) conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } return false; } finally { DBUtil.close(conn, ps, rs); } }事务控制的重点写在注释里了:setAutoCommit(false) 是事务开始的标志,commit() 在所有操作成功后调用,rollback() 在 catch 块里执行。JDBC 默认是每条 SQL 自动提交,不加事务的话,第一步检查通过了,第二步插入失败,数据就缺了一条,将来统计成绩时这个学生会莫名其妙少一门课。
4.2 成绩查询:多条件组合与 SQL 注入防护
查询功能的坑在“多条件组合”上。学生可能只输入姓名查,也可能只选课程查,还可能什么都不输入直接点查询按钮。ScoreServlet 中接收参数后统一处理:
// ScoreServlet.java 的 doGet 方法片段 String studentName = request.getParameter("studentName"); String courseId = request.getParameter("courseId"); String semester = request.getParameter("semester"); // 默认第一页 int pageNo = 1; String pageNoStr = request.getParameter("pageNo"); if (pageNoStr != null && !pageNoStr.isEmpty()) { pageNo = Integer.parseInt(pageNoStr); } // 默认每页 10 条 int pageSize = 10; // 调 service 层查询,得到一个分页结果对象 PageResult<ScoreVO> result = scoreService.queryPage(studentName, courseId, semester, pageNo, pageSize); request.setAttribute("pageResult", result); request.getRequestDispatcher("/jsp/scoreList.jsp").forward(request, response);这里参数为空和参数为 null 是两种不同情况。前端表单没填的输入框,提交过来是空字符串;URL 上根本没带这个参数,getParameter 返回 null。DAO 层判断时要先判 null 再判空字符串,顺序不能反:if (studentName != null && !studentName.trim().isEmpty()),有些人只写了!= null,结果空字符串条件也拼进了 SQL,查出来一个空名字的模糊匹配,数据量一大直接拖垮查询。
对应的 DAO 层动态拼接逻辑是这样的:
public List<ScoreVO> queryByCondition(String studentName, String courseId, String semester, int pageNo, int pageSize) { StringBuilder sql = new StringBuilder(); List<Object> params = new ArrayList<>(); sql.append("SELECT s.student_no, s.name, c.course_name, sc.score, sc.semester "); sql.append("FROM score sc "); sql.append("JOIN student s ON sc.student_id = s.id "); sql.append("JOIN course c ON sc.course_id = c.id "); sql.append("WHERE 1=1 "); if (studentName != null && !studentName.trim().isEmpty()) { sql.append("AND s.name LIKE ? "); params.add("%" + studentName.trim() + "%"); } if (courseId != null && !courseId.isEmpty()) { sql.append("AND sc.course_id = ? "); params.add(Integer.parseInt(courseId)); } if (semester != null && !semester.isEmpty()) { sql.append("AND sc.semester = ? "); params.add(semester); } sql.append("ORDER BY sc.id DESC LIMIT ?, ?"); params.add((pageNo - 1) * pageSize); params.add(pageSize); // PreparedStatement 填充参数 }注意:任何从客户端拿来的参数,都必须经过白名单校验或者 PreparedStatement 参数化。这个项目里所有条件值都用
?占位符,你如果拿到别的源码,看到字符串用加法拼进 SQL 的,直接划走。
4.3 Bootstrap 模态框与 jQuery 的异步交互
前端部分 Bootstrap 负责视觉,jQuery 负责交互。成绩录入页面最常见的交互是:点“新增成绩”按钮弹出模态框,填完表单点“保存”,Ajax 提交到后端,成功后刷新表格。这个流程的代码如下:
<!-- 新增成绩模态框 --> <div class="modal fade" id="addScoreModal" tabindex="-1" role="dialog"> <div class="modal-dialog" role="document"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">新增成绩</h5> <button type="button" class="close">// 页面加载完成后绑定保存按钮事件 $(function () { $("#saveScoreBtn").on("click", function () { // 收集表单数据 var formData = $("#addScoreForm").serialize(); // 校验:前端只做基本校验,后端必须再次校验 if ($("select[name='studentId']").val() === "") { alert("请选择学生"); return; } $.ajax({ url: contextPath + "/score/add", type: "POST", data: formData, dataType: "json", success: function (res) { if (res.code === 200) { alert("录入成功"); // 关闭模态框并刷新列表 $("#addScoreModal").modal("hide"); location.reload(); } else { alert(res.msg); } }, error: function () { alert("网络异常,请稍后重试"); } }); }); });这个交互里有个小细节,input type="number"设置了 step="0.5",意味着成绩可以录 85.5 这种小数点后一位的数。后端 DAO 用了 BigDecimal 接收,前端 number 类型和后端 BigDecimal 能对齐,如果你用 float 接收,就会出现 85.5 变成 85.499999 的问题。Bootstrap 模态框第一次打开正常,第二次打开没有数据,多半是模态框 DOM 被重复初始化了,解决方法是把模态框的 HTML 写在页面底部且只写一份。
5. 避坑手册:这五个问题占了这个项目八成以上的报错
5.1 MySQL 驱动加载失败与时区报错
现象:Tomcat 启动后访问任意页面,控制台报 ClassNotFoundException 或者 SQLException 提示 The server time zone value '�й���ʱ��' is unrecognized,页面 500。
原因:这个坑有两个源头。第一,MySQL 5.x 和 8.x 的 JDBC 驱动类名不同,5.x 用 com.mysql.jdbc.Driver,8.x 用 com.mysql.cj.jdbc.Driver,装错 jar 用错类名就报找不到类;第二,MySQL 8 对连接串有时区校验,连接串里没指定 serverTimezone 就会报时区不识别,而中文操作系统的默认时区会变成乱码。
解决:确认 lib 目录下是哪个版本的 mysql-connector-java,驱动类名和驱动版本对齐;连接串固定带上?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true这一段。allowPublicKeyRetrieval 是 MySQL 8 才需要的,不加在某些版本下会报 Public Key Retrieval is not allowed。
5.2 中文乱码:POST 和 GET 的字符集要分开处理
现象:从页面新增一个叫“张三”的学生,保存后再查出来变成“å¼ ä¸‰”,控制台打印的参数也是乱码。
原因:HTTP 请求的字符集处理分两条线。POST 请求的字符集由 request.setCharacterEncoding("UTF-8") 决定,需要在读取任何参数之前调用;GET 请求的参数在 URL 上,Tomcat 8 之前默认用 ISO-8859-1 解码,Tomcat 8 之后默认 UTF-8。如果你把项目部署在老版本 Tomcat 上,GET 请求的中文参数必乱。
解决:加一个 CharacterEncodingFilter 统一处理,在 web.xml 里配置 filter 映射:
<filter> <filter-name>encodingFilter</filter-name> <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>如果你不想引入 Spring 的类,自己写个 filter 也很快,核心就一行代码:request.setCharacterEncoding("UTF-8")。写完后别忘确认三点:数据库连接的 characterEncoding=utf8、JSP 页面顶部<%@ page contentType="text/html;charset=UTF-8" %>、HTML 里 meta 标签 charset 是 UTF-8,三者缺一不可。
5.3 JSP 页面 EL 表达式全部空白
现象:页面结构、表格、按钮都渲染出来了,但是学生姓名、成绩这些动态数据位置全是空的,控制台也不报错。
原因:最常见的两个原因。一是 JSP 页面开头少了<%@ page isELIgnored="false" %>,或者 web.xml 里配置了 Servlet 2.3 的 DTD,那个版本默认 EL 不启用;二是 request.setAttribute 的 key 和 JSP 里 EL 表达式写的 key 不一致,比如 Servlet 里放的 studentList,JSP 里写${students},取出来当然是空。
解决:先打开 JSP 看头部有没有 isELIgnored 设置,没写就补上;再对比 Servlet 里的 setAttribute key 和页面里的 EL 变量名。排查的时候我习惯在页面临时加一行${pageScope},能输出当前请求作用域里所有对象,一眼就能看出数据到底在不在、key 叫什么。
5.4 Bootstrap 模态框点保存按钮出现重复提交
现象:新增成绩点了两次保存,数据库里出现两条一模一样的记录。
原因:这个坑藏在 JavaScript 事件绑定里。某些写法在模态框每次打开时重新绑定一次 click 事件,第二次打开时按钮上一个事件没解绑又绑了一个,点一下执行两次。另一个原因是表单提交后没有禁用按钮或移除事件,用户手快连点两下。
解决:把事件绑定放在$(function(){})初始化函数里,只绑一次;或者在保存按钮点击后立即加上$(this).prop("disabled", true),等 Ajax 返回成功再恢复。更稳妥的做法是提交成功后直接location.reload()刷新整个页面,让所有状态重置,这个处理最简单也最不容易出问题。
5.5 上传的文件重启 Tomcat 就丢了
现象:项目里有文件上传功能,上传成功后本地能看到文件,但一重启 Tomcat 文件就消失,或者项目重新部署后文件全没了。
原因:这个项目里 ServletUploadFile 处理文件上传,如果代码里把文件保存到了 Tomcat 的临时目录或者项目发布目录下,Tomcat 重启时会清理临时目录,重新部署时会覆盖整个 webapps 目录,保存的文件自然就没了。
解决:文件要保存到 Tomcat 之外的固定路径,比如 Windows 下的 D:/upload,Linux 下的 /data/upload。常见做法是在服务器上建一个独立目录,通过配置文件配置上传路径,页面访问时用虚拟目录映射:
<Context docBase="D:/upload" path="/upload" reloadable="true"/>这段配置加在 Tomcat 的 server.xml 的 Host 节点下,就能把 D:/upload 目录映射成 /upload 虚拟路径,页面里通过/upload/文件名访问到磁盘上的文件。课程设计里很多人忽略这一点,答辩时演示上传没问题,老师一重启项目文件就没了,相当尴尬。
6. 部署验收与升级路线:把系统跑起来并验证核心链路
6.1 打包部署到 Tomcat 的两种方式
拿到源码后第一件事是让它跑起来。这个项目是传统 JSP 项目,部署方式和 Spring Boot 不一样,需要打包成 war 放进 Tomcat 的 webapps 目录。用 IDEA 打 war 包的操作是:File -> Project Structure -> Artifacts -> 点加号选 Web Application: Archive -> 勾选 Build on make -> 然后 Build -> Build Artifacts,产物在项目根目录的 out/artifacts 下。
# 把 war 包复制到 Tomcat 的 webapps 目录下 cp target/student-score-manager.war $CATALINA_HOME/webapps/ # 启动 Tomcat $CATALINA_HOME/bin/startup.sh # 查看启动日志 tail -f $CATALINA_HOME/logs/catalina.out启动完成后,访问地址是http://localhost:8080/student-score-manager/,注意项目名一定要对上,部署多个应用时访问路径是 war 包名。如果 404,先看 catalina.out 里项目有没有成功部署,再确认 URL 里的上下文路径和 war 包名一致。IDEA 里也可以配置 Tomcat 直接跑,但找工作面试聊到部署,war 包手动部署这一套得会,很多小公司生产环境还在用这种方式。
6.2 核心功能验收清单
系统跑起来后,按下面的清单走一遍,能覆盖这个项目主要的业务逻辑:
| 功能路径 | 操作 | 预期结果 |
|---|---|---|
| 登录 | 输入管理员账号密码 | 进入系统主页,Session 中保存用户信息 |
| 学生管理 | 新增学生张三,班级 软件2301 | 列表中出现张三,刷新后仍在 |
| 成绩录入 | 给张三录一门 Java 课程成绩 85.5 | 成绩表新增一条记录,无报错 |
| 多条件查询 | 按姓名“张”查 | 只显示姓张的学生成绩,分页正常 |
| 统计页面 | 查看某课程的平均分、及格率 | 数据与手算结果一致 |
| 文件上传 | 上传一张学生照片 | 重启 Tomcat 后照片仍能访问 |
| 退出登录 | 点击退出 | 返回登录页,直接访问主页被拦截 |
走完这个清单,项目的主要功能就验证完成了。这里想提一个判断源码质量的方法:功能能跑只是底线,你还需要确认事务有没有加、SQL 参数有没有用占位符、重要操作有没有日志记录。这三个点决定了一个项目是课程设计还是能上生产。
6.3 后续升级路线
如果你想把这份源码变成自己的毕设或者面试项目,有两条升级路径值得考虑。第一条是轻量改造,保持 Servlet + JSP 骨架不变,把 JDBC 换成连接池。阿里开源的 Druid 或者 HikariCP 在 pom.xml 加依赖,把 DBUtil 里 DriverManager.getConnection 换成 DataSource.getConnection,项目性能立刻不同。第二条是逐步替换,保持功能不变,把 DAO 层换成 MyBatis,让 SQL 从 Java 代码里解放出来,写 XML 映射文件,方便后期调优。这条路线可以让你在面试里说“我用传统的 JDBC 做过,也用过 MyBatis,能讲清楚两者的适用边界”。
升级的时候有个执行顺序:先加日志框架,再换连接池,最后动 DAO 层。日志标记是从“能跑”到“可维护”的分水岭,打印参数和 SQL 对排查问题帮助很大。系统上线前把 System.out.println 全部换成 logback 或 log4j,这是一个 Java 开发者的基本功。
这个项目我从拿到到完全跑通花了不到一个小时,其中半小时在处理 MySQL 时区和驱动版本的问题。从那以后,我每拆一个 Java Web 项目,第一件事就是先看 db.properties 和 pom.xml / lib 目录,确认驱动版本和连接串对得上,再去启动 Tomcat,省掉了大半的黑匣子疑惑。项目的骨架你已经清楚了,照着上面的步骤跑一遍,希望帮到你。
本文还有配套的精品资源,点击获取