JavaWeb公寓管理系统:Servlet+DAO分层设计与答辩要点解析
简介一份基于JavaWebMySQL的大学生公寓管理系统完整课程设计源码包面向计算机专业正在完成期末大作业或课程设计的学生以及需要项目实战练习的初学者。系统覆盖登录认证、宿舍楼管理、学生信息维护、记录管理等典型模块代码分层清晰适合作为JavaWebServletJSPJDBC技术栈的参考项目。资源共242个文件其中含25个Java源文件、25个class字节码文件、24个JSP页面以及78个JS脚本另有SQL数据库脚本和CSS样式表等压缩包整体仅6.7MB便于快速下载部署。项目附带导师指导并通过的98分课程设计成果源码与数据库配套完整可直接导入IDE运行也可在此基础上扩展功能。当前已有1348人学习下载尤其适合需要完整项目参考、理解ServletDao分层开发或完成期末答辩准备的读者。1. 公寓管理系统的ServletDAO结构比SpringBoot版更适合答辩讲解期末大作业选JavaWebMySQL技术栈常被老师追问一句“你写的Servlet凭什么能支撑三个角色同时使用”。大学生公寓管理系统刚好把这个问题摆到台面上宿舍管理员、学生、系统管理员三种身份访问同一套数据却不能互相越权。源码里实际存在的DormBuildServlet、RecordServlet、StudentServlet、LoginServlet与对应DAO组成了一套不用Spring也能讲清楚的分层实现。对正在做课程设计的学生它提供了可直接运行的期末大作业参考对写过几年业务代码的人它恰恰是回顾Servlet原生API、JDBC连接管理与请求分发的最小样本。2. DormBuildServlet与RecordServlet的模块边界请求分发与角色操作入口2.1 DormBuildServlet的action参数约定一个Servlet撑起宿舍楼管理DormBuildServlet管理的资源是宿舍楼。页面端通过表单隐藏域或者URL拼接把action参数传过来Servlet根据action的值决定是新增、修改、删除还是跳转列表页。为什么不用REST风格路径课程设计阶段保持get/post两种方法配合action字符串能让前端JSP的表单写法更直观答辩时也更容易解释。WebServlet(/dormBuild) public class DormBuildServlet extends HttpServlet { private DormBuildDao dormBuildDao new DormBuildDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action request.getParameter(action); if (list.equals(action)) { request.setAttribute(buildList, dormBuildDao.findAll()); request.getRequestDispatcher(/dormBuildList.jsp).forward(request, response); } else if (preAdd.equals(action)) { request.getRequestDispatcher(/dormBuildAdd.jsp).forward(request, response); } else if (preUpdate.equals(action)) { int id Integer.parseInt(request.getParameter(id)); request.setAttribute(build, dormBuildDao.findById(id)); request.getRequestDispatcher(/dormBuildUpdate.jsp).forward(request, response); } } Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String action request.getParameter(action); if (add.equals(action)) { DormBuild build new DormBuild(); build.setName(request.getParameter(name)); build.setIntro(request.getParameter(intro)); dormBuildDao.save(build); } else if (update.equals(action)) { DormBuild build new DormBuild(); build.setId(Integer.parseInt(request.getParameter(id))); build.setName(request.getParameter(name)); build.setIntro(request.getParameter(intro)); dormBuildDao.update(build); } else if (delete.equals(action)) { int id Integer.parseInt(request.getParameter(id)); dormBuildDao.deleteById(id); } response.sendRedirect(dormBuild?actionlist); } }这里的doGet只负责读操作和页面跳转doPost统一处理写操作。关键的参数约定是actionadd、update、delete、list、preAdd、preUpdate分别对应新增、修改、删除、查询列表、跳转新增页、跳转修改页。name和intro是宿舍楼名称与简介字段。需要特别注意的是doPost最后统一重定向到查询列表避免用户刷新页面时重复提交表单这是课程设计里很容易被忽略的PRG模式。提示如果部署后出现中文乱码先检查doPost里是否有request.setCharacterEncoding(UTF-8)而且要放在读取任何参数之前。2.2 RecordServlet的归寝记录与卫生记录合并查询RecordServlet承担的是公寓日常管理里出现频率最高的操作归寝记录和卫生检查记录。它的难点在于查询条件不固定——宿舍管理员可能只查某一个宿舍楼也可能按日期查某一天的未归寝人员学生端则只关心自己的记录。WebServlet(/record) public class RecordServlet extends HttpServlet { private RecordDao recordDao new RecordDao(); Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String studentNumber request.getParameter(studentNumber); String buildId request.getParameter(buildId); String date request.getParameter(date); ListRecord records recordDao.search(studentNumber, buildId, date); request.setAttribute(recordList, records); request.getRequestDispatcher(/recordList.jsp).forward(request, response); } }这个查询接口把三个参数全部交给RecordDao去拼接SQL。studentNumber精确匹配学生学号buildId关联宿舍楼date用于筛选某一天的记录。三个参数都允许为空意味着管理员可以不带任何条件直接查全部记录也可以只带一个条件缩小范围。这种设计比写死三个查询方法更省代码但要求DAO里对空值做判断。2.3 StudentServlet与DormManagerServlet的权限边界StudentServlet负责学生档案的维护DormManagerServlet负责宿舍管理员的维护。两者结构相似但权限范围不同学生档案的修改通常由系统管理员操作宿舍管理员只能查看而DormManagerServlet只在系统管理员模块里暴露。源码里将二者拆成两个Servlet而不是合并主要就是为了配合JSP页面的目录权限控制。Servlet类管理资源可执行操作面向角色DormBuildServlet宿舍楼增删改查系统管理员StudentServlet学生档案增删改查、按宿舍楼过滤系统管理员、宿舍管理员RecordServlet归寝/卫生记录查询、录入宿舍管理员、学生只读DormManagerServlet宿舍管理员账号增删改查系统管理员PasswordServlet当前登录者密码修改三种角色通用LoginServlet登录态登录、注销三种角色共用这张表在答辩时可以直接用作功能清单的口述提纲也解释了为什么模块划分不能照搬SpringBoot的三层架构这里每个Servlet对应一类资源而不是对应一种角色操作角色过滤放在登录后的JSP页面判断里。这种简化在数据量不大的课程设计场景里完全够用。3. MySQL表结构与DAO层SQL从建表语句到多表联查的写法3.1 四张核心表的字段设计与外键关联公寓管理系统的数据模型围绕四条线展开学生、宿舍楼、宿管、记录。大多数学员的表设计会忽略一个点记录表需要同时关联学生和宿舍楼才能支持“查某栋楼的未归寝人数”这类常见问题。CREATE TABLE t_dorm_build ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 宿舍楼名称, intro VARCHAR(200) COMMENT 宿舍楼简介 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_student ( id INT PRIMARY KEY AUTO_INCREMENT, student_number VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, name VARCHAR(50) NOT NULL, sex VARCHAR(10), build_id INT COMMENT 关联宿舍楼, dorm_room VARCHAR(20) COMMENT 房间号, CONSTRAINT fk_student_build FOREIGN KEY (build_id) REFERENCES t_dorm_build(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_dorm_manager ( id INT PRIMARY KEY AUTO_INCREMENT, manager_name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, build_id INT, CONSTRAINT fk_manager_build FOREIGN KEY (build_id) REFERENCES t_dorm_build(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE t_record ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, record_date DATE NOT NULL, status VARCHAR(20) COMMENT normal-正常 late-晚归 absent-未归, type VARCHAR(20) COMMENT attendance-归寝 health-卫生, detail VARCHAR(200), CONSTRAINT fk_record_student FOREIGN KEY (student_id) REFERENCES t_student(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里把宿舍管理员也挂到了t_dorm_build上含义是一个宿管负责一栋楼。t_record通过student_id关联t_student而t_student又通过build_id关联t_dorm_build所以从记录到宿舍楼是两级关联。设计上的取舍是把卫生和归寝放同一张表用type字段区分好处是减少表数量坏处是查询时要多带一个type条件。3.2 StudentDao与DormBuildDao的JDBC模板写法DAO层的写法基本是固定的获取连接、预编译SQL、设置参数、执行查询、封装结果集、关闭资源。这门课程设计里的StudentDao核心方法包括findAll、findById、save、update、deleteById以及一个按宿舍楼过滤学生的findByBuildId。public class StudentDao { public ListStudent findByBuildId(int buildId) { String sql SELECT id, student_number, name, sex, build_id, dorm_room FROM t_student WHERE build_id ?; ListStudent list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, buildId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Student s new Student(); s.setId(rs.getInt(id)); s.setStudentNumber(rs.getString(student_number)); s.setName(rs.getString(name)); s.setSex(rs.getString(sex)); s.setBuildId(rs.getInt(build_id)); s.setDormRoom(rs.getString(dorm_room)); list.add(s); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }这里使用PreparedStatement而不是Statement第一个原因是参数通过setInt传入避免手工拼接整数时出现SQL注入第二个原因是MySQL会对预编译SQL做执行计划缓存。配置MySQL连接串时建议在JDBC URL后加上useSSLfalse和characterEncodingutf8否则高版本MySQL客户端会在连接时报警告。DBUtil.getConnection()是自定义的工具类内部读取db.properties里的driver、url、username、password。3.3 RecordDao的联表查询与条件拼接RecordDao的search方法需要处理2.2里传进来的三个可选参数。SQL层面要注意JOIN的方向以t_record为主表LEFT JOIN t_student和t_dorm_build这样即使某条记录的学生被删除历史记录仍然能查询出来。public ListRecord search(String studentNumber, String buildId, String date) { StringBuilder sql new StringBuilder(); sql.append(SELECT r.id, s.student_number, s.name, s.dorm_room, ) .append(d.name AS build_name, r.record_date, r.status, r.type, r.detail ) .append(FROM t_record r ) .append(LEFT JOIN t_student s ON r.student_id s.id ) .append(LEFT JOIN t_dorm_build d ON s.build_id d.id ) .append(WHERE 1 1 ); ListObject params new ArrayList(); if (studentNumber ! null !studentNumber.trim().isEmpty()) { sql.append(AND s.student_number ? ); params.add(studentNumber.trim()); } if (buildId ! null !buildId.trim().isEmpty()) { sql.append(AND s.build_id ? ); params.add(Integer.parseInt(buildId)); } if (date ! null !date.trim().isEmpty()) { sql.append(AND r.record_date ? ); params.add(date.trim()); } sql.append(ORDER BY r.record_date DESC, r.id DESC); // 使用PreparedStatement执行sql.toString()并依次绑定params }“WHERE 1 1”在这里不是多余写法它的作用是让后续每个AND条件都无需判断是否是第一个条件代码结构清晰很多。date参数直接传字符串依赖MySQL对2025-06-01的隐式转换但更稳妥的做法是用java.sql.Date而非字符串。这个search方法体现了课程设计里最常见的查询封装套路StringBuilder拼接SQL主体List收集参数最后统一绑定。查询场景WHERE条件参数来源查全部记录无条件无查某学号记录s.student_number ?学生输入框查某栋楼记录s.build_id ?楼栋下拉框查某天记录r.record_date ?日期选择器组合查询同时满足多个条件三个控件组合提示导入数据库脚本时如果报错先把SQL文件里的表顺序理清楚先导t_dorm_build再导t_student和t_dorm_manager最后导t_record。外键依赖要求父表必须先存在。4. LoginServlet登录鉴权与PasswordServlet密码修改的实现细节4.1 LoginServlet的会话管理与角色标识写入登录逻辑是所有模块的门禁。LoginServlet根据用户名和密码分别去t_dorm_manager和t_student两张表里查询查不到就说明账号不存在查到了就把用户信息写入session。WebServlet(/login) public class LoginServlet extends HttpServlet { private DormManagerDao dormManagerDao new DormManagerDao(); private StudentDao studentDao new StudentDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String username request.getParameter(username); String password request.getParameter(password); DormManager manager dormManagerDao.findByUsernameAndPassword(username, password); if (manager ! null) { request.getSession().setAttribute(userId, manager.getId()); request.getSession().setAttribute(userName, manager.getManagerName()); request.getSession().setAttribute(userType, manager); request.getSession().setAttribute(buildId, manager.getBuildId()); response.sendRedirect(dormBuild?actionlist); return; } Student student studentDao.findByNumberAndPassword(username, password); if (student ! null) { request.getSession().setAttribute(userId, student.getId()); request.getSession().setAttribute(userName, student.getName()); request.getSession().setAttribute(userType, student); request.getSession().setAttribute(buildId, student.getBuildId()); response.sendRedirect(student?actiondetailid student.getId()); return; } request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } }这里先查询宿管表再查询学生表两个表都命中不了就返回登录页并显示错误信息。userId、userName、userType、buildId这四个session属性中userType是后续所有页面判断权限的依据buildId则保存了当前用户所属的宿舍楼避免学生跨楼查看数据。注意宿管登录后跳转到宿舍楼列表页学生登录后跳转到个人详情页这是两种角色在系统里的默认首页差异。4.2 页面端角色判断与JSP标签的使用登录后不能让每个Servlet都重复判断角色。这个项目在JSP页面端通过session里的userType配合c:if标签做菜单级的权限控制是一种零成本的轻量方案。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % div classmenu c:if test${sessionScope.userType manager} a hrefdormBuild?actionlist宿舍楼管理/a a hrefdormManager?actionlist宿管账号管理/a /c:if c:if test${sessionScope.userType manager || sessionScope.userType student} a hrefrecord?actionlist查寝记录/a /c:if a hrefpassword?actionpreUpdate修改密码/a a hreflogin?actionlogout退出登录/a /divsessionScope.userType是上一节LoginServlet里写入的会话属性。manager可以看到宿舍楼管理和宿管账号管理菜单student看不到查寝记录两个角色都能看但RecordServlet里还要根据userType决定是否过滤当前学生的记录。这种页面端控制在课程设计场景下比写拦截器更直观缺点是如果一个JSP被直接访问无法防止绕过菜单操作所以关键写操作仍要靠Servlet端再次判断。4.3 PasswordServlet的密码修改流程密码修改是这个项目里容易被忽略的模块但答辩时老师几乎必问。它的流程是登录用户输入旧密码和新密码Servlet先根据session里的userId去对应角色表里核对旧密码核对通过后再执行UPDATE。WebServlet(/password) public class PasswordServlet extends HttpServlet { private DormManagerDao dormManagerDao new DormManagerDao(); private StudentDao studentDao new StudentDao(); Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); HttpSession session request.getSession(); String userType (String) session.getAttribute(userType); int userId (Integer) session.getAttribute(userId); String oldPwd request.getParameter(oldPassword); String newPwd request.getParameter(newPassword); String confirmPwd request.getParameter(confirmPassword); if (!newPwd.equals(confirmPwd)) { request.setAttribute(errorMsg, 两次输入的新密码不一致); request.getRequestDispatcher(/passwordUpdate.jsp).forward(request, response); return; } boolean ok false; if (manager.equals(userType)) { ok dormManagerDao.updatePassword(userId, oldPwd, newPwd); } else if (student.equals(userType)) { ok studentDao.updatePassword(userId, oldPwd, newPwd); } if (ok) { session.invalidate(); response.sendRedirect(login.jsp); } else { request.setAttribute(errorMsg, 旧密码错误或更新失败); request.getRequestDispatcher(/passwordUpdate.jsp).forward(request, response); } } }统一从session里取userType来决定调用哪个DAO的updatePassword避免了再写一个专门的密码表。在updatePassword内部SQL是UPDATE t_student SET password ? WHERE id ? AND password ?一次UPDATE同时完成旧密码校验和新密码修改。改密成功后直接session.invalidate()销毁会话强制用户重新登录这是一种简单但有效的安全习惯。角色修改密码后会话处理系统管理员重新登录invalidate宿舍管理员重新登录invalidate学生重新登录invalidate5. 答辩演示时最容易翻车的三个细节5.1 分页查询用LIMIT限制记录条数RecordServlet的列表页在数据量小的时候没问题但答辩演示时老师可能会往数据库里插入几十条记录然后问“为什么页面没有分页”。给查询加上LIMIT是关键的第一步也是最容易在DAO层实现的优化。常见做法是给search方法增加offset和limit两个参数SQL末尾拼接LIMIT ?, ?第一个参数是起始下标第二个参数是每页条数。这里要注意PreparedStatement对LIMIT参数的绑定方式MySQL支持setInt直接设置这两个占位符不需要手工拼进SQL字符串。sql.append(ORDER BY r.record_date DESC, r.id DESC LIMIT ?, ?); // 从0开始计算偏移量(pageNum - 1) * pageSize在JSP端配合一个简单的页码条把总记录数先COUNT出来再除以pageSize得到总页数就能解决课程设计里的分页问题。这个实现不依赖PageHelper因为原生JDBC场景下引入分页插件反而会增加配置复杂度答辩时也不好解释。5.2 删除宿舍楼之前先检查外键引用t_student表的build_id和t_dorm_manager表的build_id都关联了t_dorm_build直接执行DELETE FROM t_dorm_build WHERE id 1会触发外键约束错误。答辩现场如果演示到这个操作报错信息会直接暴露在外键处理上的缺失。常见做法是删除前先查两张关联表是否有引用数据。SELECT COUNT(*) FROM t_student WHERE build_id 1; SELECT COUNT(*) FROM t_dorm_manager WHERE build_id 1;两条查询都返回0时才允许删除否则在Servlet里返回提示“该宿舍楼仍有学生或宿管关联无法删除”。也可以使用ON DELETE SET NULL让外键字段自动置空但这样做会让学生失去所属宿舍楼信息不建议在公寓管理场景使用。5.3 用SQL直接验证数据变化答辩演示代码操作结束后老师往往会直接打开MySQL命令行查看数据。建议在演示前准备好几条验证SQL查某个宿舍楼的学生数量、查某天的归寝记录、查某学生最近一条记录。这些查询能证明系统写入的数据真正落到了数据库而不是只存在内存里。SELECT d.name AS 宿舍楼, COUNT(s.id) AS 学生数 FROM t_dorm_build d LEFT JOIN t_student s ON d.id s.build_id GROUP BY d.id; SELECT s.student_number, s.name, r.record_date, r.status FROM t_record r JOIN t_student s ON r.student_id s.id WHERE r.record_date 2025-06-01;第一条SQL用LEFT JOIN保证没有学生的宿舍楼也会出现在结果里第二条SQL展示指定日期的归寝状态。这两条查询分别对应“宿舍楼管理”和“记录查询”两个模块的数据库验证答到点上比背概念更有说服力。本文还有配套的精品资源点击获取