简介:一套基于RIFD技术(射频识别)的自习室座位管理系统设计与实现资源,适用于计算机相关专业毕业设计、课程设计及Java Web开发学习者。项目包含完整论文(Word文档)与可运行源码,围绕座位预约、取消预约、学生信息管理、座位管理及黑名单管理等核心功能展开,论文详细覆盖选题背景、国内外现状、相关技术分析(集成开发环境、数据库连接技术、前端框架)、可行性分析、需求分析、总体设计、数据库表设计以及系统实现与测试等章节。资源包共2000个文件,主要包含Java后端源码及相关依赖、前端页面与样式脚本、数据库SQL脚本、项目配置文件等,其中前端采用AdminLTE框架,压缩包大小约154.38MB。目前已有653人学习下载,适合作为毕业设计的完整方案参考、论文结构对照与功能二次开发。
1. 座位管理系统的痛点:从预约到取消,为什么需要一个独立状态流转
很多自习室仍然靠手工登记或微信群接龙管理座位,高峰期一座难求,有人预约了又不到场,管理员也拿不到真实数据核对。这套基于RFID身份识别的自习室座位管理系统,核心并不在页面好不好看,而在“状态流转”:预约时座位从空闲变为已预约,刷卡入座后变使用中,离开后释放回空闲;超时未到则进入违约逻辑,黑名单自动介入。项目采用IDEA + JDBC + JQuery + AdminLTE实现,后端是Servlet/JSP,前端直接套用AdminLTE模板,数据库用学生信息、座位信息、预约信息、系统日志四张表,把登录、座位预约、取消预约、黑名单管理、日志查询串成完整闭环。源码里的“RIFD”其实对应行业通用的RFID拼写,阅读代码时注意这个差异即可,不影响理解。
2. 技术选型与整体架构:IDEA + JDBC + JQuery + AdminLTE 能装下什么
2.1 为什么不选 MyBatis?轻量 JDBC 在课程设计里的边界
IDEA只是开发环境,真正决定工程量上限的是数据库访问层。这个项目用的是原生JDBC,没有引入MyBatis或Hibernate。理由很实际:课程设计或毕业设计要体现的是数据库操作和事务控制,JDBC把连接、预编译、结果集、异常处理全部暴露出来,写起来虽然啰嗦,但答辩时能讲清楚“这个更新为什么影响了三张表”。换成MyBatis后SQL是少敲了,但自动提交和一级缓存会让事务边界模糊,不熟悉ORM的人调试起来反而更吃力。
在读源码时会看到一个统一的JdbcUtils工具类,用来管理连接和释放资源。常见做法是用ThreadLocal存Connection,保证同一线程内共享同一个连接。下面是一个精简版:
public class JdbcUtils { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/study_room?useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PASS = "你的密码"; private static ThreadLocal<Connection> tl = new ThreadLocal<>(); static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { Connection conn = tl.get(); if (conn == null || conn.isClosed()) { conn = DriverManager.getConnection(URL, USER, PASS); tl.set(conn); } return conn; } public static void release(AutoCloseable... resources) throws Exception { for (AutoCloseable r : resources) { if (r != null) r.close(); } tl.remove(); } }ThreadLocal是关键:一次HTTP请求里可能连续查询学生信息、座位状态和预约记录,如果每次都new Connection,连接数会翻倍,事务也难控制。release方法用可变参数,无论传入PreparedStatement还是ResultSet都能统一关闭。注意URL里的serverTimezone参数,MySQL 8必须指定时区,否则取值时间字段会报错;useSSL=false是为了本地开发省去证书验证。
从选型边界看,JDBC适合表数量不多、并发量不大的系统。如果想更薄一点,可以考虑Spring JdbcTemplate,比MyBatis少一层XML配置。但在这个项目里保持手写JDBC反而更好复现:导入mysql-connector-java依赖,改成本地数据库信息,启动Tomcat就能跑。
| 对比项 | 手写JDBC | MyBatis | Spring JdbcTemplate |
|---|---|---|---|
| 学习曲线 | 低,但需注意连接释放 | 中,需理解SqlSession和Mapper | 低,API接近JDBC |
| 事务控制 | 手动,灵活度高 | 依赖SqlSession或Spring | 配合@Transactional |
| SQL可见性 | 完全可见 | 在XML中维护 | 完全可见 |
| 项目体积 | 最小,适合答辩演示 | 需要配置文件 | 需要Spring容器 |
| 出问题时定位 | 断点直接看驱动源码 | 先找代理对象 | 相对直接 |
项目代码里基本能搜索到Class.forName和DriverManager,这是手写JDBC的指纹。想升级成Spring Boot时,不要一次性替换所有DAO,建议先换一个查询方法测试连接池是否正常,再逐模块迁移。
2.2 AdminLTE 模板的引入方式与前端资源组织
项目正文里出现的adminlte.css、adminlte.core.css和adminlte.plugins.css,是AdminLTE 2.x版本的典型文件结构。它们的定位有区别:adminlte.css负责主体风格,包括布局、侧边栏、导航条;adminlte.core.css是基础样式,负责栅格、按钮、表格,需要在前者之前加载;adminlte.plugins.css汇总了日期选择器、数据表格、iCheck等插件样式。
JSP页面的引入顺序如下:
<!-- 在<head>中按顺序引入 --> <link rel="stylesheet" href="${pageContext.request.contextPath}/dist/css/adminlte.core.css"> <link rel="stylesheet" href="${pageContext.request.contextPath}/dist/css/adminlte.css"> <link rel="stylesheet" href="${pageContext.request.contextPath}/dist/css/adminlte.plugins.css"> <!-- 其次是JQuery和AdminLTE核心JS --> <script src="${pageContext.request.contextPath}/plugins/jquery/jquery.min.js"></script> <script src="${pageContext.request.contextPath}/dist/js/adminlte.min.js"></script>顺序很关键:core.css在前,adminlte.css在后,主题样式才能覆盖基础样式;如果顺序颠倒,侧边栏宽度和配色会被重置。plugins.css可以放最后,因为第三方控件不影响整体布局。使用${pageContext.request.contextPath}是JSP标准写法,避免项目部署在非ROOT路径时资源404。
调整页面时,我一般保留AdminLTE默认皮肤变量,只在业务区域修改内容和按钮颜色。不要直接改adminlte.min.css,压缩版本极难维护。正确做法是新建custom.css放最后,覆盖需要改的属性,比如把主色换成自习室品牌色。
2.3 数据库设计:四张表的关联关系
系统业务核心是预约状态,数据表必须支持“多个人抢一个座位”和“一个人只能有一个有效预约”的约束。四张表分别是学生信息表、座位信息表、学生预约信息表、系统日志表。建表SQL如下:
CREATE DATABASE IF NOT EXISTS study_room DEFAULT CHARSET utf8mb4; USE study_room; CREATE TABLE student ( student_id VARCHAR(20) PRIMARY KEY COMMENT '学号', student_name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT '登录密码,建议MD5摘要', role ENUM('student','admin') DEFAULT 'student', status TINYINT DEFAULT 1 COMMENT '1正常,0黑名单', black_count INT DEFAULT 0 COMMENT '连续违约次数' ) ENGINE=InnoDB; CREATE TABLE seat ( seat_id INT PRIMARY KEY AUTO_INCREMENT, seat_no VARCHAR(20) NOT NULL UNIQUE COMMENT '物理座位编号,如A-12', area_name VARCHAR(50) COMMENT '区域,如静音区', status TINYINT DEFAULT 0 COMMENT '0空闲,1已预约,2使用中' ) ENGINE=InnoDB; CREATE TABLE reservation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id VARCHAR(20) NOT NULL, seat_id INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status TINYINT DEFAULT 0 COMMENT '0预约中,1已入座,2已取消,3超时违约', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_stu_time (student_id, start_time), INDEX idx_seat_time (seat_id, start_time) ) ENGINE=InnoDB; CREATE TABLE sys_log ( log_id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id VARCHAR(20), action VARCHAR(50) COMMENT 'login, reserve, cancel, punish', detail VARCHAR(200), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB;表结构中有几个细节值得注意。student和reservation是一对多,reservation和seat是多对一,但一个学号在相同时段只能有一条status为0或1的记录,这可以通过查询校验实现。seat表保留area_name字段,方便以后扩展楼层区域。status使用TINYINT而不是VARCHAR,减少存储开销,也方便Java中用int比较。
看表字段就能理解预约流程:学生提交预约,先查座位状态,再INSERT reservation,再UPDATE seat.status,三者顺序不能颠倒。如果漏掉sys_log,黑名单统计就缺少依据,第4章会专门讲如何通过日志追溯预约行为。
3. 核心功能模块实现:登录、预约、取消预约的黑名单联动
3.1 登录与角色判断:学生信息表里的用户态
登录模块表面上是查询student表,实际上还要判断status是否黑名单。参考实现:
@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String studentId = req.getParameter("studentId"); String password = MD5Util.md5(req.getParameter("password")); String sql = "SELECT student_id, student_name, role, status FROM student WHERE student_id=? AND password=?"; try (Connection conn = JdbcUtils.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, studentId); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { if (rs.getInt("status") == 0) { req.setAttribute("error", "该学号已被列入黑名单,请联系管理员"); req.getRequestDispatcher("/login.jsp").forward(req, resp); return; } HttpSession session = req.getSession(); session.setAttribute("userId", rs.getString("student_id")); session.setAttribute("userName", rs.getString("student_name")); session.setAttribute("role", rs.getString("role")); resp.sendRedirect(req.getContextPath() + "/index.jsp"); } else { req.setAttribute("error", "学号或密码错误"); req.getRequestDispatcher("/login.jsp").forward(req, resp); } } } catch (Exception e) { e.printStackTrace(); resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); } } }密码用MD5Util包装是一种常见课程设计做法,现代系统建议BCrypt加盐,但要注意BCrypt输出长度约60字符,需要修改password字段长度。登录成功后在session里只保存userId、userName和role,不保存完整密码,后续操作都从session拿当前登录人。
这里容易踩的坑是JSP权限控制只靠隐藏菜单。需要在每个受保护页面检查session.getAttribute("userId")是否为null,否则用户直接访问/index.jsp也能绕过登录。常见做法是写一个LoginFilter拦截所有*.jsp和*.do请求,放行login相关路径。
3.2 预约页面:座位状态实时查询与RFID身份绑定
预约页面数据来自seat表,页面初始化时用JQuery发起Ajax请求获取座位状态,避免整页刷新:
$.getJSON(contextPath + "/seat/list", function(data) { var html = ""; $.each(data, function(i, seat) { var statusText = seat.status == 0 ? "空闲" : (seat.status == 1 ? "已预约" : "使用中"); var btnClass = seat.status == 0 ? "btn-success" : "btn-disabled"; html += '<div class="col-md-3">' + '<button class="btn ' + btnClass + ' seat-btn">String sqlCheck = "SELECT COUNT(*) FROM reservation WHERE student_id=? AND status IN (0,1)"; String sqlSeat = "SELECT status FROM seat WHERE seat_id=? FOR UPDATE"; String sqlReserve = "INSERT INTO reservation(student_id, seat_id, start_time, end_time) VALUES(?,?,?,?)"; String sqlUpdateSeat = "UPDATE seat SET status=1 WHERE seat_id=?";FOR UPDATE在InnoDB下会锁住这一行,防止第二个请求继续插入。很多源码没有这一步,是典型的并发遗漏。RFID身份绑定在这个模块中体现为读卡器返回学号卡号,与登录学生的studentId做一致性校验。座位本身不一定有传感器,RFID只做身份识别,真正的工作量在预约状态机上。
3.3 取消预约:为什么要在事务里同时改座位表和日志表
预约状态流转中,各状态可执行操作如下:
| 状态值 | 含义 | 可执行操作 |
|---|---|---|
| 0 | 预约中 | 取消、刷卡入座 |
| 1 | 已入座 | 离座 |
| 2 | 已取消 | 无 |
| 3 | 超时违约 | 管理员释放 |
取消预约不能只删预约记录,否则seat.status仍是1,座位会永久占用。下面是service层的取消方法,用事务包裹:
public boolean cancelReservation(long rid, String studentId) throws Exception { Connection conn = JdbcUtils.getConnection(); boolean success = false; try { conn.setAutoCommit(false); // 1. 校验预约是否属于当前学生且未入座 String verifySql = "SELECT seat_id FROM reservation WHERE id=? AND student_id=? AND status=0"; PreparedStatement ps = conn.prepareStatement(verifySql); ps.setLong(1, rid); ps.setString(2, studentId); ResultSet rs = ps.executeQuery(); if (!rs.next()) { conn.rollback(); return false; } int seatId = rs.getInt("seat_id"); rs.close(); ps.close(); // 2. 更新预约状态为已取消 String updateRes = "UPDATE reservation SET status=2 WHERE id=?"; PreparedStatement ps2 = conn.prepareStatement(updateRes); ps2.setLong(1, rid); ps2.executeUpdate(); ps2.close(); // 3. 座位状态恢复为空闲 String updateSeat = "UPDATE seat SET status=0 WHERE seat_id=?"; PreparedStatement ps3 = conn.prepareStatement(updateSeat); ps3.setInt(1, seatId); ps3.executeUpdate(); ps3.close(); // 4. 写日志 String logSql = "INSERT INTO sys_log(student_id, action, detail) VALUES(?, 'cancel', ?)"; PreparedStatement ps4 = conn.prepareStatement(logSql); ps4.setString(1, studentId); ps4.setString(2, "预约单:" + rid); ps4.executeUpdate(); ps4.close(); conn.commit(); success = true; } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); JdbcUtils.release(conn); } return success; }方法顺序很重要:先校验,再改预约,再改座位,最后写日志。如果先改座位状态再校验,会有一个窗口期让错误请求读到假数据;如果写日志失败,所有改动都要回滚,否则黑名单统计缺账。setAutoCommit(false)关闭自动提交,commit成功才落库,任何一步异常都rollback,finally中统一释放连接。
实际运行中还会有“管理员批量释放长时间无人座位”的变体,常见做法是预约状体为1且超过开始时间30分钟后,由定时任务自动取消。用Timer或Quartz实现均可,但要注意扫描间隔不要短于10秒,否则连接池容易被打满。
3.4 黑名单管理:连续违约的判定逻辑
黑名单不是手动标记,而是靠统计违约次数。这里的违约定义为“预约后未入座”或“取消次数过多”。最简单的方式是用SQL统计近7天取消和违约的预约:
SELECT student_id, COUNT(*) AS total FROM reservation WHERE status IN (2, 3) AND start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY student_id HAVING total > 3;这条SQL将最近7天内取消(status=2)或超时未到(status=3)的预约按学号分组,超过3次进入黑名单候选。更严谨的“连续违约”要查最后一次正常入座时间,但课程设计里为了可解释,一般直接用区间累计次数。
Java定时任务每天扫描一次并更新学生状态:
String blackSql = "UPDATE student SET status=0, black_count=? " + "WHERE student_id IN (SELECT student_id FROM reservation WHERE status IN (2,3) " + "AND start_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) GROUP BY student_id HAVING COUNT(*) > ?)"; PreparedStatement ps = conn.prepareStatement(blackSql); ps.setInt(1, 3); ps.setInt(2, 3); int count = ps.executeUpdate();这里的两个3含义不同:第一个3是设置black_count字段值,记录当前已累计的次数;第二个3是HAVING比较的阈值。如果改成“5次进入黑名单”,只需要改第二个参数。第一个参数不能跟着改,否则历史流水被覆盖。管理员手动恢复学生时,需要同时把student.status改回1、black_count重置为0,并写入sys_log,方便追溯。
4. 系统日志与测试:可追溯性和异常场景验证
4.1 系统日志表设计:谁在什么时间做了什么
日志表是统计和排错的基础。sys_log的action字段使用英文标识,detail放中文可读信息。插入日志的代码在取消预约时已经出现,后台日志列表的SQL如下:
SELECT l.log_id, l.student_id, s.student_name, l.action, l.detail, l.create_time FROM sys_log l LEFT JOIN student s ON l.student_id = s.student_id ORDER BY l.create_time DESC LIMIT 0, 20;LEFT JOIN的目的是显示学生姓名,如果不join,管理员排查“哪个学生频繁取消”时必须再查student表。LIMIT固定为0,20避免日志页卡顿,实际项目会配合分页控件。create_time使用DATETIME DEFAULT CURRENT_TIMESTAMP,应用层不传时间,避免应用服务器时间不准时出现排序错乱。
4.2 功能测试用例设计:登录、预约、取消预约
测试这个系统不需要专门工具,把关键业务路径走一遍即可。以下是一份适合直接写进报告的测试用例:
| 用例编号 | 测试内容 | 操作步骤 | 预期结果 |
|---|---|---|---|
| TC01 | 登录成功 | 输入正确学号和密码 | 跳转首页,session中有userId |
| TC02 | 登录失败 | 输入错误密码 | 停留登录页并显示错误信息 |
| TC03 | 黑名单拦截 | 用status=0账号登录 | 显示黑名单提示,不入session |
| TC04 | 座位预约成功 | 点击空闲座位并提交 | 预约表新增记录,座位状态变1 |
| TC05 | 座位冲突 | 两个会话同时点同一座位 | 只有一个成功,另一个提示占用 |
| TC06 | 取消预约 | 在个人中心取消未入座预约 | 预约状态变2,座位状态变0 |
| TC07 | 连续违约入黑名单 | 完成3次违约后执行定时任务 | 学生status被置0 |
TC05是并发测试里最重要的,前端按钮disabled是弱约束,后端FOR UPDATE才是兜底。测试时用两个不同浏览器同时点,观察返回结果。TC07需要把定时扫描间隔临时调成10秒,否则要等很久才能验证。
4.3 常见故障排查:约满冲突、会话失效、中文乱码
约满冲突如果不想用FOR UPDATE,可以用“先UPDATE seat WHERE status=0,再判断affected rows是否为1”:
String sql = "UPDATE seat SET status=1 WHERE seat_id=? AND status=0"; int rows = ps.executeUpdate(); if (rows == 0) { // 说明已经有人抢先预约 }这种方式利用UPDATE的行锁,affected rows为0就代表座位状态被改过,比SELECT FOR UPDATE更直观。
会话失效通常表现为操作到一半被踢回登录页,多半是Tomcat会话超时时间太短或项目重启导致旧session过期。可在web.xml中临时调长:
<session-config> <session-timeout>120</session-timeout> </session-config>中文乱码有两处来源,一处是数据库URL缺少characterEncoding=utf8,另一处是Servlet响应没设UTF-8。一个过滤器可以解决:
@WebFilter("/*") public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }顺序是:先设req编码,再设resp编码,最后chain.doFilter。如果顺序反了,请求参数可能已经按ISO-8859-1解析,之后设置无效。EncodingFilter要在LoginFilter之前配置,否则会话校验先执行时中文参数已经被污染。
5. 进阶:把 RFID 读卡器的返回流接到座位状态上
5.1 串口读取与轮询的常见做法
实际项目中读卡器通过串口或USB转串口连接电脑。Java读取串口常用jSerialComm库,课程设计里可以写一个Swing窗口实时显示读到的卡号。示例代码:
SerialPort port = SerialPort.getCommPort("COM3"); port.setBaudRate(9600); port.setNumDataBits(8); port.setParity(SerialPort.NO_PARITY); port.setNumStopBits(1); port.openPort(); port.addDataListener(new SerialPortDataListener() { @Override public int getListeningEvents() { return SerialPort.LISTENING_EVENT_DATA_AVAILABLE; } @Override public void serialEvent(SerialPortEvent event) { if (event.getEventType() != SerialPort.LISTENING_EVENT_DATA_AVAILABLE) return; byte[] buf = new byte[port.bytesAvailable()]; port.readBytes(buf, buf.length); String cardNo = new String(buf).trim(); // 这里把cardNo和预约信息绑定 System.out.println("RFID卡号: " + cardNo); } });波特率9600是很多RFID读卡器的默认值,如果读出来是乱码,先检查波特率是不是115200。bytesAvailable判断当前缓冲区长度,避免readBytes阻塞。拿到卡号后,常见做法是查student表里card_no字段,再自动填充登录表单或直接触发入座。
5.2 状态机优化:从读卡到释放的完整生命周期
把座位状态从“预约/空闲”两个值扩展成四值状态,能更精确描述物理世界:
| 当前状态 | 触发事件 | 目标状态 | 约束条件 |
|---|---|---|---|
| 空闲(0) | 学生提交预约 | 已预约(1) | 未到开始时间也可预约 |
| 已预约(1) | 学生刷卡入座 | 使用中(2) | 预约记录存在且未过期 |
| 使用中(2) | 学生离座刷卡 | 空闲(0) | 使用时长不能小于1分钟 |
| 已预约(1) | 超时未到 | 违约(3) | 当前时间 > start_time + 30分钟 |
| 违约(3) | 管理员释放 | 空闲(0) | 写日志并扣除信用分 |
这个状态表可以直接对应reservation.status字段。注意入座和离座是两个独立接口,分别接收读卡器传来的卡号。离座时不仅要把预约状态改成已完成,还要把座位状态改为空闲。如果把seat.status和reservation.status混成一个,很容易出现“座位使用中但预约记录已取消”的脏数据。
5.3 验证方法:用模拟器推数据检查并发
没有实体读卡器时,可以用Java线程池模拟多张卡同时触发预约。下面是一段模拟并发测试的示例:
ExecutorService pool = Executors.newFixedThreadPool(10); CountDownLatch latch = new CountDownLatch(10); for (int i = 0; i < 10; i++) { final int studentId = 1001 + i; pool.execute(() -> { CloseableHttpClient client = HttpClients.createDefault(); HttpPost post = new HttpPost("http://localhost:8080/study_room/reserve"); post.setEntity(new UrlEncodedFormEntity( List.of(new BasicNameValuePair("studentId", String.valueOf(studentId)), new BasicNameValuePair("seatId", "1")), "UTF-8")); try { CloseableHttpResponse resp = client.execute(post); System.out.println(studentId + " -> " + resp.getStatusLine().getStatusCode()); } catch (IOException e) { e.printStackTrace(); } latch.countDown(); }); } latch.await();测试重点看返回码和数据库结果:理想情况是10个请求里只有1个返回200成功,其余提示“座位已被预约”。如果多个成功,说明后端缺少状态校验。排查时直接按sys_log.create_time倒序看日志,能清楚看到是哪一秒发生了抢占。日志里同时记录卡号和学号,比人眼盯着屏幕数按钮靠谱得多。
本文还有配套的精品资源,点击获取