ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java Web学生成绩管理系统全流程开发实战:从表结构到部署避坑

2026/10/3 9:07:18 拓冰建站 浏览量
Java Web学生成绩管理系统全流程开发实战:从表结构到部署避坑 在Java Web的项目清单里学生成绩管理系统绝对算得上最经典的练手题目之一。用Java MySQL做一套Web成绩管理说难不难但想做得像样从需求拆解、表结构设计到权限控制和成绩统计每一环都有讲究。这篇文章我就把整条链路拆开讲透适合正在做课设、毕设或者单纯想拿个完整项目练手的人直接参考。我见过很多同学的第一版设计功能清单写得满满当当管理员管学生、管老师、管班级、管课程老师录成绩、改成绩学生查成绩、看排名再加上导出Excel、成绩统计图、消息通知……结果数据库建了十几张表写到一半自己先绕晕。成绩管理系统真正的难点不在于功能多而在于把最小闭环做扎实账号能登录、角色权限分得清、成绩进得去、查得出、改得动。下面我按一套完整可跑通的方案来讲每一步说清楚为什么这么设计顺便把网上教程很少提的坑一并讲出来。1. 需求边界这个系统到底要管哪些人、哪些事1.1 三类角色与核心业务场景学生成绩管理系统首先要搞清楚“谁在用”。我建议第一版只做三类角色管理员、教师、学生。很多同学一开始就把角色设计得很细什么班主任、任课老师、教学秘书、年级主任权限矩阵画出来自己先晕了。把角色控制在三个权限逻辑会非常清晰管理员管基础数据教师管成绩录入学生管成绩查询。这里有个容易被忽略的决策学生和教师的登录账号到底共用一张user表还是分开建。我建议共用一张user表加role字段student表和teacher表分别用user_id关联。这样登录校验只查一张表逻辑最简单学生、教师的个性化信息落在各自的表里互不干扰。有人喜欢把login表、student表、teacher表全部分开登录时先判断这个账号属于哪类再查对应表纯属给自己找麻烦。1.2 功能模块清单与优先级排序把三类角色一列功能模块就很清楚了。下面是我整理的第一版优先级建议按这个顺序开发别跳步。优先级功能模块角色说明必须做登录与退出全部密码加密Session维持登录态必须做班级管理管理员维护班级基础数据必须做学生信息管理管理员增删改查按班级筛选必须做教师与课程管理管理员课程绑定授课教师必须做成绩录入与修改教师教师只能录入自己授课课程必须做成绩查询学生查看本人成绩按学期筛选必须做班级成绩统计管理员/教师平均分、及格率、最高最低分建议做成绩排名教师/学生班级排名、年级排名建议做成绩导出教师/管理员导出CSV答辩加分项可选项成绩图表分析学生ECharts折线图、柱状图我见过太多项目上来就做图表和导出结果登录、权限这些基本功没做扎实演示时当场翻车。排序逻辑很简单先保证数据能正确流转再谈展示和导出。另外功能清单最好给每个模块标注“谁能用”开发到后面你会感谢当初写了这一列因为权限判断只需要照着清单来。这段比表格更值得说的一点是为什么不做“班主任”这类中间角色因为第一版最小系统里班主任想看的数据教师权限基本都覆盖了。多一个角色就多一份权限矩阵的维护成本。等系统真正跑起来发现某个角色确实看不了某些数据再加也不迟。做项目最怕的不是缺功能而是权限模型从一开始就复杂到无法收场。2. 表结构设计成绩表别做成“一列一科”2.1 六张基础表与关联关系数据库设计是整个系统的地基。我推荐的六张表方案是user用户、clazz班级、student学生、teacher教师、course课程、score成绩。表之间的关联画出来很简洁学生属于班级课程属于某个教师成绩表关联学生和课程。为什么单独拆班级表而不是在学生表里直接写班级名字符串因为班级是独立维护的基础数据直接存字符串后期改班级名称就要批量UPDATE学生表而且没法做班级维度的统计。凡是可能被反复引用的“名称”尽量抽成表这是最省钱的做法。2.2 建表SQL与关键约束第一版建议按下面这套SQL建库建表MySQL 8.0字符集统一utf8mb4直接复制就能跑。CREATE DATABASE IF NOT EXISTS grade_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE grade_system; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT 密码加密存储, role TINYINT NOT NULL DEFAULT 3 COMMENT 角色1管理员 2教师 3学生, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE clazz ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 班级ID, name VARCHAR(50) NOT NULL UNIQUE COMMENT 班级名称, grade_year VARCHAR(20) COMMENT 年级如2021级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班级表; CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL UNIQUE COMMENT 关联用户表, clazz_id INT NOT NULL COMMENT 所属班级, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_student_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_student_clazz FOREIGN KEY (clazz_id) REFERENCES clazz(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生表; CREATE TABLE teacher ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL UNIQUE COMMENT 关联用户表, teacher_no VARCHAR(20) NOT NULL UNIQUE COMMENT 工号, name VARCHAR(50) NOT NULL, title VARCHAR(50) COMMENT 职称可空, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_teacher_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教师表; CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 课程名称, teacher_id INT NOT NULL COMMENT 授课教师, credit DECIMAL(3,1) DEFAULT 3.0 COMMENT 学分, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程表; CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, semester VARCHAR(20) NOT NULL COMMENT 学期如2023-2024-1, exam_type VARCHAR(20) DEFAULT 期末 COMMENT 考试类型期末/补考, score DECIMAL(5,2) NOT NULL COMMENT 成绩, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course_semester (student_id, course_id, semester, exam_type), CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_score_course FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT成绩表;几个关键约束单独说。第一score表唯一键 (student_id, course_id, semester, exam_type) 是防重复录入的关键。没有这个约束教师不小心把同一门成绩保存两次数据就脏了统计报表立刻出错。有了唯一键Service层先查一次存在就UPDATE不存在就INSERT也就是经典的up-sert逻辑。注意把exam_type放进唯一键是为了兼容“期末、补考”两种考试类型同一学期里一门课最多两条成绩不会重复覆盖。第二外键建议都加上。有些教程为了省事不建外键全靠应用层控制结果删除一个班级时留下一堆孤儿学生数据。第一次做项目外键能让数据一致性的下限高很多。等系统数据量真正大了再考虑要不要去掉外键去搞分库分表那是另一个话题现在不用操心。第三所有表用InnoDB字符集用utf8mb4而不是utf8。utf8mb4才能存全量Unicode字符学生姓名里万一有生僻字或者将来要存表情符号都能正常显示不会出现怪字符。2.3 成绩存储的横表与纵表之争关于成绩表网上常见两种设计。一种是横表score表里直接放语文、数学、英语这类固定字段一行代表一个学生的一个学期另一种是纵表一行代表“某个学生某门课的一条成绩”课程是动态的。我强烈建议用纵表。成绩系统的课程会随学期变化今年新增一门“数据结构实验”横表就要加字段改表结构是所有Web项目里最痛苦的事纵表只需要插入几条新记录。横表查成绩单确实方便但代价是表结构僵死。纵表查询时要把多科成绩组装成一行展示用一条按条件聚合的SQL或者服务端循环都能搞定这点麻烦完全值得。分数精度也要注意用 DECIMAL(5,2)不要用 FLOAT 或 DOUBLE。浮点数在累加和比较时会有精度误差排名、及格率都是对数值敏感的运算用DECIMAL心里才踏实。2.4 初始化数据让系统第一分钟就能跑起来建完表顺手插几条初始化数据对后续联调帮助巨大。管理员账号、一个班级、一两个教师、三五个学生、两三门课这些基础数据不要靠界面一条条建用SQL脚本写清楚还能顺便验证外键、唯一键是否生效。-- 初始管理员密码请用你的加密逻辑生成后再替换这里只是演示占位 INSERT INTO user(username, password, role) VALUES (admin, 生成的加密结果, 1); INSERT INTO clazz(name, grade_year) VALUES (计算机2101, 2021级); INSERT INTO user(username, password, role) VALUES (teacher01, 生成的加密结果, 2); INSERT INTO teacher(user_id, teacher_no, name) VALUES (2, T001, 张老师);这里要提醒一句不要因为图省事就插明文密码。哪怕只是课设数据库密码一旦泄漏就是实打实的风险。初始化脚本里写的应该是加密后的字符串用你业务里的加密工具跑一遍再粘进去。这也是答辩时老师很喜欢追问的点能答上来很加分。3. 后端骨架连接池、路由分发与登录态设计3.1 技术选型为什么我建议先用 Servlet JSP先回答一个很多人纠结的问题做成绩管理系统到底用 Spring Boot 还是 Servlet JSP公司里做项目我会毫不犹豫选 Spring Boot。但如果你是课设、毕设或者刚学完Java Web想拿项目练手我建议第一版用 Servlet JSP JSTL。理由有三一是依赖最少一个Tomcat就能跑出问题容易定位二是HTTP请求怎么被接收、参数怎么传递、Session怎么维持这些底层逻辑一目了然而且任何一个Java岗位面试都绕不开这些问题三是后期换Spring Boot时Dao、Service这些分层思想可以原样平移不会白做。本文后续的关键逻辑我按Servlet思路写。想看Spring Boot版本的朋友也建议先看懂这些因为Controller、Filter、依赖注入本质都是同一套东西换了写法。很多同学一上来就搭Spring Boot全家桶结果连请求到controller的过程都说不清那才是本末倒置。3.2 数据库连接池Druid配置与JDBC工具类Web项目里不要写“每次请求都DriverManager.getConnection()”。创建一个数据库连接涉及网络握手、认证开销不小高并发时还会把数据库连接数打满。正确做法是用连接池。我常用Druid配置简单自带监控页性能也够如果你用的Spring Boot默认的HikariCP也可以参数思路完全一致只是名字不同。在 src/main/resources 下建 druid.propertiesdriverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/grade_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai usernameroot password你的数据库密码 initialSize5 maxActive20 maxWait3000URL里这几个参数几乎每次都会被问到我直接说结论characterEncodingutf8 解决中文写入乱码useSSLfalse 避免本地开发时SSL握手带来的警告和连接失败生产环境再考虑开启serverTimezoneAsia/Shanghai 是MySQL 8.0驱动最常见的报错来源不配就会报时区错误。这套参数可以当成固定搭配记下来以后任何Java连接MySQL都能套用。然后写JdbcUtils工具类统一管理数据源public class JdbcUtils { private static DruidDataSource dataSource; static { try { Properties props new Properties(); props.load(JdbcUtils.class.getClassLoader() .getResourceAsStream(druid.properties)); dataSource (DruidDataSource) DruidDataSourceFactory .createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这个类不大但把“获取连接”统一收口了。所有Dao都从这里拿连接将来要换连接池只动这一个类其余代码不用碰。给初学者的建议不要在Dao里到处去new Properties、加载配置文件那是重复劳动也是将来改配置时最容易漏改的地方。3.3 登录鉴权与角色权限的落地写法登录是系统大门怎么做既安全又简单我的做法登录成功后把用户对象放进Session用一个Filter拦截所有受保护路径每次请求先检查Session里有没有用户没有就跳回登录页。密码一定不能明文存。课设场景可以先用MD5加盐比如 MD5(用户名 固定盐 明文密码)如果想显得专业直接引入BCrypt加密。核心原则是数据库里绝不能出现能直接读出来的明文密码。面试时被问到“密码怎么存”能说出“加盐哈希而不是MD5裸奔”印象分会好很多。LoginServlet的核心逻辑protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); UserDao userDao new UserDao(); User user userDao.findByUsername(username); if (user null || !checkPassword(password, user.getPassword())) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } if (user.getStatus() 0) { req.setAttribute(error, 该账号已被禁用); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(loginUser, user); if (user.getRole() 1) { resp.sendRedirect(req.getContextPath() /admin/index); } else if (user.getRole() 2) { resp.sendRedirect(req.getContextPath() /teacher/index); } else { resp.sendRedirect(req.getContextPath() /student/index); } }这里有个网上教程常忽略的细节登录成功用Redirect失败用Forward。如果登录成功后直接Forward转发用户刷新页面时表单会再次提交造成重复登录甚至重复写数据用Redirect让浏览器重新发起一次GET请求状态就干净了。这个Forward vs Redirect的选择在后面所有表单提交场景里都适用值得统一记住。拦截器Filter写起来也很固定WebFilter(/*) 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); String uri req.getRequestURI(); if (uri.endsWith(/login.jsp) || uri.contains(/login) || uri.endsWith(.css) || uri.endsWith(.js)) { chain.doFilter(request, response); return; } if (session null || session.getAttribute(loginUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }Filter只做“有没有登录”这件事角色控制放在具体Servlet里判断。比如教师录成绩的Servlet进去先检查当前登录用户的role是否为2不是就返回403页面。为什么不在Filter里把权限矩阵做全因为角色少还好角色一多Filter会膨胀得很难维护而且同一个接口可能需要按角色返回不同数据Filter粒度太粗控制不了那么细。4. 核心业务实现成绩录入、排名统计与分页查询4.1 成绩新增与修改的事务边界成绩录入是最容易写错的地方。表面看是“INSERT一条记录”实际要考虑这条成绩是否已存在存在是覆盖还是提示批量提交时某一条失败怎么办我的做法score表加了唯一键之后Service层配合事务做“查-改-插”三步public void saveScore(Score score) throws SQLException { Connection conn JdbcUtils.getConnection(); try { conn.setAutoCommit(false); ScoreDao dao new ScoreDao(); Score exists dao.findByUnique(score.getStudentId(), score.getCourseId(), score.getSemester(), score.getExamType(), conn); if (exists ! null) { score.setId(exists.getId()); dao.update(score, conn); } else { dao.insert(score, conn); } conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } }三个关键点。第一同一个Connection贯穿检查和写入保证事务隔离否则“查”和“写”用的是两个连接中间数据可能已经被别人改了。第二先查再决定Insert还是Update配合数据库唯一键做双保险。第三finally里关闭连接连接池才能回收连接。很多同学把关闭写在try里一旦发生异常连接就漏关最终连接池被耗尽系统卡死这是线上事故级别的错误课设里也经常出现。补充一个进阶细节saveScore可以进一步做成批量版本接收List 在同一个事务里循环调用。性能更好关键是事务边界要覆盖整个批量过程要么全部成功要么全部回滚。4.2 总分、平均分与班级排名SQL能力决定报表速度学生端的成绩报表大致是各科成绩、总分、平均分、班级排名。不少人习惯先查出成绩列表再用Java循环算总分、平均分再查全班成绩算排名。跑得通但代码又长又慢数据量一大页面就卡。单个学生的学期汇总可以用GROUP BYSELECT student_id, COUNT(*) AS course_count, SUM(score) AS total_score, ROUND(AVG(score), 2) AS avg_score FROM score WHERE student_id ? AND semester ? GROUP BY student_id;班级排名MySQL 8.0下用窗口函数SELECT s.name AS student_name, sc.score, RANK() OVER (PARTITION BY sc.course_id ORDER BY sc.score DESC) AS rank_in_class FROM score sc JOIN student s ON s.id sc.student_id WHERE sc.course_id ? AND sc.semester ? ORDER BY sc.score DESC;如果MySQL版本不支持窗口函数用子查询也能实现排名核心思路是“统计班里比自己分数高的人数加1就是排名”SELECT s.name, sc.score, (SELECT COUNT(*) 1 FROM score sc2 WHERE sc2.course_id sc.course_id AND sc2.semester sc.semester AND sc2.score sc.score) AS rank_in_class FROM score sc JOIN student s ON s.id sc.student_id WHERE sc.course_id ? AND sc.semester ?;选哪种取决于数据库版本。别为了一个课设去折腾数据库升级能跑通才是前提。这里也牵出一个容易被问的基础知识点RANK和DENSE_RANK有什么区别RANK会出现并列后跳号比如两个并列第二下一个名次是第四DENSE_RANK则连续编号。成绩排名一般用DENSE_RANK更符合直觉或者干脆接受并列跳号演示时跟老师说明白就行。4.3 分页查询LIMIT参数与SQL注入的坑后台列表页学生、成绩数据一多必然要分页。最常见的错误是把页码拼进SQL字符串比如String sql SELECT * FROM score LIMIT pageNo , pageSize;这样写有两个问题。第一是SQL注入风险pageNo、pageSize虽然是数字一旦参数被篡改成恶意字符串整个列表接口就危险了第二是参数拼接容易出错。正确做法是用PreparedStatement占位符String sql SELECT * FROM score WHERE semester ? ORDER BY id DESC LIMIT ?, ?; PreparedStatement ps conn.prepareStatement(sql); ps.setString(1, semester); ps.setInt(2, (pageNo - 1) * pageSize); ps.setInt(3, pageSize);注意LIMIT的偏移量要自己算第1页从第0条开始所以是 (pageNo - 1) * pageSize。很多初学者直接传入pageNo第一页是对的第二页就开始少数据这是分页最常见的隐性bug。分页另一个容易被忽略的点总条数要单独查。有人只做了当前页查询没查COUNT(*)前端翻页不知道总页数。建议数据访问层把“查询列表”和“查询总数”两个方法放在一起返回一个包含list、totalCount、totalPage、pageNo、pageSize的PageBean前端渲染直接拿字段用省得每次在JSP里临时算。5. 前端页面与接口联调JSP的回显与验证分工5.1 页面结构与公共片段复用Servlet JSP方案里没有真正的前端工程页面长在JSP里。这时候最怕的是每个页面都自己写一套导航、CSS引用改一个菜单就要全站改一遍。我建议把页面拆成公共片段header.jsp放导航栏sidebar.jsp放与角色相关的菜单footer.jsp放公共脚本。每个业务页面用JSP的include引入% include file/common/header.jsp % % include file/common/sidebar.jsp % div classpage-main !-- 页面主体内容 -- /div % include file/common/footer.jsp %sidebar里根据Session里的角色动态渲染菜单用JSTL的choose判断c:choose c:when test${loginUser.role 1} lia href${pageContext.request.contextPath}/admin/student学生管理/a/li lia href${pageContext.request.contextPath}/admin/course课程管理/a/li /c:when c:when test${loginUser.role 2} lia href${pageContext.request.contextPath}/teacher/score成绩录入/a/li /c:when c:otherwise lia href${pageContext.request.contextPath}/student/score我的成绩/a/li /c:otherwise /c:choose这样同一个登录用户在不同角色下看到的菜单天然不同前端先挡了一层。注意所有链接都要用pageContext.request.contextPath拼上下文路径否则部署之后所有菜单都会404这一点在第六章专门再讲。5.2 表单提交、数据回显与错误提示成绩录入页面最常见的问题是“输入有误提交后刚填的内容全没了”。这其实是表单回显没做好。JSP里输入框的value要用参数回填input namestudentName value${param.studentName ! null ? param.studentName : } /配合EL表达式写起来很顺。回显的价值不只是体验答辩时老师很可能故意输入错误数据看你的处理页面能保留之前的输入印象分会好很多。错误提示同理Servlet里setAttribute(error, ...)JSP里判断非空就显示红字c:if test${not empty error} div classalert alert-danger${error}/div /c:if我习惯在表单校验失败时把所有接收到的参数重新set回request再Forward回表单页。代码多几行但用户体验完全不在一个档次。别小看这个小细节很多同学的项目功能都齐全就是卡在这种“看着能用但不顺手”的地方被老师扣掉印象分。5.3 前端验证与服务端验证的分工一个常见错误认知是“前端验证够了”。实际上前端的required、maxlength只是提升体验真正要防的是绕过页面的直接请求。比如有人直接POST一个score999到服务器前端根本拦不住。我的原则前端做格式校验比如必填、数字范围、长度后端做业务校验比如成绩范围0-100、课程权限、学生是否存在。前端负责让正常人舒服地填写后端负责让任何请求都合规落地。两层都做项目才经得起推敲。成绩范围这种校验后端大概率就一行if (score.getValue() 0 || score.getValue() 100) { throw new BusinessException(成绩必须在0到100之间); }不要觉得重复安全和体验从来不是二选一。面试时被问“前端校验能不能保证安全”能说出“不能必须服务端兜底”这句话就已经及格了。6. 部署到Tomcat与高频踩坑记录6.1 War包构建与部署步骤开发完在IDEA里跑Tomcat和真正部署是两回事。建议用Maven打成War包再部署流程pom.xml里packaging设置为war执行 mvn clean package把target目录下的xxx.war复制到Tomcat的webapps目录启动Tomcatwar会自动解压浏览器访问 http://localhost:8080/项目名/。这里有一个部署前后的经典差异IDEA里访问路径通常没有项目名部署到Tomcat后URL会多一层上下文路径所有跳转最好统一用 req.getContextPath() 拼前缀否则登录成功后的Redirect全部404。这也解释了为什么前面代码里所有地址都习惯性地带contextPath。还要特别注意Tomcat版本。Tomcat 10之后Servlet包名从javax.servlet换成了jakarta.servlet如果你用的是旧教程的javax代码部署到Tomcat 10会直接编译或启动失败。比如IDEA 2024版本创建Web项目时的新特性和网上老教程的代码经常在这里对不上。我的建议是统一用Tomcat 9 javax版本参考资料最多踩坑最少。6.2 MySQL驱动版本与连接报错MySQL 8.0的驱动类名和5.x不一样旧教程里的com.mysql.jdbc.Driver已经不建议使用应该用com.mysql.cj.jdbc.Driver。Maven坐标也有变化dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency高频报错就两个。一是“Communications link failure”多半是驱动jar没有打进应用或者数据库地址写错二是“The server time zone value ... is unrecognized”这就是前面让配serverTimezoneAsia/Shanghai的原因。遇到这两个报错按这个思路排查基本十分钟内能定位。别一上来就重装MySQL这个错误和安装过程没关系。6.3 中文乱码请求、响应、数据库三层都要管中文乱码是Web项目里出镜率最高的坑而且经常是“页面正常存库变问号”或者反过来。我的排查套路是分三层处理。第一层JSP页面文件编码是UTF-8头部写 contentTypetext/html; charsetUTF-8第二层Servlet里统一 req.setCharacterEncoding(UTF-8)响应时 setContentType(text/html; charsetUTF-8)第三层数据库连接URL带characterEncodingutf8建库字符集utf8mb4。三处都做中文基本不会出问题。补充一个冷知识GET请求的乱码和Tomcat的URIEncoding有关。CharacterEncodingFilter只能处理POST的请求体GET参数是Tomcat连接层解码的需要在server.xml的Connector上加 URIEncodingUTF-8。这个细节很多教程不写但实际项目里GET查询参数带中文时总会撞上。6.4 连接池参数调整别让连接被“饿死”部署后如果出现“连接池耗尽”或“获取连接超时”先看两个参数maxActive够不够maxWait是不是太短。并发访问量不大时maxActive设20完全够但如果某个页面的SQL执行特别慢连接长期不释放20个连接也会被占满。这种时候优先优化SQL而不是一味调大连接数。还容易犯的错是忘配置 testOnBorrow 或 testWhileIdle。MySQL的wait_timeout默认8小时连接空闲超过这个时间会被服务端断开连接池里的连接实际已死但Java侧不知道下一次取出来一用就报错。Druid里加一句testWhileIdletrue validationQuerySELECT 1这样每次借用连接前先验活把“死连接”挡在门外。这个配置能解决很多“系统跑一段时间后突然报错重启又正常”的诡异问题。做项目时遇到这种玄学问题先检查连接池往往比怀疑业务代码靠谱得多。7. 拿这套代码去演示之前的几句实话7.1 演示前必须过一遍的清单项目写完后演示才是终局。我建议留一份“演示专用初始化脚本”把测试数据清理干净重新导入一份体面的演示数据四五十个学生、五六门课、两三个学期的成绩让班级平均分、排名、统计图都有内容可看。演示前务必在干净环境里完整走一遍这条链路管理员登录 → 建班级 → 建学生 → 建课程 → 教师登录录成绩 → 学生登录查成绩。这一条链路跑通比准备再多页PPT都有用。另外把三个容易在现场翻车的点提前排掉。第一清理掉所有弱智测试账号比如密码全是123456的老账号或者演示到一半冒出来的“测试学生1”。第二确认数据库初始化脚本可以从零执行不要依赖你本地已经改过的数据环境。第三预览一遍所有页面确认没有写死在本地的路径比如localhost:8080这种部署后端口变了页面就崩。7.2 后续扩展与面试复盘至于要不要继续加功能我的建议是优先做“成绩导出Excel”再做“成绩分析图”这两项对演示效果提升最大实现难度也适中。导出甚至不用上POI先用简单的CSV输出跑通答辩时老师问起来你能说清楚原理比硬上一套复杂的Excel库更有说服力。还有一个每个做课设的同学都该养成的习惯数据库记得导出SQL备份。演示前一天备份一次现场万一手滑删了表还能一键复原。别问我为什么强调这个问就是我见过太多人在答辩现场脸色发白地恢复数据。做完这套系统再回头刷Java面试题里的Web基础部分你会发现Servlet生命周期、Session机制、事务隔离、SQL注入这些概念突然都有了落点。最后再分享一个小技巧把你在这个项目里踩过的坑整理成一个TXT每个坑写清楚现象、原因和解决方式。面试时被问到项目经验你随口讲出两个真实排查过程比背十篇八股都管用。