简介:面向高校计算机专业学生的Java Web学生宿舍管理系统源码包,兼顾学习、毕业设计与期末大作业场景。系统覆盖宿舍信息、学生信息、水电费等核心管理模块,并实现登录验证、信息增删改查及条件检索等常用功能;所有源码均已在本地编译运行通过,评审分达到98分,难度适中,适合具备JavaWeb基础者参考或二次开发,且经助教老师审定,内容可靠。压缩包为zip格式,共133个文件、约3.3MB,其中包含20个Java类、20个class编译文件、18个JSP页面、52个JavaScript脚本、5个CSS样式、SQL数据库脚本以及Eclipse工程配置文件等,目录结构完整,可快速导入IDE运行。已有144人学习下载。通过这份项目,既能理清Servlet+JSP+DAO的分层开发流程和数据库表设计思路,也能为毕业设计文档编写、答辩演示和功能扩展提供稳定可用的起点。
1. 学生宿舍管理系统:为什么它是JavaWeb课程设计里的常青树
如果你用“javaweb学生宿舍管理系统”去搜,会发现这个题目几乎出现在每一届的JavaWeb课程设计和毕业设计选题里。原因很简单:它不是一个玩具项目。宿舍管理天然包含楼栋、房间、床位、学生、入住、退宿、调宿、访客、报修、水电费这些业务实体,数据之间有清晰的关联关系,天然适合用来展示数据库外键设计、事务处理、多表联查这些核心能力。市面上那些“高分项目”之所以被反复推荐,并不是因为技术多前沿,而是它把JavaWeb最常见的技术栈——Servlet/JSP或Spring+SpringMVC+MyBatis、MySQL、Tomcat——完整地串了一遍,从建库建表到登录鉴权再到CRUD报表,全部业务闭环。
这篇笔记适合两类人。一类是做课程设计或毕业设计的学生,需要的是一个能跑通、能讲清楚、答得上答辩问题的完整基线;另一类是想练手JavaWeb项目完整案例的开发者,想看看一个“看起来简单”的管理系统里,哪些地方才是真正决定项目能不能用的细节。我会按我自己做这套系统的顺序来写:先建库建表,再做登录鉴权和权限拦截,再做核心维护逻辑,最后说部署和排错。整个过程用IDEA运行javaweb项目配置开始,到Tomcat正式部署结束,每一步都给出能直接抄的代码和参数。
在动手之前,先说一个容易被忽略的结论:这类项目拿到高分或者能被真正用起来,关键不在“功能多”,而在“数据完整”。很多人的系统能登录、能增删改查,但一查数据就会发现——房间床位状态是乱的,学生退宿后历史记录没了,宿舍楼和学生的关联字段对不上。这些问题在答辩现场一问就露馅。下面从数据库开始,把这一步做扎实。
2. 数据库设计先行:宿舍管理的核心表与字段约束
2.1 宿舍管理系统的实体关系拆解
一个宿舍管理系统最核心的实体是:楼栋、房间、床位、学生、入住记录。常见做法是用五张表加一张关联表来建模。楼栋和房间是父子关系,房间和床位是父子关系,学生和床位通过入住记录建立多对多关系——一个学生多次入住不同床位,一个床位在不同时间段住过不同学生。
字段设计上要注意几个关键点。楼栋表要有楼栋编号和楼栋名称,房间表要有房间号、所属楼栋ID、房间类型(四人间/六人间)、可住人数、当前已住人数。床位表要有床位号、所属房间ID、状态(空闲/占用)。学生表要有学号、姓名、性别、学院、专业、联系方式。入住记录表是这张设计里最重要的表,它必须包含学生ID、楼栋ID、房间ID、床位ID、入住时间、退宿时间,并且用退宿时间为空来表示“当前正在入住”。
这个模型的关键在于:房间的已住人数不应该是手工维护的,而是通过统计入住记录里退宿时间为空的记录数得出来的。我见过很多版本的项目在房间表里放一个“已住人数”字段,然后在入住和退宿时手动加减,这种做法在并发和异常情况下必然导致数据不一致。正确做法是房间表只存固定容量,入住人数每次通过SQL实时统计。
-- 房间的已住人数通过统计入住记录得到,不在房间表冗余维护 SELECT r.room_id, r.capacity, COUNT(sr.student_id) AS current_count FROM room r LEFT JOIN stay_record sr ON r.room_id = sr.room_id AND sr.checkout_time IS NULL WHERE r.room_id = ? GROUP BY r.room_id, r.capacity这段SQL里,LEFT JOIN保证了没有入住记录的空房间也会出现在结果中,checkout_time IS NULL这个条件筛出当前有效的入住记录。逻辑说明:房间容量是房间表的固有属性,当前人数是动态状态,两者通过统计关联得到。参数说明:r.capacity字段类型建议用TINYINT,四人间、六人间这种容量值不会超过255,用INT是浪费。
2.2 建表SQL与字段类型选型
字段类型选型上有一个血泪经验:学号不要用INT,要用VARCHAR(20)。原因有两个,一是学号经常以0开头,INT会丢掉前导零;二是学号是字符串语义,不参与数值运算。同理,床位号、房间号这类编号字段也都是字符串语义。时间字段统一用DATETIME,不要用TIMESTAMP,后者在2038年会有溢出问题,而且受时区影响,排查问题时会多一个干扰项。
CREATE TABLE building ( building_id INT PRIMARY KEY AUTO_INCREMENT COMMENT '楼栋ID', building_no VARCHAR(10) NOT NULL UNIQUE COMMENT '楼栋编号,如A栋', building_name VARCHAR(50) NOT NULL COMMENT '楼栋名称' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='楼栋表'; CREATE TABLE room ( room_id INT PRIMARY KEY AUTO_INCREMENT, building_id INT NOT NULL, room_no VARCHAR(10) NOT NULL, capacity TINYINT NOT NULL DEFAULT 4, room_type VARCHAR(20) DEFAULT '四人间', UNIQUE KEY uk_building_room (building_id, room_no), CONSTRAINT fk_room_building FOREIGN KEY (building_id) REFERENCES building(building_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房间表'; CREATE TABLE bed ( bed_id INT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, bed_no VARCHAR(5) NOT NULL, status TINYINT DEFAULT 0 COMMENT '0空闲 1占用', UNIQUE KEY uk_room_bed (room_id, bed_no), CONSTRAINT fk_bed_room FOREIGN KEY (room_id) REFERENCES room(room_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='床位表'; CREATE TABLE student ( student_id INT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号', student_name VARCHAR(50) NOT NULL, gender TINYINT COMMENT '0女 1男', college VARCHAR(50) COMMENT '学院', major VARCHAR(50) COMMENT '专业', phone VARCHAR(20) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生表'; CREATE TABLE stay_record ( record_id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, building_id INT NOT NULL, room_id INT NOT NULL, bed_id INT NOT NULL, checkin_time DATETIME NOT NULL, checkout_time DATETIME DEFAULT NULL, CONSTRAINT fk_record_student FOREIGN KEY (student_id) REFERENCES student(student_id), CONSTRAINT fk_record_room FOREIGN KEY (room_id) REFERENCES room(room_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入住记录表';这段建表SQL里有几个设计决策要说明。外键约束FOREIGN KEY在这个项目里必须保留——很多人为了省事去掉外键,结果业务层代码逻辑写错时,脏数据直接进了库,答辩现场被问数据一致性时非常被动。UNIQUE KEY用于防止楼栋内重复房间号、房间内重复床位号,这是数据完整性的最后一道防线。所有表都用InnoDB,因为MyISAM不支持事务,而入住和退宿操作必须在一个事务里完成——同时更新床位状态和插入入住记录,任何一个失败都要回滚。
参数说明:autoincrement起始值如果用了默认1,在删除数据后再次插入会跳号,这是正常现象,不要试图去改AUTO_INCREMENT的值让它连续。字符集统一utf8mb4,如果你用utf8,存emoji昵称会报错,虽然学生表里大概率没有emoji,但统一字符集能少一个排查方向。排序规则用默认的utf8mb4_general_ci即可,不要用utf8mb4_unicode_ci,在这个项目里没有任何性能或正确性差异,纯属折腾自己。
2.3 初始化数据的坑:楼栋、房间、床位一次性生成
建完表之后要生成初始化数据。楼栋和房间可以手动插入几条,但床位如果也是手动一条条插,50个房间每个4个床位就是200条INSERT,纯属体力活。常见做法是用一个存储过程或者一个Java程序批量生成。我更推荐写一个简单的Java初始化程序,在项目启动时执行,而不是用存储过程——因为存储过程对新手来说是个黑匣子,出错了不好排查。
// 初始化数据生成器:为每个房间生成床位记录 public void initBedData() { // 假设room表里已经有数据了,查出所有房间ID List<Integer> roomIds = roomMapper.selectAllRoomIds(); for (Integer roomId : roomIds) { Room room = roomMapper.selectById(roomId); // 根据房间容量生成对应数量的床位,床号从1开始编号 for (int i = 1; i <= room.getCapacity(); i++) { Bed bed = new Bed(); bed.setRoomId(roomId); bed.setBedNo(String.valueOf(i)); bed.setStatus(0); // 初始状态为空闲 bedMapper.insert(bed); } } }这段代码逻辑说明:先查出所有房间ID,逐个获取容量,按容量生成床位。参数说明:床号用String.valueOf(i)生成,从1开始,比如1号床就是“1”,四人间就有1到4号床。这里有个细节,床号不要补零成“01”,因为排序时“01”“02”“10”的字典序是对的,但如果不补零,“1”“2”“10”的字典序会乱,到时候管理页面上床位排序就错了。如果你一定要补零,String.format("%02d", i)可以生成两位编号。
初始化数据这一步做到位,后面做入住分配时会非常顺畅——因为所有房间的床位都是完整的,不会出现在界面上下拉框里看不到某些床位的情况。
3. 从登录到鉴权:用Filter还是Interceptor,怎么选
3.1 登录模块的设计:密码加密与Session管理
这个系统的登录模块是整个项目里第一个能体现“工程意识”的地方。最忌讳的做法是密码明文存数据库,登录时直接SELECT * FROM user WHERE username=? AND password=?。这种写法在答辩时一旦被问到安全问题,基本等于送分题给对方。正确做法是密码加盐哈希,哈希算法用SHA-256或BCrypt,再配合一个随机盐值。
// 注册时生成随机盐值,并用盐值+密码做SHA-256哈希 public String hashPassword(String rawPassword, String salt) { String salted = salt + rawPassword; // 盐值前置,简单且够用 MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] hash = md.digest(salted.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : hash) { sb.append(String.format("%02x", b)); } return sb.toString(); }逻辑说明:盐值前置拼接后做哈希,这样即使两个人使用相同密码,因为盐值不同,最终的哈希值也不同,可以防止彩虹表攻击。参数说明:盐值可以用UUID.randomUUID().toString()生成,存到用户表单独的salt字段里;MessageDigest是JDK自带的工具类,不需要引入额外依赖。如果你用的是Spring Security,直接用它提供的BCryptPasswordEncoder即可,但在这个项目里手写SHA-256就够了,因为功能简单,不需要引入一套完整的安全框架。
登录成功后,用户信息放进Session。这里有个关键细节:不要把用户密码哈希值放进Session,只放用户ID、用户名、角色这些必要信息。Session的maxInactiveInterval建议设置为30分钟——Tomcat默认就是30分钟,不需要额外配置。如果是“记住我”功能,需要用到Cookie+Token方案,但一般的宿舍管理系统用不着,别给自己挖坑。
3.2 Filter拦截器配置:哪些页面需要登录才能访问
登录鉴权的核心问题是:整个系统的哪些URL需要登录才能访问。常见做法是定义一个LoginFilter,在web.xml或Spring配置里注册,然后配置url-pattern。但这里有一个新手容易翻车的点:很多人把url-pattern配成/*,然后把不需要登录的资源——比如登录页面、登录请求、CSS、JS、图片——全部在Filter里放行。这个思路没错,但如果在Filter里忘了放行静态资源,就会出现一个诡异现象:首页能打开,但页面样式全部丢失,因为CSS被拦截了。
<filter> <filter-name>loginFilter</filter-name> <filter-class>com.dorm.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String uri = request.getRequestURI(); // 登录页面、登录请求、静态资源直接放行 if (uri.endsWith("/login.jsp") || uri.endsWith("/login") || uri.contains("/static/") || uri.endsWith(".css") || uri.endsWith(".js") || uri.endsWith(".png") || uri.endsWith(".jpg")) { chain.doFilter(req, resp); return; } // 检查Session中是否有登录用户 HttpSession session = request.getSession(false); if (session != null && session.getAttribute("loginUser") != null) { chain.doFilter(req, resp); } else { // 未登录则重定向到登录页 response.sendRedirect(request.getContextPath() + "/login.jsp"); } }逻辑说明:request.getSession(false)里的false参数是关键——如果传true,未登录用户的请求会强制创建一个新Session,这意味着每个未被拦截的请求都会产生一个无用Session,时间长了Tomcat内存里全是垃圾Session。参数说明:静态资源放行条件用uri.contains("/static/")覆盖项目里统一存放静态资源的目录,.css、.js、图片后缀单独再兜底一层,双保险。放行条件里还包括了/login这个POST请求路径,否则用户提交登录表单时会被当成未登录拦截,这是最常见的一个逻辑漏洞。
3.3 角色权限:管理员和宿管员的菜单级控制
宿舍管理系统一般有两种角色:系统管理员和宿管员。管理员可以管理楼栋、房间、学生、账号;宿管员只能做日常操作——分配房间、登记入住、退宿。如果用SSM框架,可以在Interceptor里判断角色,也可以用更简单的方案:在JSP页面里用自定义标签或c:if来控制菜单显隐。但真正的后端拦截不能只靠隐藏菜单,因为懂一点HTTP的人可以直接构造URL去访问管理员接口。
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); User user = (User) session.getAttribute("loginUser"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return false; } // 超级管理员直接放行 if ("admin".equals(user.getRole())) { return true; } // 普通管理员只能访问以 /dorm 开头的URL,访问 /system 开头的URL则拒绝 String uri = request.getRequestURI(); if (uri.startsWith(request.getContextPath() + "/system/") && !"admin".equals(user.getRole())) { response.setStatus(403); request.getRequestDispatcher("/WEB-INF/views/error/403.jsp").forward(request, response); return false; } return true; }这段代码是SpringMVC的HandlerInterceptor实现,配置在spring-mvc.xml里,只对DispatcherServlet映射的URL生效。逻辑说明:这个方案比纯Filter更精细,因为Interceptor在HandlerMapping之后执行,可以拿到处理器信息,可以用@RequestMapping的路径来精确控制权限,而不是靠字符串匹配URL。参数说明:/system/这个路径前缀约定是管理员功能模块,/dorm/是宿管日常操作模块,这个前缀约定在Controller的@RequestMapping里要严格遵守,否则权限控制就是摆设。
虽然SpringMVC的Interceptor功能更强大,但Filter负责的登录状态检查和Interceptor负责的权限检查在实际项目中经常会同时存在。分工是:Filter管“你是谁”的粗粒度拦截,Interceptor管“你能干什么”的细粒度控制。如果你用Servlet+JSP做这个项目,没有Interceptor可用,那就把角色判断也加进Filter里,逻辑差不多,只是代码会多一点。
4. 核心业务逻辑:入住、退宿、调宿的事务边界
4.1 入住分配:事务里完成状态更新与记录插入
入住操作是宿舍管理系统里最需要小心的地方,因为它涉及多张表的联动修改:床位状态从空闲变成占用、房间已住人数加一、新建一条入住记录。这三个操作必须在一个事务里完成,否则任何一步失败都会留下脏数据——比如床位状态变成占用了,但入住记录没插进去,床位就平白无故消失了。
@Transactional(rollbackFor = Exception.class) public void checkIn(CheckInRequest req) { // 1. 校验床位状态是否为空闲 Bed bed = bedMapper.selectByIdForUpdate(req.getBedId()); if (bed.getStatus() == 1) { throw new BusinessException("该床位已被占用"); } // 2. 校验学生是否已有未退宿记录 int activeCount = stayRecordMapper.countActiveByStudentId(req.getStudentId()); if (activeCount > 0) { throw new BusinessException("该学生已有未退宿的入住记录"); } // 3. 更新床位状态 bedMapper.updateStatus(req.getBedId(), 1); // 4. 插入入住记录 StayRecord record = new StayRecord(); record.setStudentId(req.getStudentId()); record.setBuildingId(req.getBuildingId()); record.setRoomId(req.getRoomId()); record.setBedId(req.getBedId()); record.setCheckinTime(new Date()); // 退宿时间默认为NULL,表示当前正在入住 stayRecordMapper.insert(record); }逻辑说明:@Transactional注解保证方法内所有操作在同一个事务中,任何一个异常都会整体回滚。rollbackFor = Exception.class这个参数非常重要——Spring默认只在抛出RuntimeException时回滚,如果你抛的是自定义的BusinessException(通常继承自RuntimeException),那没问题;但如果你抛的是受检异常,必须加上这个参数,否则事务不会回滚,数据照样写入。参数说明:selectByIdForUpdate里的FOR UPDATE是行级锁,防止两个管理员同时给同一个学生分配同一个床位——虽然管理器系统并发场景不多,但加上不亏,代价只是多几行SQL。
校验逻辑里有个关键的业务规则:一个学生只能有一条未退宿的入住记录。这条规则能杜绝一个学生同时住两个床位的情况,但也意味着如果要调宿,必须先退宿再入住,或者另写一个调宿方法。业务规则用代码写死后,数据库层面不需要再加唯一索引——因为stay_record表允许同一学生有多条历史记录,只有“当前未退宿的记录最多一条”这个约束,而这个约束用唯一索引不好表达。
4.2 退宿逻辑:不要删除记录,要更新状态
退宿操作最常见的错误是直接DELETE掉入住记录。这样做看起来干净,但历史记录就全部丢了——你是为了要一个“住在哪个房间、什么时候住过”的历史才设计的stay_record表,如果退宿时删记录,这张表就失去意义了。正确做法是把checkout_time字段从NULL更新为当前时间,表示这次入住已经结束。这和MySQL里修改结构时先备份数据是一个道理——数据一旦删除就没有后悔药了。
@Transactional(rollbackFor = Exception.class) public void checkOut(Integer recordId) { // 1. 查出入住记录,确认它是未退宿状态 StayRecord record = stayRecordMapper.selectById(recordId); if (record == null) { throw new BusinessException("入住记录不存在"); } if (record.getCheckoutTime() != null) { throw new BusinessException("该记录已完成退宿,请勿重复操作"); } // 2. 更新退宿时间 stayRecordMapper.updateCheckoutTime(recordId, new Date()); // 3. 释放床位 bedMapper.updateStatus(record.getBedId(), 0); }逻辑说明:退宿只做时间戳更新和床位状态释放,不删除任何数据。这里要注意,更新退宿时间和释放床位是两个独立的SQL,必须放在同一个事务里——如果updateCheckoutTime成功而updateStatus失败,就会出现“学生已退宿但床位还被占用”的问题。参数说明:checkout_time字段在数据库里允许为NULL,NULL就是“未退宿”的语义,不要用某个特殊日期比如1970-01-01来替代NULL,这种魔法值会给所有查询增加一层判断逻辑。
4.3 查询列表的SQL优化:分页的count和page要一致
管理系统的首页通常是房间列表或学生列表,右上角一堆筛选条件、下面一个表格、底部一个分页栏。分页查询的SQL是一个常见翻车点:count语句和page语句的WHERE条件不一致。比如count查的是筛选后的总条数,而page查的是从第1页开始的数据,但你没加同一个筛选条件,导致总页数和实际数据对不上——用户看到“共3页”,点第2页却一片空白。
尽量用同一条筛选逻辑生成两个SQL,或在Mapper里把动态SQL抽出来复用。MyBatis的<sql>标签可以抽公共片段,这是最省事的做法。
<sql id="roomListWhere"> <where> <if test="buildingId != null"> AND r.building_id = #{buildingId} </if> <if test="roomType != null and roomType != ''"> AND r.room_type = #{roomType} </if> <if test="status != null"> AND r.status = #{status} </if> </where> </sql> <select id="countRoomList" resultType="int"> SELECT COUNT(*) FROM room r <include refid="roomListWhere"/> </select> <select id="selectRoomList" resultType="RoomVO"> SELECT r.*, b.building_name FROM room r LEFT JOIN building b ON r.building_id = b.building_id <include refid="roomListWhere"/> ORDER BY r.building_id, r.room_no LIMIT #{offset}, #{pageSize} </select>逻辑说明:<sql>标签定义公共WHERE片段,count和page两个查询都引用它,这样从根源上杜绝条件不一致。参数说明:LIMIT #{offset}, #{pageSize}是MySQL的分页语法,offset的算法是(pageNum - 1) * pageSize,这个计算在Service层完成,不要在SQL里算。比如第2页每页10条,offset = (2-1) * 10 = 10,代表从第11条开始取。
5. 避坑指南:IDEA运行、数据库连接、中文乱码的5个经典问题
5.1 IDEA配置Tomcat后端口被占用
现象:点Run按钮启动Tomcat,控制台报Port 8080 was already in use,或者在浏览器访问项目路径时看到的是另一个应用的页面。如果你的电脑里同时装了Oracle、Nacos或者其他Java应用,8080被占用的概率不低。
原因:8080是Tomcat的默认端口,但其他开发工具也可能用这个端口。IDEA里创建Tomcat Server配置时,会默认使用Tomcat安装目录下的server.xml里的配置,但如果你手动改过端口又在IDEA里配了JMX端口,两个Tomcat可能都在监听8080。
解决:先找到占用端口的进程,lsof -i:8080(Mac/Linux)或netstat -ano | findstr 8080(Windows),看到PID后用tasklist或ps -ef定位是什么程序。如果是残留的Java进程,直接kill掉;如果是被你的其他项目占用,就在conf/server.xml里改HTTP端口,把8080改成8090。改完之后在IDEA的Server | HTTP port里也要同步改,这两个地方的端口必须一致,否则IDEA会带着不同配置启动Tomcat,启动后访问的端口和预期不一致,你会误以为项目部署失败了。
5.2 JDBC连接MySQL报中文乱码
现象:页面上显示的学生姓名、楼栋名称全是???,或者从数据库里查出来的是乱码,控制台日志也打印中文乱码,但数据库和表的字符集都已经是utf8mb4了。
原因:三层编码设置不匹配。第一层是数据库连接URL——如果你的连接串是jdbc:mysql://localhost:3306/dorm?characterEncoding=utf8,那没问题;但如果漏了characterEncoding=utf8,驱动会使用MySQL服务器的默认字符集,很可能不是UTF-8。第二层是Tomcat的URI编码——GET请求的URL参数默认用ISO-8859-1解码,浏览器发送UTF-8编码的中文参数,Tomcat用错编码解码自然乱码,这个需要在server.xml的Connector节点上配置URIEncoding="UTF-8"。第三层是页面响应编码——JSP页面顶部没有加<%@ page contentType="text/html;charset=UTF-8" %>,响应头里没有charset=UTF-8,浏览器就会用默认编码解析。
解决:连接URL加参数是第一步;URIEncoding加到Connector节点是第二步;JSP页面的page指令是第三步。排查时按这个顺序检查三处,一般能解决。特别注意characterEncoding的值是单数UTF-8不是UTF8,两个写法MySQL驱动都接受,但统一用带连字符的写法减少记忆负担。
5.3 MyBatis返回的Map类型字段为null时丢键
现象:查询结果用Map<String, Object>接收时,某个字段为NULL,Map里直接没有这个key。前端JSP用${map.fieldName}取值,取不到变成空白,和预期不符。
原因:MyBatis默认的callSettersOnNulls配置是false,意味着当查询结果为NULL时,ResultSet里的列不会调用Map的put方法,所以Map里缺失这个键。
解决:在mybatis-config.xml里加一行<setting name="callSettersOnNulls" value="true"/>。这样NULL字段也会以null值放进Map。这个配置在调试时非常有帮助——否则你很难区分“这个字段是NULL”和“这个字段在Map里不存在”,排查问题时会多绕很多弯路。
5.4 修改了代码但浏览器还是旧页面
现象:在IDEA里改了JSP或Java代码,重启了Tomcat,但是浏览器访问的还是旧内容,按Ctrl+F5强制刷新也没用。
原因:IDEA热部署的配置不对,或者浏览器缓存了旧页面。IDEA默认的On Update Action是Restart Server,如果没有设置成Update classes and resources,修改Java代码后不会自动编译并替换class文件,而JSP文件不改的话页面内容也不会变。另外,浏览器对静态资源的缓存策略也会导致旧内容滞留。
解决:在IDEA的Run/Debug Configurations | Server | On Update Action里选择Update classes and resources,这样每次修改代码后按Ctrl+F10可以热更新。如果是JSP修改后不生效,检查Tomcat的web.xml里<jsp-config>的checkInterval是不是默认的0——为0时不检查JSP变更,需要手动设置成<checkInterval>1</checkInterval>(秒)或在conf/web.xml里改。另外,开发阶段可以给JSP页面加个版本号参数,<script src="/static/js/app.js?v=20240101">,强制浏览器重新加载。
5.5 数据库连接池参数设置不当导致Connection耗尽
现象:系统运行一段时间后,所有页面都卡住,控制台报Connection is not available, request timed out,重启Tomcat后恢复正常,但过一会儿又重新卡住。
原因:连接池的最大连接数太小,或者某个请求占用了连接但没有释放——比如Connection没有放在finally块或try-with-resources里关闭,连接泄漏了。Tomcat的JDBC连接池默认maxActive是20,如果系统有几十个并发的慢查询,20个连接很快就用完了。
解决:这个问题的根治办法不是调大maxActive,而是找到泄漏连接的代码。排查思路:在连接池配置里加上testOnBorrow="true"和validationQuery="SELECT 1",这样每次借出连接时会校验连接是否有效,但这个是亡羊补牢——真正有效的做法是检查所有DAO层代码,确保Connection、PreparedStatement、ResultSet都用try-with-resources关闭。如果没找到泄漏点,可以临时把maxActive调到50看看是否缓解,但这只是治标,连接泄漏不解决,50个连接也会耗尽。
6. 从开发到上线:Tomcat部署、验证清单和后续演进
6.1 手动部署到Tomcat的三种方式对比
在IDEA里直接Run能跑起来,不代表项目就算“部署完了”。真正上线时,你面对的是没有IDEA的服务器环境。能证明自己真的理解部署流程的方法,是把WAR包丢到Tomcat的webapps目录下,启动Tomcat,访问成功。这三种方式各有适用场景——IDEA内嵌部署只适合开发调试;manager界面热部署适合偶尔更新;手动放WAR包到webapps最传统也最可控。
# 先用Maven打包,生成WAR文件 mvn clean package -DskipTests # 把打好的WAR包复制到Tomcat的webapps目录 cp target/dormitory.war /opt/tomcat/apache-tomcat-9.0.85/webapps/ # 启动Tomcat(前台启动,便于看日志) /opt/tomcat/apache-tomcat-9.0.85/bin/startup.sh # 查看启动日志,确认没有异常 tail -f /opt/tomcat/apache-tomcat-9.0.85/logs/catalina.out逻辑说明:-DskipTests跳过测试代码编译,但编译测试代码本身不会跳过——如果你写了一些失败的单测导致打包失败,这个参数只跳过执行。WAR包放到webapps后,Tomcat启动时会自动解压并部署。参数说明:catalina.out是Tomcat的标准输出日志,启动时如果有Exception或Error,都在这个文件里,排查启动失败的第一动作就是tail这个文件,而不是去翻IDEA的控制台。
部署到服务器上时,我一般还会做两件事。一是把conf/server.xml里的Host节点配置一个appBase指向专门的部署目录,而不是默认的webapps,这样系统升级时只需要替换WAR包,不会把Tomcat自带的默认应用也暴露出去。二是改掉Tomcat默认的8005端口——这是Tomcat的关闭端口,任何本机进程都可以通过向这个端口发送SHUTDOWN命令来关闭Tomcat,不换端口等于给系统留了一个后门。这个动作虽小,但体现的是上线意识,答辩时提到这个细节会很加分。
6.2 验收验证清单:从用户视角检查每一个核心流程
系统做完之后,要按用户的操作路径把核心流程全部走一遍,这是赶在答辩前最后一刻才发现“原来这里根本没通”的最后防线。我建议准备一份验证清单,每完成一项打个勾,不要凭感觉说“应该没问题”。
第一个必测项是“登录-退出-登录”的闭环——登录成功后刷新页面是否保持登录状态;退出后再访问受保护的URL,是否被正确重定向到登录页。第二个是“学生入住全流程”——录入学生信息、分配房间、确认入住、查看房间已住人数是否从0变1、查看该学生的入住记录是否生成。第三个是“退宿全流程”——退宿后床位状态是否变为空闲、房间已住人数是否减1、该生的入住记录是否保留了退宿时间。第四个是“重复操作防护”——同一个学生能不能重复入住、同一个床位能不能被分配两次、退宿过的床位能不能再次分配。
这里有一个常用技巧:用Postman或浏览器的开发者工具直接构造请求去测试接口。如果你只在页面上点点点,可能会漏掉一些前端已经隐藏掉的操作方式——比如直接用POST请求调用退宿接口,但传的recordId不存在,或者重复调用两次退宿接口,看看后端会不会报错,还是会把床位状态弄乱。这些异常路径在答辩现场被问到的概率很高,别觉得“正常流程没问题就行了”。
6.3 这个方向值不值得做:投入产出比与后续演进
说了这么多,最后聊聊你可能会纠结的问题:花时间做一个JavaWeb学生宿舍管理系统,值得吗?我的看法是,如果你只是一个学期要交课程设计的学生,或者正在准备校招需要补充项目经验,这个方向性价比很高。它的技术栈基础——Servlet/JSP或SSM、MySQL、Tomcat——是所有JavaWeb开发的公共底座,你在这个项目里踩过的坑,在下一个项目里大概率还会遇到,提前踩一遍等于给自己攒经验。
但如果你的目标是简历上写一个“高含金量”的项目,我会提醒你一句:宿舍管理系统属于烂大街选题,面试官看到这个项目名称的瞬间可能会下意识降低期待值。这时候你逆风翻盘的方式只有一个——能在介绍项目时说出数据库事务边界在哪、连接池参数怎么调、权限拦截怎么做、部署时改过哪些默认配置,一切能体现“工程细节”的东西。绣花一样的CRUD代码远不如对生产环境问题的理解有价值,后者才是面试官真正想听的。
如果做完这个系统还想往上走一步,我建议你朝两个方向演进。一个是功能深化:把手工分配床位升级成自动分配算法,把水电费从手动录入改成按房间用量自动计算,把统计报表从“查出来再算”改成定时任务聚合数据。另一个方向是技术演进:把JSP换成Vue前后端分离,把SSM换成Spring Boot加MyBatis-Plus,这样你等于从JavaWeb直接跨进了现在更主流的开发范式。系统本身的教学价值就在这个位子上:一根线串起Servlet到Spring Boot的演进逻辑,不会让重构显得生硬。
我做了这么多年项目,最深的感受是:不要轻视任何一个看起来简单的管理系统。它朴素的外表下藏着的都是真问题——并发控制、事务边界、权限模型、部署排错。能把一个“简单项目”的每一个细节都讲清楚,比只会吹嘘“我们用了微服务架构”但一问细节就卡壳的人强太多。希望这个方案的每一步能帮到你,动手跑通的那一刻,你会发现它远没有传说中那么可怕。
本文还有配套的精品资源,点击获取