ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Node.js与Express的宿舍分配系统:JWT鉴权与床位锁定实践

基于Node.js与Express的宿舍分配系统:JWT鉴权与床位锁定实践

简介:Hostel-Allocation 是一套面向高校宿舍场景的全栈 Web 预订系统源码,适合具备 Node.js 与前端基础、希望练习完整项目开发的在校学生或初级开发者。它解决学生线下排队订房、管理员人工分配效率低的问题:学生可远程查看并预订房间,管理员能实时掌握入住情况,减少人员聚集与接触。资源包共 22 个文件,约 34KB,以 11 个 js 脚本、4 个 html 页面为主,辅以 json 配置、css 样式及证书密钥文件,覆盖登录注册、房间管理、预订页面与后端路由等模块。技术栈融合 Express.js 与 Sequelize 操作 MySQL,前端采用 jQuery、HTML5、CSS3 与 Bootstrap4 构建响应式界面,并通过 JWT 实现身份认证,前后端逻辑划分清晰。已有 114 人学习,读者可借此理解路由设计、ORM 建模、鉴权流程与页面交互的完整实现,适合作为课程设计或全栈练手参考。

1. 从一张排队长龙照片说起:宿舍分配系统到底该解决什么问题

每年开学季,宿舍楼下总会出现一条蜿蜒的队伍。学生攥着纸质申请表,宿管阿姨翻着厚厚的登记本,一个名字一个名字地找空床位。这个场景在不少高校依然存在,而它带来的问题远不止“排队累”——人群聚集、信息不透明、分配结果靠手写记录容易出错,后续查寝、调宿、退宿全是一笔糊涂账。

这套 Hostel-Allocation 方案要解决的,就是把“人找人、纸对纸”的流程搬到线上。学生远程登录就能看到哪些楼栋、哪些楼层还有空位,提交预订后系统自动锁定床位;管理员在后台能一眼看清谁住在哪个房间、哪个床位,调宿退宿都有记录可查。技术栈选的是 Node.js + Express + MySQL + Sequelize + JWT,前端用 jQuery 配合 Bootstrap4 和 HTML5/CSS3,没有上 React 或 Vue,原因后面会讲。适合正在做 Web 开发方向毕设或课程设计的同学,也适合需要快速交付一套内部管理系统的开发者参考。

2. 需求拆解与技术选型:为什么用 Express + Sequelize 而不是全家桶

2.1 三类角色的核心诉求

先把角色分清楚,后面所有设计都围绕这三类人展开。

学生端最关心的是“我能不能订到”“我订的是哪个床位”“能不能改”。具体功能包括:注册登录、查看宿舍楼与房间列表、查看床位占用状态、提交预订、查看自己的预订记录、申请调宿或退宿。

管理员端最关心的是“谁住在哪”“还有多少空位”“怎么快速调整”。功能包括:登录后台、查看所有楼栋房间的入住情况、按楼栋/楼层/房间号筛选、手动分配床位、审批调宿申请、导出住宿名单。

系统本身要保证的是:床位不能被重复预订、用户只能操作自己的订单、管理员操作要有日志可追溯。这三条是后面数据库设计和接口鉴权的出发点。

2.2 技术选型的取舍逻辑

很多同学做毕设喜欢直接上 Vue + Element UI + Spring Boot,前后端分离看起来很“正规”,但实际开发周期会拉长至少一倍。这套方案选 jQuery + Bootstrap4 的原因很直接:页面交互复杂度不高,主要是表格渲染、表单提交、状态切换,jQuery 的 DOM 操作和 AJAX 足够用,Bootstrap4 的栅格和组件能快速搭出可用的管理界面。省下来的时间可以花在业务逻辑和边界条件上,而不是折腾构建工具和状态管理。

后端选 Express 而不是 Koa 或 NestJS,是因为 Express 的中间件生态最成熟,JWT 鉴权、请求校验、错误处理都有现成的中间件可以直接接入。Sequelize 作为 ORM 负责 MySQL 操作,好处是模型定义清晰、关联查询方便、迁移脚本可版本化。对于宿舍分配这种关系型数据密集的场景,用 ORM 比手写 SQL 更不容易在关联查询上翻车。

提示:如果导师明确要求前后端分离或使用特定框架,优先满足导师要求。这套方案的价值在于业务逻辑和数据库设计,技术栈可以替换。

2.3 数据库表结构设计

核心表一共五张,关系不复杂但约束要写清楚。

-- 用户表:区分学生和管理员 CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, role ENUM('student', 'admin') DEFAULT 'student', real_name VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 楼栋表 CREATE TABLE buildings ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, gender ENUM('male', 'female') NOT NULL, floors INT NOT NULL ); -- 房间表 CREATE TABLE rooms ( id INT PRIMARY KEY AUTO_INCREMENT, building_id INT NOT NULL, room_number VARCHAR(10) NOT NULL, floor INT NOT NULL, capacity INT DEFAULT 4, FOREIGN KEY (building_id) REFERENCES buildings(id) ); -- 床位表:这是分配的最小单位 CREATE TABLE beds ( id INT PRIMARY KEY AUTO_INCREMENT, room_id INT NOT NULL, bed_number VARCHAR(5) NOT NULL, status ENUM('available', 'occupied', 'maintenance') DEFAULT 'available', FOREIGN KEY (room_id) REFERENCES rooms(id) ); -- 预订记录表 CREATE TABLE allocations ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, bed_id INT NOT NULL, status ENUM('active', 'checked_out', 'transferred') DEFAULT 'active', allocated_at DATETIME DEFAULT CURRENT_TIMESTAMP, released_at DATETIME, FOREIGN KEY (user_id) REFERENCES users(id), FOREIGN KEY (bed_id) REFERENCES beds(id) );

床位表单独拆出来而不是在房间表里用数字表示剩余床位,是为了让“一个床位一条记录”成为硬约束。预订时直接对 beds 表的 status 字段做更新,配合数据库事务就能防止并发重复预订。allocations 表保留历史记录,退宿或调宿时不删数据,只改状态和释放时间,这样后续查历史有据可依。

3. 后端接口与鉴权实现:JWT 中间件和床位锁定逻辑

3.1 JWT 鉴权中间件的写法

登录成功后签发 token,后续请求在 Header 里带上Authorization: Bearer <token>。中间件负责校验 token 并把用户信息挂到req.user上。

// middleware/auth.js const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET || 'hostel-secret-key'; // 校验 token,解析出用户信息 function authenticate(req, res, next) { const header = req.headers.authorization; if (!header || !header.startsWith('Bearer ')) { return res.status(401).json({ message: '未登录或 token 缺失' }); } const token = header.slice(7); try { const payload = jwt.verify(token, SECRET); req.user = { id: payload.id, role: payload.role }; next(); } catch (err) { return res.status(401).json({ message: 'token 无效或已过期' }); } } // 角色校验,管理员接口专用 function requireAdmin(req, res, next) { if (req.user.role !== 'admin') { return res.status(403).json({ message: '无权限执行此操作' }); } next(); } module.exports = { authenticate, requireAdmin };

authenticate只负责“你是谁”,requireAdmin负责“你能不能干这个”。两个中间件分开写的好处是学生接口只挂authenticate,管理员接口挂authenticate + requireAdmin,职责清晰。token 过期时间建议设 2 小时,太长不安全,太短学生填个表就掉线。

3.2 床位预订的事务处理

这是整个系统最容易出 bug 的地方。两个学生同时点同一个床位,如果不加锁就会超卖。

// routes/allocation.js const { sequelize, Bed, Allocation } = require('../models'); router.post('/allocate', authenticate, async (req, res) => { const { bedId } = req.body; const t = await sequelize.transaction(); try { // 锁定该床位行,防止并发读取到相同状态 const bed = await Bed.findByPk(bedId, { lock: t.LOCK.UPDATE, transaction: t }); if (!bed || bed.status !== 'available') { await t.rollback(); return res.status(400).json({ message: '该床位已被占用' }); } // 检查该学生是否已有 active 预订 const existing = await Allocation.findOne({ where: { user_id: req.user.id, status: 'active' }, transaction: t }); if (existing) { await t.rollback(); return res.status(400).json({ message: '你已有预订,请先退宿或调宿' }); } // 更新床位状态并写入分配记录 await bed.update({ status: 'occupied' }, { transaction: t }); await Allocation.create({ user_id: req.user.id, bed_id: bedId, status: 'active' }, { transaction: t }); await t.commit(); res.json({ message: '预订成功' }); } catch (err) { await t.rollback(); res.status(500).json({ message: '预订失败,请重试' }); } });

关键在lock: t.LOCK.UPDATE这一行,它会对该床位行加排他锁,第二个请求必须等第一个事务提交后才能读取。配合事务的原子性,要么床位状态和分配记录同时写入,要么都不写。existing检查是防止一个学生占多个床位,这个约束在业务上必须卡死。

3.3 管理员查看入住情况的关联查询

管理员需要一张表看到“楼栋-房间-床位-学生”的完整链路,用 Sequelize 的 include 做嵌套关联。

// routes/admin.js router.get('/occupancy', authenticate, requireAdmin, async (req, res) => { const beds = await Bed.findAll({ include: [{ model: Room, include: [{ model: Building, attributes: ['name', 'gender'] }] }], order: [['room_id', 'ASC'], ['bed_number', 'ASC']] }); // 查出所有 active 分配记录,按 bed_id 映射学生信息 const allocations = await Allocation.findAll({ where: { status: 'active' }, include: [{ model: User, attributes: ['real_name', 'username'] }] }); const allocMap = {}; allocations.forEach(a => { allocMap[a.bed_id] = a.User; }); const result = beds.map(bed => ({ building: bed.Room.Building.name, room: bed.Room.room_number, bed: bed.bed_number, status: bed.status, student: allocMap[bed.id] ? allocMap[bed.id].real_name : null })); res.json(result); });

先查床位再查分配记录,在内存里做映射,比在 SQL 里写多层 LEFT JOIN 更直观,也方便前端直接渲染表格。数据量大的时候可以加分页,但一般宿舍楼也就几百个床位,一次性返回没问题。

4. 前端页面与交互:jQuery 表格渲染和表单校验

4.1 学生端床位选择页

学生登录后看到的是按楼栋分组的床位卡片,可用床位显示为绿色可点击,已占用显示为灰色。

// public/js/student.js function loadBeds(buildingId) { $.ajax({ url: '/api/beds?buildingId=' + buildingId, headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }, success: function (beds) { var html = ''; beds.forEach(function (bed) { var cls = bed.status === 'available' ? 'bed-card available' : 'bed-card occupied'; var disabled = bed.status === 'available' ? '' : 'disabled'; html += '<div class="col-md-3">' + '<div class="' + cls + '">' + '<p>房间 ' + bed.roomNumber + '</p>' + '<p>床位 ' + bed.bedNumber + '</p>' + '<button class="btn btn-sm btn-primary" ' + disabled + ' onclick="allocate(' + bed.id + ')">预订</button>' + '</div></div>'; }); $('#bed-list').html(html); }, error: function (xhr) { if (xhr.status === 401) { window.location.href = '/login.html'; } } }); } function allocate(bedId) { if (!confirm('确认预订该床位?')) return; $.ajax({ url: '/api/allocate', method: 'POST', contentType: 'application/json', headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }, data: JSON.stringify({ bedId: bedId }), success: function () { alert('预订成功'); loadBeds(currentBuilding); }, error: function (xhr) { alert(xhr.responseJSON.message); } }); }

用 jQuery 的$.ajax统一处理 token 注入和 401 跳转,比在每个请求里手写 fetch 更省事。Bootstrap4 的col-md-3让床位卡片自适应排列,available和occupied两个 class 控制颜色和禁用状态。confirm弹窗虽然简陋,但能防止误触,答辩时演示也够用。

4.2 管理员端入住总览表格

管理员页面用 Bootstrap4 的 striped table 展示所有床位状态,支持按楼栋筛选。

// public/js/admin.js function loadOccupancy() { $.ajax({ url: '/api/admin/occupancy', headers: { 'Authorization': 'Bearer ' + localStorage.getItem('token') }, success: function (rows) { var html = ''; rows.forEach(function (r) { var badge = r.status === 'occupied' ? '<span class="badge badge-danger">已入住</span>' : '<span class="badge badge-success">空闲</span>'; html += '<tr><td>' + r.building + '</td><td>' + r.room + '</td><td>' + r.bed + '</td><td>' + badge + '</td><td>' + (r.student || '—') + '</td></tr>'; }); $('#occupancy-table tbody').html(html); } }); }

表格只做展示,调宿和退宿操作通过行内按钮触发单独的接口。管理员页面不需要花哨的交互,信息密度和可读性最重要。Bootstrap4 的 badge 组件用来区分状态,比纯文字更直观。

5. 避坑与常见问题:从重复预订到 token 过期

5.1 床位超卖:并发请求下的重复分配

现象是同一个床位被两个学生同时预订成功,数据库里出现两条 active 记录。原因是没有加行锁,两个请求同时读到status = 'available'。解决方法是像 3.2 节那样在事务里用LOCK.UPDATE锁定床位行,第二个请求会阻塞到第一个事务提交,再读到的就是occupied。如果用的是 MySQL 默认的 REPEATABLE READ 隔离级别,不加锁的普通 SELECT 读的是快照,更容易出问题。

5.2 token 过期后页面无响应

现象是学生填完表单点提交,页面卡住没反应,控制台报 401。原因是 token 过期后接口返回 401,但前端没有统一处理。解决方法是在$.ajaxSetup里加全局的 error 回调,遇到 401 就清除本地 token 并跳转登录页。不要在每个请求里单独写,容易漏。

$.ajaxSetup({ error: function (xhr) { if (xhr.status === 401) { localStorage.removeItem('token'); window.location.href = '/login.html'; } } });

5.3 Sequelize 关联查询字段名对不上

现象是管理员页面学生姓名列全是 undefined。原因是include里的模型别名和实际返回的对象键不一致。Sequelize 默认用模型名作为键,如果定义了as别名就要用别名取。排查方法是先console.log(JSON.stringify(result, null, 2))看实际结构,再决定用a.User还是a.user。这个坑几乎每个用 Sequelize 的人都会踩一次。

5.4 密码明文存储

现象是数据库里能看到用户密码原文。原因是注册接口直接存了req.body.password。解决方法是用 bcrypt 做哈希,注册时bcrypt.hash(password, 10),登录时bcrypt.compare(password, hash)。这个不是可选项,是必须做的。答辩时如果被问到安全性,密码哈希是基本要求。

5.5 前端直接暴露管理员接口

现象是学生用浏览器控制台直接调/api/admin/occupancy也能拿到数据。原因是管理员接口只挂了authenticate没挂requireAdmin。解决方法是在所有/api/admin/*路由上统一加requireAdmin中间件,或者在路由注册时用router.use(authenticate, requireAdmin)批量挂载。鉴权要放在后端做,前端隐藏按钮只是障眼法。

6. 验收与答辩:怎么证明这套系统真的能用

验收不是把页面点一遍就完事。我一般会按下面这张清单走一遍,每项都要有明确的通过标准。

验收项操作步骤通过标准
注册登录新用户注册后登录token 正常签发,跳转对应角色首页
床位展示学生端查看楼栋床位可用/已占用状态正确,颜色区分明显
预订锁定两个浏览器同时预订同一床位只有一个成功,另一个提示已占用
重复预订同一学生再次预订提示已有预订,不允许占多个床位
管理员总览管理员查看入住表楼栋、房间、床位、学生姓名完整对应
调宿退宿学生申请退宿后管理员查看床位状态变回 available,历史记录保留
权限隔离学生 token 调管理员接口返回 403,不泄露数据
密码安全查看数据库 users 表password_hash 为 bcrypt 哈希值

答辩时老师最常问的三个问题:床位并发怎么处理的、token 怎么防伪造、数据库表之间什么关系。前两个在 3.1 和 3.2 里有现成答案,第三个把 2.3 的建表语句讲清楚就行。如果老师问“为什么不用 Vue”,就按 2.2 的取舍逻辑回答:交互复杂度低,jQuery 够用,省下的时间花在业务约束上。

还有一个小技巧:答辩前把数据库清空,现场从注册开始演示一遍完整流程。比对着已有数据念页面更有说服力,也能证明系统不是“只能看不能跑”的花架子。

从那以后我每次做完这类管理系统,都会在交付前强制走一遍并发测试和权限越权测试,这两项过了,基本就不会在答辩现场翻车。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进