宿舍管理系统实战:Servlet+JSP+MySQL实现登录鉴权与事务分配
2026/9/16 5:06:14 网站建设 项目流程

简介:基于Servlet+JSP+MySQL的javaWeb学生宿舍管理系统源码,面向Java Web初学者、期末大作业与毕业设计人群,提供一套结构完整、可直接运行的宿舍管理项目,涵盖学生信息、宿舍分配、访客登记等常见模块,难度适中且经助教审定,适合作为课设参考或二次开发蓝本。整个资源包共202个文件,压缩后仅7.21MB,其中22个JSP页面与21个Java类构成核心前后端逻辑,16个CSS与73个JS负责界面样式与交互效果,7个JAR包提供依赖库,另含SQL脚本用于快速初始化数据库,并配有Markdown、XML等说明文件,目录清晰便于对照学习。目前已有248人学习下载,资源内含使用教程,代码经本地编译可运行,评审分达到98分。对需要快速落地学生宿舍管理系统的开发者而言,这套源码能省去从零搭建的时间,同时从分层结构、请求处理到数据持久化都能提供实际参考。

1. 学生宿舍管理系统:为什么Servlet+JSP+MySQL仍是最接地气的毕业设计选择

很多人在做宿舍管理系统时,一上来就扎进Spring Boot的依赖地狱里,结果被自动配置和各种starter困住,连一个简单的登录都理不清。实际上,宿舍管理系统的业务量并不大,核心就是几张表的增删改查,加上一个带角色的权限控制,用Servlet+JSP+MySQL这套原生的Java Web技术栈,能把每个HTTP请求从浏览器到数据库完整走一遍,理解Session、JDBC、事务这些真正值钱的基础概念。这个项目难度适中,源码经过本地编译运行,在毕业设计和期末大作业答辩中拿到高分的关键,不只是功能齐全,而是逻辑可读、边界清晰。无论你是想快速交差,还是想在Servlet过滤器、数据库事务这些点上做出亮点,这套组合都比重框架更有拆解价值。

2. 系统设计先行:宿舍管理的数据模型与JDBC连接层

2.1 需求拆解与角色权限边界

一个完整的宿舍管理系统通常有三种角色:系统管理员、宿管员、学生。管理员维护院系和班级,宿管员负责宿舍楼、房间和床位分配,学生只能查看自己的入住信息和发起报修。在设计数据库时,不要把权限写死在JSP页面里,而是通过角色字段和Filter统一控制。我一般先画Use Case图,把每个角色的操作边界画出来,再转成表结构,这样写代码时不会东拉西扯。

核心表大概有六张:用户表、学生表、宿舍楼表、宿舍房间表、入住记录表、报修表。用户表与角色相关,学生表关联用户和宿舍房间,房间表保存容量和当前人数,入住记录表处理学生和房间的多对多关系。下面用表格列出关键表的职责和字段。

表名主要字段作用
userid, username, password, role账号登录与角色区分
studentid, student_no, user_id, room_id, major学生基本信息及宿舍关联
dorm_buildingid, name, address, manager_id宿舍楼信息
dorm_roomid, building_id, room_no, capacity, current_count房间容量与实时已住人数
repairid, student_id, description, status报修工单状态

2.2 建表脚本与字段设计说明

下面给出一个经过整理的建表SQL,字段命名使用下划线风格,便于在JSP、Servlet中映射属性。

CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, role ENUM('admin','dorm_manager','student') NOT NULL, real_name VARCHAR(32), created_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE dorm_building ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, address VARCHAR(128), manager_id INT ); CREATE TABLE dorm_room ( id INT PRIMARY KEY AUTO_INCREMENT, building_id INT NOT NULL, room_no VARCHAR(16) NOT NULL, capacity INT NOT NULL DEFAULT 4, current_count INT NOT NULL DEFAULT 0, FOREIGN KEY (building_id) REFERENCES dorm_building(id) ); CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE, user_id INT NOT NULL, room_id INT, phone VARCHAR(20), major VARCHAR(64), FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (room_id) REFERENCES dorm_room(id) ); CREATE TABLE repair ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, description TEXT, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (student_id) REFERENCES student(id) );

这段SQL的关键点:user表的role字段用ENUM约束,防止非法角色写入,这是权限控制的第一道防线。dorm_room表同时维护capacity和current_count,分配宿舍时只需要更新current_count,不必每次COUNT查询,既提升性能又方便在列表页直接展示剩余床位。student表的room_id外键关联房间,换寝时只需更新这个字段,但要同步调整新旧房间的current_count,所以操作必须放在事务里。

2.3 通用JDBC连接工具类的写法

源码里通常带一个DBUtil,作用是把获取连接、关闭资源这些重复代码统一封装。参考项目里看到的是直接基于java.sql的版本,没有引入连接池,课设场景完全够用。关键实现如下。

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/dorm?useSSL=false&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.jdbc.Driver"); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(ResultSet rs, PreparedStatement ps, Connection conn) { if (rs != null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (ps != null) { try { ps.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn != null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }

三个参数说明:useSSL=false是MySQL 5.7以上常见的设置,避免连接时SSL握手带来的警告和时延。characterEncoding=utf8必须写在URL里,否则数据库中文插入后查看变成乱码,这个坑在Windows本地开发时特别容易触发。静态块里加载驱动是传统写法,如果使用MySQL 8.x,驱动类名要改成com.mysql.cj.jdbc.Driver,否则运行时会报ClassNotFoundException。关闭资源的顺序一定是先ResultSet后PreparedStatement最后Connection,因为连接最贵重,迟关容易导致连接泄漏。

3. Servlet+JSP的登录认证与权限控制:从表单到Filter

3.1 Model2模式下Servlet和JSP如何分工

在这个项目里,Servlet不直接输出HTML,它只接收请求、调用DAO、保存状态、转发给JSP。JSP从request、session作用域中取数据,只负责渲染。这种Model2模式避免了JSP里写大段Java代码,也让页面美化和逻辑维护分开。登录模块是最典型的流程:用户填写login.jsp表单,LoginServlet的doPost方法收到请求,调用UserDAO查询数据库,成功后把User对象放入session并重定向到主页,失败则把错误信息放入request后转发回登录页。

3.2 登录页面的表单写法

<form action="${pageContext.request.contextPath}/login" method="post"> <div class="form-group"> <input type="text" class="form-control" name="username" placeholder="用户名" required> </div> <div class="form-group"> <input type="password" class="form-control" name="password" placeholder="密码" required> </div> <button type="submit" class="btn btn-primary">登 录</button> </form>

这里用${pageContext.request.contextPath}动态拼接项目根路径,避免部署到不同Tomcat目录时写死路径导致404。method使用post而不是get,防止密码出现在浏览器历史记录中,这是Web开发的基本卫生习惯。

3.3 登录Servlet与密码校验逻辑

下面这段代码是源码的核心部分,我摘出来逐行解析。

@WebServlet("/login") public class LoginServlet extends HttpServlet { 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) { HttpSession session = request.getSession(); session.setAttribute("loginUser", user); session.setMaxInactiveInterval(30 * 60); response.sendRedirect(request.getContextPath() + "/index.jsp"); } else { request.setAttribute("errorMsg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } } }

参数说明:@WebServlet("/login")是Servlet 3.0的注解式注册,不需要在web.xml里维护映射,但要求Tomcat 7.0以上版本。session.setMaxInactiveInterval(30 * 60)把会话超时设置为30分钟,超过后用户必须重新登录。登录成功后使用sendRedirect而不是forward,是为了防止浏览器刷新时重复提交表单,这是区分初学者和工程师的细节之一。

3.4 用Filter统一拦截未登录请求

每个需要登录的页面都手动判断session太愚笨。项目里一定有一个LoginFilter,对所有请求先过滤一遍。我在参考源码中见到的是下面这种实现。

@WebFilter("/*") public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; String uri = request.getRequestURI(); if (uri.endsWith("login.jsp") || uri.endsWith("/login") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png")) { chain.doFilter(request, response); return; } HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(request, response); } else { response.sendRedirect(request.getContextPath() + "/login.jsp"); } } }

这里的拦截逻辑是先放行登录页、登录Servlet和静态资源,再检查session中是否存在loginUser。注意调用的是request.getSession(false),这个参数false表示如果当前没有Session就返回null,而不会自动创建一个新Session。很多默认写法会直接用getSession(),这会导致每个未登录请求都生成无用的Session对象,让服务器内存白白增加。如果需要区分管理员和普通学生,还可以在此基础上追加路径前缀判断和角色字段比对。

3.5 为什么按角色登录并不需要独立Servlet

之前看到一个误区,就是为管理员和学生分别写LoginServlet。完全没有必要。只需要在登录成功后读取当前用户的role字段,然后让JSP页面按不同角色显示不同菜单即可。常见的做法是引入自定义标签或者JSTL的c:if标签,例如:

<c:if test="${sessionScope.loginUser.role == 'admin'}"> <li><a href="admin/manage.jsp">院系统计</a></li> </c:if> <c:if test="${sessionScope.loginUser.role == 'dorm_manager'}"> <li><a href="dorm/assign.jsp">宿舍分配</a></li> </c:if>

这种做法的好处是在Filter中只判断登录状态,具体操作权限由页面控制,代码量小,也符合课设的复杂度要求。

4. 宿舍分配与换寝:事务一致性在JDBC中的落地

4.1 宿舍分配的业务规则与操作步骤

宿舍分配是整个系统最容易丢分的模块,因为涉及多张表的联合更新。我在做需求时定的规则是:一个学生只能入住一个房间,student.room_id必须保持单值。一个房间的已住人数不能超过capacity。分配成功后目标房间的current_count加1。如果学生已有房间,要先释放旧房间,再把旧房间的current_count减1,最后执行新分配。这5步操作必须打包在一个事务里,否则会出现学生没有宿舍但床位被占用的不一致数据。

操作步骤SQL操作影响表
1. 校验房间容量SELECT capacity, current_count FROM dorm_room WHERE id=? FOR UPDATEdorm_room
2. 查询学生当前房间SELECT room_id FROM student WHERE id=?student
3. 释放旧房间UPDATE dorm_room SET current_count=current_count-1 WHERE id=?dorm_room
4. 绑定新房间UPDATE student SET room_id=? WHERE id=?student
5. 新房间床位加1UPDATE dorm_room SET current_count=current_count+1 WHERE id=?dorm_room

这张表的顺序至关重要,先查后改,先释放后绑定,最后更新新房间的数量,可以避免释放时房间号还指向旧房间导致外键约束异常。

4.2 在DAO中使用事务的完整代码

下面这段代码是宿舍分配事务的核心实现,我把每一步都加了注释。

public class DormAssignDao { public boolean assignRoom(int studentId, int roomId) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); String sqlCheck = "SELECT capacity, current_count FROM dorm_room WHERE id=? FOR UPDATE"; ps = conn.prepareStatement(sqlCheck); ps.setInt(1, roomId); rs = ps.executeQuery(); if (!rs.next()) return false; int capacity = rs.getInt("capacity"); int currentCount = rs.getInt("current_count"); if (currentCount >= capacity) { conn.rollback(); return false; } String sqlFindOld = "SELECT room_id FROM student WHERE id=?"; ps = conn.prepareStatement(sqlFindOld); ps.setInt(1, studentId); rs = ps.executeQuery(); int oldRoomId = 0; if (rs.next()) oldRoomId = rs.getInt("room_id"); if (oldRoomId > 0) { ps = conn.prepareStatement("UPDATE dorm_room SET current_count=current_count-1 WHERE id=?"); ps.setInt(1, oldRoomId); ps.executeUpdate(); ps = conn.prepareStatement("UPDATE student SET room_id=NULL WHERE id=?"); ps.setInt(1, studentId); ps.executeUpdate(); } ps = conn.prepareStatement("UPDATE student SET room_id=? WHERE id=?"); ps.setInt(1, roomId); ps.setInt(2, studentId); ps.executeUpdate(); ps = conn.prepareStatement("UPDATE dorm_room SET current_count=current_count+1 WHERE id=?"); ps.setInt(1, roomId); ps.executeUpdate(); conn.commit(); return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.close(rs, ps, conn); } } }

逻辑说明:conn.setAutoCommit(false)开启事务,之后的SQL全部在同一个Connection上执行,只有commit后才会真正写入数据库。第1步使用了FOR UPDATE行级锁,防止两个并发请求同时看到剩余床位为1,然后同时分配造成超卖。课堂项目中可能没有并发压力,但在毕业答辩时能说出这个点,就能向老师证明你理解了数据库并发控制。第2步到第4步根据业务分支执行,任何一步失败都会进入rollback(),保证五个操作要么全部成功,要么全部不生效。注意第3步中先更新dorm_room再清空student.room_id,顺序不能反,否则如果dorm_room表更新失败,student表已经变成空宿舍,数据一致就被破坏了。

4.3 换寝场景的复用与报修模块的单表操作

换寝本质上就是“释放旧宿舍+分配新宿舍”,完全可以复用上面的assignRoom方法。只需要在页面层传入当前学生和两个房间号,在Servlet里先判断目标房间是否为空,然后调用同一个DAO方法即可。报修模块则简单很多,只有INSERT和UPDATE两条语句,不需要事务,但同样要使用PreparedStatement。

String sql = "INSERT INTO repair(student_id, description) VALUES(?, ?)"; ps = conn.prepareStatement(sql); ps.setInt(1, studentId); ps.setString(2, description); ps.executeUpdate();

注意setString会自动处理引号和转义字符,这能抵御类似' OR 1=1 --的注入攻击。如果你在源码里看到用Statement或者直接拼接SQL字符串,一定要改成PreparedStatement,因为网上的自动化扫描工具对这类漏洞非常敏感,答辩时也是明显的扣分点。

5. Bootstrap整合JSP页面与部署调试的四个实用技巧

5.1 静态资源路径处理

项目里能看到bootstrap.css、bootstrap.min.css、jquery.dataTables.css这些文件,它们放在webapp/static目录下。JSP页面引入时不能写相对路径,因为转发到子目录页面后会加载不到。最稳妥的方式是使用EL表达式拼接项目根路径。

<link href="${pageContext.request.contextPath}/static/css/bootstrap.min.css" rel="stylesheet"> <script src="${pageContext.request.contextPath}/static/js/jquery.min.js"></script>

如果看到的是<link href="css/bootstrap.min.css">,那么在访问/login这样的Servlet转发时,浏览器会解析成/项目名/css/bootstrap.min.css,导致404。

5.2 数据表格初始化

宿舍管理系统的表格非常多,手写渲染重复代码很累。源码里有jquery.dataTables.css,说明作者已经引入了DataTables。我习惯用下面的配置启用搜索、排序和分页。

$('#studentTable').DataTable({ "pageLength": 10, "language": { "url": "${pageContext.request.contextPath}/static/i18n/Chinese.json" }, "order": [[ 1, "asc" ]] });

pageLength控制每页显示行数,language.url引入中文翻译文件,order指定按第2列升序。重点提醒:DataTables依赖jQuery,必须先加载jQuery再加载DataTables的js,顺序反了会出现DataTable is not a function错误。

5.3 部署时最常见的三个问题及排除方法

问题一:Tomcat启动报ClassNotFoundException: com.mysql.jdbc.Driver。原因一般是mysql-connector-java的jar包没有复制到WEB-INF/lib目录,或者MySQL是8.0以上但代码用了旧驱动名,这时需要下载对应版本的jar包并修改成com.mysql.cj.jdbc.Driver。

问题二:中文乱码,排查三个位置:MySQL数据库表字符集是否为utf8mb4,JDBC URL是否带characterEncoding=utf8,JSP页面是否有pageEncoding="UTF-8"。三处缺一不可,通常是本地正常、服务器乱码。

问题三:登录成功后跳转404。检查sendRedirect的路径是否与webapp目录下的index.jsp大小写一致,Windows不区分大小写,但Linux服务器区分,所以本地能跑部署后却404。

5.4 用一条SQL快速验证系统分配逻辑

最后给你一个有用的验收技巧,在MySQL里执行下面的脚本,检查宿舍分配是否正确。

SELECT r.id, r.room_no, r.capacity, r.current_count, (r.capacity - r.current_count) AS available_beds FROM dorm_room r WHERE r.current_count < r.capacity ORDER BY r.id;

如果查询结果与页面显示一致,说明JSP和数据库联动没有问题。如果出现available_beds为负数,说明并发控制有缺陷,回头检查事务里的FOR UPDATE是否覆盖了所有更新路径。整个系统的后续优化方向,比如加入Redis缓存、线程池连接管理,也可以从这条SQL的数量统计开始评估。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询