JavaWeb学生管理系统课程设计:数据库设计与分层实现避坑指南
简介这份资源是面向高校计算机相关专业学生的 JavaWeb 课程设计参考项目以 MVC 模式实现学生管理系统适合正在完成课程设计或初学 JavaWeb 的开发者对照学习。项目功能覆盖登录验证与背景图片轮播、操作总界面增删改查以及学生信息的增加、按学号或全部查找、修改与删除等完整模块能够帮助读者理解分层结构与前后端交互流程。压缩包共 35 个文件约 2.15MB包含 9 个 Java 源文件、8 个 HTML 页面、5 张 JPG 与 4 张 PNG 界面素材、2 个 DOCX 说明文档以及 CSS、JS、XML 等配置与样式文件目录结构清晰便于按模块查阅。目前已有 4376 人学习下载读者可从中获取可运行的课程设计源码、界面设计草图与说明文档快速搭建开发环境并对照实现增删改查逻辑也可作为答辩演示与功能扩展的基础模板。1. 从零手搓学生管理系统为什么我劝你先别急着写增删改查很多同学拿到“javaweb课程设计学生管理系统”这个题目第一反应是打开 IDE 新建 Servlet然后对着浏览器地址栏开始写list.jsp。我当年也这么干过结果做到第三天发现学生表里存着班级名称字符串改一次班级名要 update 几百行删除学生时成绩记录成了孤儿数据分页查询每翻一页都要重新查全表。这些坑不是编码能力问题是动手前没把数据模型和分层边界想清楚。这个标题背后真正要解决的是用 Java Web 技术栈Servlet/JSP 或 Spring Boot 前端模板实现一套能跑通“学生信息录入、查询、修改、删除、分页展示”的最小闭环系统同时让代码结构经得起答辩时老师追问“你的 DAO 层为什么这么设计”。它适合正在做课程设计的学生、需要快速交付一个可演示 Web 项目的开发者以及想从“能跑就行”进阶到“结构清晰”的初学者。我见过太多人把时间花在调 JSP 页面样式上最后数据库设计一塌糊涂。这篇笔记按“先立数据模型 → 再搭分层骨架 → 再填业务逻辑 → 最后排坑”的顺序走每一步都给可复制的代码和参数说明。你跟着做至少能避开我当年踩过的那些血泪坑。2. 数据库表结构设计三张表撑起整个系统2.1 为什么学生表里不能直接存班级名称新手最容易犯的错是把班级名称当普通字段塞进学生表。比如student表里放一个class_name varchar(50)看起来省事但一旦班级改名你得遍历所有学生记录逐条更新。更致命的是如果要做“按班级统计人数”或者“班级下拉列表”你只能select distinct class_name from student数据一多就慢而且没法保证一致性——同一个班级可能因为输入差异出现“计科1班”和“计科一班”两条记录。正确做法是拆出class表学生表只存class_id外键。这样班级信息只维护一份学生表通过 join 拿名称。同理成绩如果单独管理也要拆出score表用student_id关联。三张表的关系是一个班级有多个学生一个学生有多条成绩记录。2.2 建表 SQL 与字段类型选择下面是我在 MySQL 8.0 上验证过的建表语句字段类型和约束都按课程设计常见需求设定-- 班级表只存班级基础信息 CREATE TABLE class ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 班级主键, name VARCHAR(50) NOT NULL UNIQUE COMMENT 班级名称唯一防止重复, grade VARCHAR(20) NOT NULL COMMENT 年级如2024级, created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班级信息表; -- 学生表核心表class_id 外键关联班级 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT 学生主键, sno VARCHAR(20) NOT NULL UNIQUE COMMENT 学号唯一, name VARCHAR(30) NOT NULL COMMENT 姓名, gender TINYINT DEFAULT 1 COMMENT 性别1男 0女, age INT COMMENT 年龄, class_id INT NOT NULL COMMENT 所属班级ID, phone VARCHAR(20) COMMENT 联系电话, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_student_class FOREIGN KEY (class_id) REFERENCES class(id) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表; -- 成绩表一个学生多条成绩 CREATE TABLE score ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT 学生ID, course VARCHAR(50) NOT NULL COMMENT 课程名称, score DECIMAL(5,2) COMMENT 分数保留两位小数, exam_date DATE COMMENT 考试日期, CONSTRAINT fk_score_student FOREIGN KEY (student_id) REFERENCES student(id) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT成绩表;逻辑说明class表的name加了 UNIQUE 约束防止插入重复班级student表的sno也唯一因为学号是业务主键。外键的ON DELETE RESTRICT表示班级下还有学生时不允许删班级ON DELETE CASCADE表示删学生时自动删掉他的成绩记录——这两个策略是经过考虑的不是随便写的。参数说明utf8mb4支持 emoji 和生僻字避免姓名乱码DECIMAL(5,2)存分数比FLOAT精确避免 89.99999 这种尴尬TINYINT存性别比CHAR(1)省空间且语义清晰。注意如果你用的 MySQL 版本低于 5.7DATETIME DEFAULT CURRENT_TIMESTAMP可能不支持需要改成TIMESTAMP。建表前先select version()确认。2.3 插入测试数据与验证外键约束建完表后先插几条数据验证外键是否生效INSERT INTO class (name, grade) VALUES (计科2401, 2024级), (软工2402, 2024级); INSERT INTO student (sno, name, gender, age, class_id, phone) VALUES (2024001, 张三, 1, 19, 1, 13800000001), (2024002, 李四, 0, 18, 1, 13800000002), (2024003, 王五, 1, 20, 2, 13800000003); INSERT INTO score (student_id, course, score, exam_date) VALUES (1, Java程序设计, 88.50, 2024-06-15), (1, 数据库原理, 92.00, 2024-06-20), (2, Java程序设计, 76.00, 2024-06-15);执行后跑一条 join 查询看效果SELECT s.sno, s.name, c.name AS class_name, sc.course, sc.score FROM student s JOIN class c ON s.class_id c.id LEFT JOIN score sc ON s.id sc.student_id ORDER BY s.sno, sc.course;如果张三出现两行分别对应两门课李四出现一行且 score 字段有值王五出现一行但 score 为 NULL因为没插成绩说明 LEFT JOIN 用对了。这里用 LEFT JOIN 而不是 INNER JOIN是为了让没成绩的学生也能显示出来答辩时老师问“为什么有些学生没成绩也要展示”你就有话说了。3. 后端分层骨架搭建Servlet JSP 还是 Spring Boot3.1 两种技术路线的选型对比课程设计常见两种做法原生 Servlet JSP JDBC或者 Spring Boot Thymeleaf MyBatis。前者代码量大但“看得见底层”后者开发快但可能被老师问“自动配置原理是什么”。我一般建议如果课程明确要求“Java Web 基础”选 Servlet 路线如果允许用框架且时间紧选 Spring Boot。对比项Servlet JSP JDBCSpring Boot MyBatis代码量大每个功能要写 Servlet、DAO、JSP小Mapper 接口 注解启动方式需要 Tomcat 容器配置 web.xml内置 Tomcatmain 方法启动分层清晰度手动分层容易写乱天然分层但依赖注入是黑匣子答辩风险老师熟悉问底层好答可能被问自动配置、起步依赖适合场景课程要求展示基础功底快速交付可运行 Demo我当年选的是 Servlet 路线因为导师明确说“不许用框架我要看你怎么管连接”。如果你也走这条路下面给一个最小可运行的骨架。3.2 手写 JDBC 工具类与 DAO 层先写一个DBUtil类负责加载驱动和获取连接。注意不要在每个 Servlet 里都写DriverManager.getConnection那样连接管理会失控。// DBUtil.java数据库连接工具类 public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/student_mgr?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL驱动加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 关闭资源顺序ResultSet - Statement - Connection public static void close(Connection conn, Statement stmt, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (stmt ! null) stmt.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }逻辑说明static块只执行一次保证驱动只加载一次close方法按“后开先关”顺序释放资源避免连接泄漏。参数说明URL 里的serverTimezoneAsia/Shanghai是 MySQL 8 必须加的否则时间字段会差 8 小时characterEncodingutf8防止中文乱码。接着写StudentDao封装增删改查// StudentDao.java学生数据访问对象 public class StudentDao { // 分页查询page 从1开始size 每页条数 public ListStudent findByPage(int page, int size) throws SQLException { ListStudent list new ArrayList(); String sql SELECT s.*, c.name AS class_name FROM student s JOIN class c ON s.class_id c.id ORDER BY s.id LIMIT ? OFFSET ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, size); ps.setInt(2, (page - 1) * size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Student stu new Student(); stu.setId(rs.getInt(id)); stu.setSno(rs.getString(sno)); stu.setName(rs.getString(name)); stu.setClassName(rs.getString(class_name)); list.add(stu); } } } return list; } // 新增学生 public void insert(Student stu) throws SQLException { String sql INSERT INTO student (sno, name, gender, age, class_id, phone) VALUES (?,?,?,?,?,?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, stu.getSno()); ps.setString(2, stu.getName()); ps.setInt(3, stu.getGender()); ps.setInt(4, stu.getAge()); ps.setInt(5, stu.getClassId()); ps.setString(6, stu.getPhone()); ps.executeUpdate(); } } }逻辑说明分页用LIMIT ? OFFSET ?OFFSET计算公式是(page-1)*size这是 MySQL 的标准写法。用try-with-resources自动关闭PreparedStatement和ResultSet但Connection也放在 try 里会自动关所以不需要手动调DBUtil.close。参数说明PreparedStatement的?占位符从 1 开始计数不是 0setInt和setString要跟数据库字段类型匹配否则可能报类型转换错误。3.3 Servlet 接收请求与 JSP 渲染写一个StudentListServlet处理/student/list请求// StudentListServlet.java学生列表分页展示 WebServlet(/student/list) public class StudentListServlet extends HttpServlet { private StudentDao studentDao new StudentDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 获取页码参数默认第1页 String pageStr req.getParameter(page); int page (pageStr null || pageStr.isEmpty()) ? 1 : Integer.parseInt(pageStr); int size 10; // 每页10条 try { ListStudent students studentDao.findByPage(page, size); req.setAttribute(students, students); req.setAttribute(currentPage, page); req.getRequestDispatcher(/WEB-INF/views/student_list.jsp).forward(req, resp); } catch (SQLException e) { throw new ServletException(查询学生列表失败, e); } } }逻辑说明doGet里先解析page参数如果为空默认第 1 页查询结果放到request作用域然后转发到 JSP。JSP 放在WEB-INF下是为了防止直接通过 URL 访问必须经过 Servlet 转发才能渲染。参数说明size10是每页条数你可以改成 5 或 20但建议跟分页组件的显示逻辑保持一致。对应的 JSP 片段%-- student_list.jsp学生列表页 --% table border1 trth学号/thth姓名/thth班级/thth操作/th/tr c:forEach items${students} varstu tr td${stu.sno}/td td${stu.name}/td td${stu.className}/td tda hrefedit?id${stu.id}编辑/a/td /tr /c:forEach /table逻辑说明用 JSTL 的c:forEach遍历students集合${stu.sno}是 EL 表达式会自动调用 getter。注意 JSP 顶部要加% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %否则c:forEach不生效。4. 增删改查业务逻辑实现从表单到数据库的完整链路4.1 新增学生表单校验与防重复学号新增功能最容易翻车的地方是学号重复插入。数据库虽然加了 UNIQUE 约束但直接抛SQLIntegrityConstraintViolationException给用户看很不友好。我一般会在 DAO 里先查一次// 检查学号是否已存在 public boolean existsBySno(String sno) throws SQLException { String sql SELECT COUNT(*) FROM student WHERE sno ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, sno); try (ResultSet rs ps.executeQuery()) { rs.next(); return rs.getInt(1) 0; } } }然后在 Servlet 里先调existsBySno如果返回 true 就转发回表单页并带上错误提示。参数说明COUNT(*)返回的是 long但这里用getInt(1)足够因为学生数量不会超过 int 范围。注意rs.next()必须调用否则游标不在结果行上。4.2 删除学生级联删除与事务边界删除学生时如果该学生有成绩记录score表的外键ON DELETE CASCADE会自动删掉成绩。但如果你用的是 MyISAM 引擎外键不生效成绩会变成孤儿数据。所以建表时一定要确认ENGINEInnoDB。删除的 DAO 方法public void deleteById(int id) throws SQLException { String sql DELETE FROM student WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); ps.executeUpdate(); } }逻辑说明这里没有手动开事务因为单条 DELETE 语句本身是原子的。但如果你的业务要求“删学生同时记录日志”就需要手动conn.setAutoCommit(false)执行两条 SQL 后再commit。参数说明executeUpdate返回受影响行数可以用来判断删除是否成功。4.3 修改学生动态 SQL 与字段回显修改功能分两步先根据 id 查出学生信息回显到表单再提交更新。回显的 DAO 方法public Student findById(int id) throws SQLException { String sql SELECT * FROM student WHERE id ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { Student stu new Student(); stu.setId(rs.getInt(id)); stu.setSno(rs.getString(sno)); stu.setName(rs.getString(name)); stu.setGender(rs.getInt(gender)); stu.setAge(rs.getInt(age)); stu.setClassId(rs.getInt(class_id)); stu.setPhone(rs.getString(phone)); return stu; } } } return null; }更新时要注意如果用户没改学号但学号字段传上来了直接 update 会触发 UNIQUE 冲突吗不会因为 MySQL 的 UPDATE 在值没变时不触发唯一约束检查。但如果你把学号改成了另一个已存在的值就会报错。所以更新前也要检查学号是否被其他学生占用public boolean existsBySnoExcludeSelf(String sno, int selfId) throws SQLException { String sql SELECT COUNT(*) FROM student WHERE sno ? AND id ! ?; // ... 执行查询 }参数说明id ! ?排除了当前学生自己这样用户不改学号时不会误报重复。5. 避坑与排查课程设计里最容易翻车的五个点5.1 中文乱码从请求到响应全链路排查现象表单提交“张三”数据库里存的是“å¼ ä¸‰”。原因Tomcat 默认用 ISO-8859-1 解码请求体。解决在 Servlet 的doPost第一行加req.setCharacterEncoding(UTF-8)同时响应加resp.setContentType(text/html;charsetUTF-8)。如果用的是 GET 请求Tomcat 8 以后默认 URI 编码是 UTF-8但更稳妥的做法是在server.xml的 Connector 上加URIEncodingUTF-8。5.2 数据库连接泄漏Tomcat 重启才能恢复现象系统跑一段时间后报“Too many connections”。原因某个 DAO 方法里Connection没关闭或者ResultSet没关导致Statement也没关。解决统一用try-with-resources或者把DBUtil.close放在finally块里。排查方法在 MySQL 里执行show processlist看哪些连接处于 Sleep 状态且时间很长。5.3 分页页码越界第 0 页和第 999 页现象用户手动把 URL 的page0或page999页面报错或显示空白。原因(page-1)*size算出负数 OFFSETMySQL 直接报语法错误。解决在 Servlet 里对 page 做边界校验小于 1 强制为 1大于总页数强制为总页数。总页数可以用SELECT COUNT(*) FROM student算出来再除每页条数。5.4 JSP 页面直接访问WEB-INF 目录的权限现象用户直接访问http://localhost:8080/student_list.jsp能看到页面但没数据。原因JSP 放在WEB-INF下直接访问会被容器拦截返回 404。解决这是预期行为所有 JSP 都应该通过 Servlet 转发访问。如果你发现能直接访问说明 JSP 没放在WEB-INF下赶紧移进去。5.5 外键约束导致删除失败RESTRICT 与 CASCADE 的选择现象删除班级时报“Cannot delete or update a parent row”。原因student表的外键用了ON DELETE RESTRICT班级下还有学生时不允许删。解决要么先删学生再删班级要么把外键改成ON DELETE CASCADE但这样删班级会连带删学生风险很大。我一般保留 RESTRICT在业务层先检查班级下是否有学生有就提示用户先转移学生。6. 进阶技巧用过滤器统一编码与登录拦截6.1 编码过滤器一次配置全局生效与其在每个 Servlet 里写setCharacterEncoding不如写一个 FilterWebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); } }逻辑说明WebFilter(/*)拦截所有请求在chain.doFilter之前设置编码这样后续的 Servlet 拿到的就是正确解码的参数。参数说明/*是 URL 模式/*表示所有路径如果你只想拦截特定路径可以改成/student/*。6.2 登录拦截器未登录跳转到登录页课程设计通常要求“不登录不能访问管理页面”。写一个LoginFilterWebFilter(/student/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; Object user request.getSession().getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }逻辑说明从 session 里取loginUser如果为空就重定向到登录页。注意sendRedirect之后要return否则会继续执行chain.doFilter导致双重响应。参数说明request.getContextPath()返回项目部署路径比如/student_mgr拼上/login.jsp才是完整 URL。6.3 用一条 SQL 验证系统完整性最后给一条“体检 SQL”跑一遍能看出数据关系是否正常SELECT (SELECT COUNT(*) FROM class) AS 班级数, (SELECT COUNT(*) FROM student) AS 学生数, (SELECT COUNT(*) FROM score) AS 成绩数, (SELECT COUNT(*) FROM student s LEFT JOIN class c ON s.class_id c.id WHERE c.id IS NULL) AS 无班级学生, (SELECT COUNT(*) FROM score sc LEFT JOIN student s ON sc.student_id s.id WHERE s.id IS NULL) AS 无主成绩;如果“无班级学生”和“无主成绩”都是 0说明外键关系健康。如果有非零值说明有脏数据需要手动清理。我做完这个系统最大的教训是别一上来就写页面先把 ER 图和数据字典画出来把外键策略定好后面写代码就是填空题。另一个习惯是每写完一个 DAO 方法就用单元测试跑一遍别等前端联调时才发现 SQL 写错了。希望帮到你。本文还有配套的精品资源点击获取