简介:面向JSP+MySQL课程实训学生,这份资源提供了一套完整的宿舍管理Web系统,基于JSP动态网页技术与MySQL关系型数据库实现页面动态生成和数据持久化,覆盖学生信息管理、宿舍分配与调整、住宿费用管理、在线报修及统计报表等功能,同时包含用户权限配置,体现高内聚低耦合设计与安全访问控制。压缩包共67个文件、约5.55MB,主体为Java源码、JSP页面、编译后的class、XML配置、SQL脚本及docx作业文档,并附带jar依赖;其中SQL脚本用于快速建库,class文件便于直接部署,文档记录系统设计与使用说明。已有69人学习下载。整体目录结构清晰,除源代码外还提供数据库初始化脚本、项目配置文件和作业文档,便于理解前后端交互、数据库表设计及MVC分层实现,是完成实训报告、答辩演示或二次扩展值得参考的完整素材。
1. 实训作业里的宿舍管理系统,值得先把它跑起来
宿舍管理这类题目在实训项目里出现频率很高,但很多人拿到的源码包要么缺数据库脚本,要么文档和代码对不上。这次这份java-web-student-housion-manager-master.zip算是比较完整的:src里是 JSP 页面和 Servlet,sql目录下有现成的studenthousionmanager.sql,doc里还有设计文档。它的核心价值不是功能多花哨,而是把「学生信息管理、宿舍分配、费用记录、报修跟踪」这一套最常见的宿舍管理流程用 JSP + Servlet + MySQL 完整串了起来。对正在做数据库课程设计或者软件综合实践的人来说,它最适合用来研究两层架构下表单提交、Session 登录态、JDBC 增删改查是怎么协作的。我拆这个项目的时候,最先看的就是 SQL 脚本和 web.xml 的匹配关系,这个习惯建议你保留,能省下大量排查时间。
2. 表结构与数据库初始化:studenthousionmanager.sql 怎么落地
2.1 从 doc 文档梳理出的核心表关系
打开doc/作业.docx,里面描述了系统的整体架构和数据库设计。结合 SQL 脚本,这个项目核心是围绕「学生—宿舍—费用—报修」四条线建表。从实训评分角度,这几张表的关系设计是否合理,直接决定了报告里「数据库设计」这一章能不能拿高分。
我一般拿到 SQL 脚本第一件事不是直接导入,而是先看表名和字段类型,确认主外键关系后再动手。这份脚本里表结构遵循了第三范式,学生表不直接冗余宿舍楼名称,而是通过宿舍ID关联。表与表之间保持了清晰的边界。同一个宿舍对应的多条入住记录,靠「入住时间」字段区分当前状态。
2.2 导入 studenthousionmanager.sql 的两种方式
无论用命令行还是可视化工具,导入前都要确认 MySQL 服务在运行。开发者常用的 MySQL 8.0 和 5.7 两个版本都可以直接跑这份脚本,但注意字符集问题,后面会单独说。
方式一:命令行导入
mysql -u root -p < sql/studenthousionmanager.sql这个命令把脚本文件内容直接交给 mysql 客户端执行。-u指定用户,-p表示需要输入密码,<是 shell 重定向符,把本地文件内容作为 mysql 的标准输入。如果脚本和数据文件不在当前目录,需要写相对或绝对路径,比如mysql -u root -p < /home/user/sql/studenthousionmanager.sql。
方式二:MySQL Workbench 导入
打开 Workbench 后连上本地实例,依次选择Server→Data Import→Import from Self-Contained File,选中studenthousionmanager.sql,在Default Schema里新建或者选择一个目标库,最后点Start Import。这种方式适合想直观看到导入进度和报错信息的人。
导入成功后,用SHOW TABLES;查看表清单,确认核心表已经创建。如果提示Unknown database,说明脚本里没有CREATE DATABASE语句,需要先在命令行执行CREATE DATABASE studenthousionmanager DEFAULT CHARACTER SET utf8mb4;再重新导入。
2.3 字符集、外键与索引的取舍
脚本里如果建表语句没显式指定字符集,默认继承数据库配置。中文乱码是这类项目的头号问题。我一般会在导入前先执行一条 set 命令:
SET NAMES utf8mb4;SET NAMES同时影响客户端、连接和返回结果的字符集,三条链路统一成utf8mb4后,INSERT中文时就不容易变成???。utf8mb4是utf8的超集,能存 emoji 和生僻字,宿舍住客信息里偶尔会录入少数民族姓名用字,用这个更稳。
关于外键,这个项目的表大多用逻辑外键,也就是在业务层通过 SQLJOIN关联,而没有在物理层面加FOREIGN KEY约束。实训项目这么做有个直接好处:批量导入演示数据时不用顾忌外键检查顺序,删数据也方便。代价是数据库层不保证引用完整性,误删宿舍记录时不会有数据库报错拦截。如果要在生产环境用,我建议还是补上物理外键,或者至少在建表后用ALTER TABLE加索引:
ALTER TABLE student ADD INDEX idx_student_dorm_id (dorm_id);dorm_id是学生表关联宿舍表的字段。按这个字段做条件查询很频繁,加索引后能走ref级访问,避免全表扫描。在实训报告里写清这一步,能让答辩老师看到你对索引有意识,而不只是会建表。
导入完成后,需要确认一下项目里的数据库连接配置。一般会写在src下的 JDBC 工具类里,改动点集中在 URL、用户名、密码三个位置。
3. 从登录模块看 JSP 的 Session 与 Filter 协作
3.1 登录状态为什么不能只靠 JSP 页面判断
这个项目的登录模块覆盖了「用户管理」这个功能点。管理员登录后进入主页,不同权限的人看到的菜单不同。如果只在 JSP 页面里用<%=session.getAttribute("user") %>判断,会有一个明显问题:JSP 页面可以被直接访问,未登录用户只要输入带.jsp的 URL,页面里的判断代码确实会拦截,但如果项目里还存在静态资源目录或者 Servlet 转发路径,就可能绕过检查。真正可靠的拦截要在 Servlet 层之前做 Filter。
3.2 用 LoginServlet 处理表单并写入 Session
登录表单提交到LoginServlet,Servlet 里做三件事:接收参数、查库校验、写 Session。核心代码结构如下:
// LoginServlet.java 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); } }request.getParameter获取表单字段,UserDao.findByUsernameAndPassword里是 JDBC 查询,两参数指用户名和密码,返回 null 表示没查到记录。session.setMaxInactiveInterval(30 * 60)把会话超时时间设成 30 分钟,这个值可以按需求调整,实训演示时设长一点比如 60 分钟,避免答辩时中途掉线。
3.3 用 Filter 统一拦截未登录请求
有了 Session 状态,还需要一个过滤器把它们串起来。新建AuthFilter.java:
// AuthFilter.java public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); // 不新建 Session String requestURI = request.getRequestURI(); // 放行登录页和登录接口 if (requestURI.endsWith("login.jsp") || requestURI.contains("loginServlet")) { chain.doFilter(req, resp); return; } if (session == null || session.getAttribute("loginUser") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } chain.doFilter(req, resp); }request.getSession(false)是关键点:参数为false时,如果当前请求没有关联 Session,返回 null,而不是强制创建一个。这样未登录用户访问任意 JSP 或 Servlet 时会被直接重定向回登录页。chain.doFilter是放行操作,代表请求继续走后续过滤器和目标资源。
这个 Filter 需要在web.xml里注册并配置拦截路径为/*。在实训项目里把这个类拆出来写,比在每个 JSP 头部写if判断干净得多,也符合「高内聚、低耦合」的要求。
关于 JSP 编译后的 class 存放在哪,Tomcat 工作时会把 JSP 翻译成 Java 源文件再编译成 class,放在work/Catalina/localhost/你的应用名/org/apache/jsp目录下。JSP 报错时,Eclipse 控制台会显示 JSP 对应生成的 Java 文件名,比如login_jsp.java,去这个目录看可以定位到底是哪一行翻译出错。
3.4 密码存储的直接做法与改进
实训代码里findByUsernameAndPassword大概率是把用户名和密码直接拼 SQL 查询,或者用 PreparedStatement 参数化查询后明文比对。两种做法在开发效率上一个天一个地,但在安全意识上是同一水平:密码明文落库,一旦数据库泄露,所有账号直接暴露。
在实训报告里写这部分,可以提供一种过渡方案:MD5 加盐后存储。
// PasswordUtil.java 片段 public static String md5WithSalt(String password, String salt) { String base = password + salt; byte[] bytes = DigestUtils.md5(base.getBytes(StandardCharsets.UTF_8)); // bytes 转十六进制字符串 return Hex.encodeHexString(bytes); }DigestUtils和Hex来自commons-codec库,Maven 项目引入依赖后可直接使用。盐值可以用用户ID或注册时间戳。这样同一个密码在不同用户下的密文不同,避免 MD5 撞库风险。
对于 5 年以上经验的人来说,StudentHousionManager 这类教学项目最大的学习价值,就是看会话管理和权限拦截是怎么从「页面判断」演进到「Filter 统一拦截」的,这两个符号代表了两层思路,理解了它,后面接 Spring Security 或 Shiro 时就不会觉得抽象。
4. 宿舍分配与报修:SQL 写法和 JSP 渲染的配合
4.1 自动分配空床位:一条 UPDATE 配合子查询
宿舍分配是整个系统的核心操作。管理员选择一个学生,再选择目标宿舍,点击分配后,系统要同时完成两件事:给学生记录写入宿舍ID,把宿舍表的已住人数加一。
我一般会先查清宿舍当前空床位数,再决定是否允许分配:
-- 查询指定宿舍当前入住数 SELECT dorm_id, capacity, occupied, (capacity - occupied) AS available_beds FROM dorm WHERE dorm_id = 101;capacity是宿舍容量,occupied是当前已住人数,available_beds是一个计算列,展示还有几个空床位。如果available_beds小于等于 0,说明该宿舍已满,需要换一间。把这段放在AssignServlet里作为预检查,能有效避免把学生塞进已满宿舍。
确认有床位后,执行分配:
UPDATE student SET dorm_id = 101 WHERE student_id = '2023001'; UPDATE dorm SET occupied = occupied + 1 WHERE dorm_id = 101;两条 UPDATE 不属于同一事务时,如果第一条执行成功、第二条失败,会出现学生已经占了床位但统计数字没增长的脏数据。正确做法是在 Servlet 里关闭自动提交:
// AssignServlet.java 片段 Connection conn = DBUtil.getConnection(); try { conn.setAutoCommit(false); // 关闭自动提交 // 执行上面两条 UPDATE conn.commit(); // 全部成功后提交 } catch (SQLException e) { conn.rollback(); // 任一步失败则回滚 throw new RuntimeException("分配宿舍失败,数据已回滚", e); } finally { DBUtil.close(conn); }setAutoCommit(false)之后,JDBC 不会把每条 SQL 当成独立事务提交。commit()把两条 UPDATE 作为一个整体写入,rollback()保证宿舍改了但学生没改时,两边数据回到执行前的状态。数据库里的宿舍数据一旦出现问题,溯源成本比写这几行代码高得多。
4.2 JSP 列表页用 JSTL 渲染,避免 scriptlet 混写
宿舍列表页在实训项目里往往写成 JSP 中夹杂大量<% %>脚本段,能跑但难维护。稍加整理,就可以改成使用 JSTL 标签渲染:
<!-- dormList.jsp 片段 --> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table class="dorm-table"> <thead> <tr> <th>宿舍号</th> <th>容量</th> <th>已住人数</th> <th>状态</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${dormList}" var="dorm"> <tr> <td>${dorm.dormId}</td> <td>${dorm.capacity}</td> <td>${dorm.occupied}</td> <td> <c:choose> <c:when test="${dorm.occupied >= dorm.capacity}"> <span class="badge badge-full">已满</span> </c:when> <c:otherwise> <span class="badge badge-available">有空位</span> </c:otherwise> </c:choose> </td> <td> <a href="assign?dormId=${dorm.dormId}">分配</a> <a href="dormDetail?dormId=${dorm.dormId}">详情</a> </td> </tr> </c:forEach> </tbody> </table>c:forEach相当于 Java 里的增强 for 循环,items是 Servlet 放入 request 域中的List<Dorm>,var="dorm"是每次循环时拿到的单个对象。${dorm.occupied}这种写法自动调用对象的 getter 方法,等价于dorm.getOccupied()。c:choose和c:when组成条件判断,根据占用率切换 CSS 样式类。用标签库替换 scriptlet 后,前端人员可以直接改 HTML 和 CSS,不用碰 Java 代码。
JSP 页面编译后可读性差的一个次要原因是把业务代码写进了视图层。保持在 Servlet 里把计算完成、传参给 JSP 的模式,页面就只做展示和简单判断。
4.3 报修流程:用状态字段驱动进度
报修模块是这个项目里容易被忽视但很加分的点。报修单从提交到完成,需要清晰的状态流转:
| 状态码 | 含义 | 可执行操作 |
|---|---|---|
| 0 | 待受理 | 管理员查看详情,确认受理 |
| 1 | 维修中 | 维修人员填报进度 |
| 2 | 已完成 | 学生确认,关闭工单 |
| 3 | 已驳回 | 填写驳回原因 |
状态更新语句:
UPDATE repair_order SET status = 1, handler = 'admin01', handle_time = NOW() WHERE order_id = 12;NOW()取数据库服务器当前时间,避免应用服务器和数据库服务器时间不同步导致记录偏差。后台管理页面按状态字段筛选列表,只做一个WHERE status = 0的查询就能让管理员看到待受理工单。状态字段的编码规范定好后,前端下拉框选项和 SQL 条件都跟着这套码走,不容易出现一处改了另一处漏改的问题。
5. 费用统计与报表:GROUP BY 聚合 SQL 与常见性能坑
5.1 统计各宿舍楼欠费总额:一条 SQL 完成
费用管理模块在实训演示里最常见的操作是「生成月报表」。如果逐个宿舍查询再在 Java 里累加,代码会很长,数据库来回请求次数也多。正确姿势是把汇总逻辑推到 MySQL 里:
SELECT d.building_id, COUNT(*) AS total_students, SUM(CASE WHEN f.pay_status = 'unpaid' THEN 1 ELSE 0 END) AS unpaid_count, SUM(CASE WHEN f.pay_status = 'unpaid' THEN f.amount ELSE 0 END) AS unpaid_amount FROM dorm d JOIN student s ON d.dorm_id = s.dorm_id JOIN fee f ON s.student_id = f.student_id WHERE f.fee_month = '2024-05' GROUP BY d.building_id;CASE WHEN在聚合函数内部做条件判断,f.pay_status = 'unpaid'表示未缴费。SUM(CASE WHEN ... THEN 1)等价于按条件计数,THEN f.amount则累加金额。GROUP BY d.building_id把结果按楼栋分组。这条 SQL 返回的每一行就是一栋楼的缴费汇总,Java 里只需要遍历结果集填充到 JSP 表格即可。
5.2 费用表慢查询排查:优先看执行计划
报表类查询在数据量小的时候没感觉,但实训数据如果灌到几万条,fee表按student_id和fee_month过滤就会变慢。排查方法永远不是猜,而是看执行计划:
EXPLAIN SELECT * FROM fee WHERE student_id = '2023001' AND fee_month = '2024-05';EXPLAIN输出结果里重点看type和rows两列。type为ALL表示全表扫描,rows是预估扫描行数。如果rows接近全表,说明索引没生效。此时在建表语句里补一个联合索引:
ALTER TABLE fee ADD INDEX idx_fee_student_month (student_id, fee_month);(student_id, fee_month)的顺序有讲究。查询条件是两个字段等值匹配,把区分度高的student_id放前面,可以让索引快速定位到具体某几个学生的记录,再用fee_month做二次过滤。顺序写反会导致fee_month上浪费索引空间,student_id却没有参与索引查找,得不偿失。
EXPLAIN的使用要写到实训报告的测试章节里,配合SHOW INDEX FROM fee;查看现有索引情况,形成一套「先看现状、再补索引、后验证」的思路。
5.3 分页查询避免深分页过头
报表列表页一般都会分页,老项目常见的写法是:
SELECT * FROM fee WHERE student_id = '2023001' ORDER BY create_time DESC LIMIT 1000, 20;LIMIT 1000, 20表示跳过前 1000 条,取 20 条。MySQL 收到这个语句后,会把前 1020 条全部查出来再丢弃前 1000 条。页数越深,偏移量越大,效率越差。优化方式是记住上一页最后一条记录的id:
SELECT * FROM fee WHERE student_id = '2023001' AND id < 1000 ORDER BY id DESC LIMIT 20;id < 1000是上一页最后一条记录的主键值,MySQL 从索引定位到 1000 这个位置,然后倒序扫 20 条返回。这种「键集分页」在深分页场景下性能稳定,不会随着页数增大而变慢。对实训项目来说,把这个细节写在代码注释里,是区分「能用」和「会用」的明显标志。
6. 把实训项目改成能上线的小系统:两个低成本改造技巧
6.1 数据库连接从 DriverManager 换成 Druid 连接池
实训代码里最普遍的做法是每次请求都Class.forName("com.mysql.jdbc.Driver")再DriverManager.getConnection(url, user, password)。这样做在小并发下没问题,但服务器压力一大,频繁创建和销毁连接会拖垮数据库。
可以升级为使用 Druid 连接池。Maven 中引入依赖后,在工具类里初始化一次数据源:
// DBUtil.java 升级片段(放入 Spring 容器则省略此配置) private static DruidDataSource createDataSource() { DruidDataSource ds = new DruidDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/studenthousion?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("root"); ds.setInitialSize(5); // 初始连接数 ds.setMaxActive(20); // 最大活跃连接数 ds.setMinIdle(5); // 最小空闲连接数 return ds; }setInitialSize在启动时预建连接,首次请求响应速度更快。setMaxActive控制峰值时向数据库申请的最大连接数,超过后请求排队。setMinIdle保持最小空闲连接数,避免流量低谷后连接被全部回收。连接池由 Druid 自己管理生命周期,项目里只需要每用一个连接,在finally块里调用conn.close()。连接池的close()并不真正断开数据库连接,而是归还给池子。
顺带说明,JDBC URL 里的serverTimezone=Asia/Shanghai必须加。MySQL 8.0 驱动默认使用服务器时区,不指定会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized的错,这个错误信息乱码本身就是字符集问题的经典提示。
6.2 报修状态变更加一条审计日志
线上系统比实训多一个需求:出了问题要知道谁在什么时候改了什么。加一张简单日志表即可:
CREATE TABLE repair_log ( log_id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, old_status TINYINT, new_status TINYINT, operator VARCHAR(50), op_time DATETIME DEFAULT CURRENT_TIMESTAMP );状态更新时执行:
UPDATE repair_order SET status = 2 WHERE order_id = 12; INSERT INTO repair_log(order_id, old_status, new_status, operator) SELECT 12, status, 2, 'admin01' FROM repair_order WHERE order_id = 12;INSERT ... SELECT先把旧状态查出来再写入日志,不用在 Java 代码里额外查一次。这两条语句建议也放进同一事务中,避免日志漏写无法回溯。这个表建好后,后续要做报表、统计工单处理时长,都能从日志表里拿到时间点数据。
最后提醒一个实训项目迁移到新电脑或新 Tomcat 版本时常见的问题:Tomcat 10 及以上版本把 Java EE 换成了 Jakarta EE,项目的javax.servlet.*包需要批量改成jakarta.servlet.*。用 IDE 的全局替换功能改完后再编译,同时记得把站点根目录下的work目录删掉,Tomcat 重启时会重新编译 JSP,临时生成的 class 文件不会被旧缓存干扰。
本文还有配套的精品资源,点击获取