☰
Express+Sequelize宿舍分配系统:JWT鉴权与事务行锁防超卖
2026/11/5 6:57:05 网站建设 项目流程

简介:Hostel-Allocation 是一套面向高校学生与宿舍管理员的在线宿舍预订与分配系统源码,围绕远程选房、预订记录、房间入住情况查询和管理员统一分配等流程展开。项目后端基于 Node.js、Express.js 与 MySQL,借助 Sequelize 完成数据建模和增删改查,前端使用 HTML5、CSS3、Bootstrap4 与 jQuery 构建响应式页面,并用 JWT 实现登录认证与受保护资源访问,适合作为 Web 全栈课程设计、毕业项目或练手案例,可帮助理解路由分层、数据库关联、权限校验和前后端数据交互。压缩包共 22 个文件,约 34KB,以 JavaScript 逻辑文件、HTML 页面和 JSON 配置为主,另含样式表、证书密钥、说明文档与忽略配置,整体体量轻、模块划分清楚,便于阅读源码和二次修改。目前已有 117 人学习,读者可获取完整项目骨架、认证与数据库连接思路、预订与房间管理页面示例,以及宿舍业务模块的组织方式和基础排错参考。

1. 需求先立住:宿舍分配搬到线上要解决的三件事

一栋宿舍楼几百个床位,开学季集中报到,走廊里排两小时队,只为在一张登记表上写个名字。这套 Hostel-Allocation 系统要解决的就是这个问题:学生远程就能看到哪些房间还有空床位、直接提交预订;管理员在后台随时查谁住在哪个房间、哪个床位空着。它适合拿来做毕业设计或课程设计的同学,也适合需要一个可交付的房间分配原型的开发者。三件事必须做到:床位状态不能让两个人同时抢到、学生看不到别人的隐私信息、管理员一眼能查到完整入住名单。下面按架构、认证、业务、排查、验收五段拆开讲。

2. 技术选型与总体架构:Express + Sequelize 的四层结构

2.1 为什么是 Node.js + Express + MySQL 这套组合

这个项目的业务量不大,但涉及的东西一点都不少:多角色登录、房间和床位的关联查询、并发预订、管理员统计。选型的时候最容易走两个极端,一是直接上重型框架把简单需求做复杂,二是纯手写 SQL 拼接口最后自己都维护不动。Express 属于中间那条路,路由、中间件、错误处理都是显式的,学生能看懂一次请求从进来到返回经历了什么。

数据库选 MySQL 而不是文档库,是因为这里的核心是关系:一个房间有多个床位,一个床位最多绑一个学生,一个学生同一时间只能有一个有效预订。这些约束用外键和唯一索引表达最自然,数据库自己就能挡住大部分脏数据,应用层不用写一堆防御代码。Sequelize 作为 ORM 的价值也在这里,它把hasMany/belongsTo直接映射成关联查询,房间列表带床位状态这种典型场景,一次include就能出结果,不必在控制器里手动拼两条 SQL 再在内存里做 join。

前端用 jQuery + Bootstrap4 而不是上框架,理由很实际。这套系统页面数量在十个以内,交互集中在表单提交和列表刷新,引入构建工具和状态管理带来的复杂度远大于收益。jQuery 的$.ajax配合 Bootstrap 的栅格和组件,一个熟悉基础语法的同学两天就能把页面骨架搭出来,剩下的时间可以全部投在业务逻辑和并发处理上。HTML5 + CSS3 负责结构,Bootstrap4 负责响应式,手机上打开也能正常选房,这在宿舍场景里是刚需,学生大多用手机操作。

JWT 负责认证,好处是无状态,服务端不存 session,接口调试和后续扩展都省事。代价是 token 签发出去就收不回来,所以过期时间要设短,敏感操作要二次校验,这些细节放在第 3 章讲。

2.2 目录分层与一次请求的完整链路

目录结构决定了一个项目能不能被第二个人接手。常见的错误是把所有逻辑塞进app.js,路由、SQL、校验混在一起,改一个字段要翻五个文件。这套系统按职责分成四层:路由层只负责定义 URL 和挂中间件,控制器层负责参数校验和组装响应,服务层承载业务规则和事务,模型层只描述表结构和关联。

hostel-allocation/ ├── app.js # 入口:中间件注册、路由挂载、错误兜底 ├── config/ │ └── database.js # Sequelize 连接配置,从环境变量读 ├── models/ # 只描述表结构和关联关系 │ ├── index.js # 统一加载模型并建立关联 │ ├── user.js │ ├── room.js │ ├── bed.js │ └── booking.js ├── services/ # 业务规则,事务都在这一层 │ ├── authService.js │ └── bookingService.js ├── controllers/ # 入参校验 + 调用 service + 输出 │ ├── authController.js │ ├── roomController.js │ └── adminController.js ├── routes/ # URL 与中间件的绑定 │ ├── auth.js │ ├── rooms.js │ └── admin.js ├── middleware/ │ ├── auth.js # JWT 校验与角色守卫 │ └── errorHandler.js # 统一错误出口 └── public/ # Bootstrap4 + jQuery 静态页面 ├── login.html ├── rooms.html ├── admin.html ├── css/app.css └── js/api.js

一次「学生提交预订」的请求走完整个链路是这样的:浏览器带着Authorization: Bearer <token>请求POST /api/bookings,routes/bookings.js先挂requireAuth解析 token 并把用户信息写入req.user,再进控制器校验roomId、bedId是否为合法整数,然后调bookingService.bookBed()开启事务、锁床位、写预订记录,成功后控制器返回 201 和预订详情,任何一步抛错都由errorHandler统一转成{ code, message }的 JSON。前端在fail回调里只需按code分支就能给出准确提示,不必去猜 HTTP 状态码背后的含义。

这条链路的价值在于可定位。出问题时看日志里的code就知道是认证失败、参数非法还是床位已被占用,不用在几百行代码里翻找。

2.3 数据模型设计:四个核心实体和它们的约束

表结构是整个方案的基础。设计得好,后面很多并发和一致性问题数据库层面就挡住了;设计得差,只能在应用层写一堆补丁。这套系统四张表:用户、房间、床位、预订。

表名关键字段约束与说明
usersid, name, email, password_hash, role, student_noemail 唯一;role 取 student / admin
roomsid, room_no, building, floor, capacity, gender_typeroom_no 与 building 联合唯一
bedsid, room_id, bed_no, status, occupant_id(room_id, bed_no) 联合唯一;status 取 available / occupied / disabled
bookingsid, user_id, room_id, bed_id, status, booked_atbed_id 唯一且仅对有效状态生效

床位表里冗余一个status和occupant_id是有意为之。如果每次查房态都去bookings表做聚合,房间列表页会变成 N+1 查询,十个房间就是十次统计。把当前占用状态直接放在床位上,列表接口一次查询就能出结果,代价是写预订时必须在同一个事务里更新两张表,这个一致性由第 4 章的事务保证。

模型定义用 Sequelize 写成工厂函数,models/index.js统一加载后建立关联:

// models/bed.js module.exports = (sequelize, DataTypes) => { const Bed = sequelize.define('Bed', { roomId: { type: DataTypes.INTEGER, allowNull: false }, bedNo: { type: DataTypes.STRING(8), allowNull: false }, // 冗余状态字段,避免列表页做聚合查询 status: { type: DataTypes.ENUM('available', 'occupied', 'disabled'), allowNull: false, defaultValue: 'available' }, occupantId: { type: DataTypes.INTEGER, allowNull: true } }, { tableName: 'beds', underscored: true, indexes: [ // 同一房间内的床位号不能重复 { unique: true, fields: ['room_id', 'bed_no'] } ] }); return Bed; };

underscored: true这个选项值得单独说一句。Model 里写驼峰occupantId,落到数据库自动变成occupant_id,控制器和前端继续用驼峰,两边都不别扭。不设这个选项就会出现「JS 里是 camelCase、SQL 里是 camelCase、同事写迁移脚本时手滑写成 snake_case」的经典翻车现场。

关联关系在models/index.js里一次性声明清楚:Room.hasMany(Bed)、Bed.belongsTo(Room)、User.hasMany(Booking)、Booking.belongsTo(Bed)。不要在两个模型文件里各写一次,Sequelize 会报重复关联警告,而且关联别名不一致时include会静默失败,查出来是空数组,排查起来非常费时间。

3. 认证与权限:JWT 双角色隔离的实现细节

3.1 JWT 载荷设计:把角色放进 token 而不是每次查库

这套系统只有两种角色,学生和管理员,但两者的权限边界必须清晰。学生能查房间、能预订自己的床位、能取消自己的预订;管理员能查所有入住记录、能强制释放床位、能禁用房间。边界一乱,学生调一下接口就能看到全楼学生的姓名和学号,这在宿舍场景里是明确的隐私问题。

JWT 的载荷里放三个字段就够了:sub(用户 id)、role(student / admin)、name(仅用于前端展示,不放学号、手机号)。不要在 token 里塞完整用户对象,一是 token 会变大,二是 JWT 默认只签名不加密,任何人 base64 解码就能读到内容,敏感字段一律不进。

// services/authService.js const jwt = require('jsonwebtoken'); function signToken(user) { return jwt.sign( { sub: user.id, role: user.role, name: user.name }, process.env.JWT_SECRET, { expiresIn: '2h', // 学生用 2 小时足够覆盖一次选房流程 issuer: 'hostel-allocation' } ); }

expiresIn设 2 小时是个折中。设太短,学生填完表单提交时 token 过期,体验很差;设太长,token 泄露后的窗口期就长。管理员端可以单独签 30 分钟,因为后台操作频率低,重新登录成本可以接受。两个有效期用同一个signToken加参数区分即可,不必写两套逻辑。

JWT_SECRET从环境变量读,不要写死在代码里。这条不是形式主义,毕设代码经常被传到公开仓库,写死的密钥等于把后台入口一起交出去。

3.2 鉴权中间件与路由守卫的三层结构

认证和授权是两件事,中间件也要分开写。第一层requireAuth只回答「你是谁」,解析失败直接 401;第二层requireRole('admin')回答「你能不能进」,角色不符返回 403;第三层在控制器里做资源归属校验,回答「这条数据是不是你的」。三层缺一层都会留下越权口子,最常见的就是只有前两层,学生 A 拿着合法 token 去DELETE /api/bookings/123,而 123 是学生 B 的预订。

// middleware/auth.js const jwt = require('jsonwebtoken'); // 第一层:解析身份 function requireAuth(req, res, next) { const header = req.headers.authorization || ''; const token = header.startsWith('Bearer ') ? header.slice(7) : null; if (!token) { return res.status(401).json({ code: 'NO_TOKEN', message: '请先登录' }); } try { req.user = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'hostel-allocation' }); next(); } catch (err) { // 过期和签名错误分开返回,前端好做区分处理 const code = err.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'TOKEN_INVALID'; return res.status(401).json({ code, message: '登录状态已失效' }); } } // 第二层:校验角色 function requireRole(role) { return function (req, res, next) { if (!req.user || req.user.role !== role) { return res.status(403).json({ code: 'FORBIDDEN', message: '无权访问' }); } next(); }; } module.exports = { requireAuth, requireRole };

路由挂载时按最小权限原则写,公开接口只有登录和注册,其余全部先过requireAuth:

// routes/index.js const router = require('express').Router(); const { requireAuth, requireRole } = require('../middleware/auth'); router.use('/auth', require('./auth')); // 公开 router.use('/rooms', requireAuth, require('./rooms')); // 登录可见 router.use('/bookings', requireAuth, require('./bookings')); // 登录可见 router.use('/admin', requireAuth, requireRole('admin'), require('./admin')); // 仅管理员

requireRole写成返回中间件的高阶函数,而不是写死判断,是为了以后加第三种角色(比如宿管)时只改一行。这个习惯在毕设答辩里是加分项,评委问「如果再加一个角色怎么办」,你能直接指出来改哪里。

3.3 注册登录接口的完整实现与密码处理

密码存储只有一个正确答案:加盐哈希,绝不明文、绝不用 MD5。bcrypt 的hash自带随机盐,compare做恒定时间比较,这两个特性刚好挡住彩虹表和时序攻击。代价是计算慢,所以盐的轮数不要贪高,10 轮在普通机器上单次约 60~80 毫秒,够用且不会把登录接口拖垮。

// services/authService.js const bcrypt = require('bcrypt'); const { User } = require('../models'); async function register({ name, email, password, studentNo }) { // 先查重,靠数据库唯一索引兜底,不要只靠这一步 const exists = await User.findOne({ where: { email } }); if (exists) throw new Error('EMAIL_TAKEN'); const passwordHash = await bcrypt.hash(password, 10); return User.create({ name, email, studentNo, passwordHash, role: 'student' // 角色由服务端决定,不接受前端传入 }); } async function login({ email, password }) { const user = await User.findOne({ where: { email } }); // 用户不存在时也执行一次 compare,避免响应时间泄露账号是否存在 const hash = user ? user.passwordHash : '$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidin'; const ok = await bcrypt.compare(password, hash); if (!user || !ok) throw new Error('INVALID_CREDENTIALS'); return user; }

这里有两个容易忽略的点。第一,role必须由服务端写死为student,如果直接User.create(req.body),前端在注册请求里多加一个"role": "admin"就能注册出管理员账号,这是最典型的批量赋值漏洞。第二,登录失败统一返回INVALID_CREDENTIALS,不要区分「用户不存在」和「密码错误」,否则接口就成了账号枚举工具。

注册接口还要做基础校验:邮箱格式、密码长度不少于 8 位、学号必填且唯一。校验放在控制器层,用一个小函数收集所有错误一次性返回,比逐个字段抛错体验好得多。

4. 核心业务落地:从学生选房到管理员看板

4.1 房间列表接口:一次查询把床位状态带出来

学生打开页面第一眼要看到的是「哪栋楼、哪个房间、还剩几张床」。这个接口如果拆成「查房间」加「循环查床位」,十个房间就是十一次查询,床位多了直接拖垮响应。正确做法是用 Sequelize 的include一次带出关联数据,在数据库侧完成 join。

// controllers/roomController.js const { Room, Bed } = require('../models'); async function listRooms(req, res, next) { try { const { building, gender } = req.query; const where = {}; if (building) where.building = building; if (gender) where.genderType = gender; // male / female const rooms = await Room.findAll({ where, include: [{ model: Bed, as: 'beds', attributes: ['id', 'bedNo', 'status'], // 不返回 occupantId,保护隐私 required: false }], order: [['building', 'ASC'], ['roomNo', 'ASC']] }); // 在内存里聚合成前端直接可用的结构 const data = rooms.map((room) => { const beds = room.beds || []; return { id: room.id, roomNo: room.roomNo, building: room.building, floor: room.floor, capacity: room.capacity, genderType: room.genderType, totalBeds: beds.length, availableBeds: beds.filter((b) => b.status === 'available').length, beds: beds.map((b) => ({ id: b.id, bedNo: b.bedNo, status: b.status })) }; }); res.json({ code: 'OK', data }); } catch (err) { next(err); } }

attributes里刻意不返回occupantId,因为房间列表是学生可见的接口,一旦带上入住人 id,配合其他接口就能反查出姓名。这类隐私边界在宿舍系统里必须提前划好,等出了事再补就晚了。required: false保证没有床位的空房间也能出现在列表里,否则管理员新建房间后会以为没保存成功。

前端拿到availableBeds直接渲染成徽章,剩余为 0 的房间置灰并禁用预订按钮,这一层体验能挡掉大量无效请求。

4.2 预订接口:事务 + 行锁防超卖

整个系统最容易翻车的地方就在这里。两个学生同时点「预订」同一个床位,如果代码是先findOne查状态、判断为available、再update,那么两个请求可能都读到available,最后都写入成功,一个床位住两个人。这不是理论概率问题,开学季集中访问时几乎必然发生。

解决方式是事务加行锁,把「读状态」和「改状态」放进同一个原子操作里:

// services/bookingService.js const { sequelize, Bed, Booking, Room } = require('../models'); async function bookBed(userId, roomId, bedId) { return sequelize.transaction(async (t) => { // FOR UPDATE:锁住这一行,第二个请求会在这里排队等待 const bed = await Bed.findOne({ where: { id: bedId, roomId }, lock: t.LOCK.UPDATE, transaction: t }); if (!bed) throw new Error('BED_NOT_FOUND'); if (bed.status !== 'available') throw new Error('BED_TAKEN'); // 一个学生同一时间只能有一个有效预订 const active = await Booking.findOne({ where: { userId, status: ['pending', 'checked_in'] }, transaction: t }); if (active) throw new Error('ALREADY_BOOKED'); // 同一事务内更新床位与写入预订,要么都成功要么都回滚 bed.status = 'occupied'; bed.occupantId = userId; await bed.save({ transaction: t }); return Booking.create({ userId, roomId, bedId, status: 'pending', bookedAt: new Date() }, { transaction: t }); }); }

几个参数必须说清楚。lock: t.LOCK.UPDATE生成的是SELECT ... FOR UPDATE,它锁住的是被查询到的那一行,第二个并发请求会在这一行上阻塞,等第一个事务提交后再读,此时status已是occupied,直接抛BED_TAKEN。锁的粒度是行级,不同床位的预订互不影响,不会把整个表锁死。

sequelize.transaction的回调里任何一次throw都会触发自动回滚。这意味着如果Booking.create因为唯一索引冲突失败,前面bed.save的修改也会被撤销,不会出现「床位占了但没预订记录」的幽灵数据。这个特性依赖你把所有写操作都传同一个transaction: t,漏传一个就会变成独立事务,回滚管不到它,这是实操中最常见的失误。

调用方控制器只负责把错误码翻译成 HTTP 响应:

// controllers/bookingController.js const bookingService = require('../services/bookingService'); const ERROR_MAP = { BED_NOT_FOUND: [404, '床位不存在'], BED_TAKEN: [409, '这个床位刚被别人订走了'], ALREADY_BOOKED: [409, '你已有一个有效预订,请先取消'] }; async function create(req, res, next) { try { const booking = await bookingService.bookBed( req.user.sub, Number(req.params.roomId), Number(req.body.bedId) ); res.status(201).json({ code: 'OK', data: booking }); } catch (err) { const mapped = ERROR_MAP[err.message]; if (mapped) { return res.status(mapped[0]).json({ code: err.message, message: mapped[1] }); } next(err); } }

409 这个状态码用得很关键,它明确告诉前端「是冲突,不是参数错也不是服务器崩了」,前端据此提示「换个床位试试」,而不是弹一个笼统的「操作失败」。

4.3 管理员看板:谁住在哪个房间

管理员的核心诉求只有一句话:输入学号或姓名,立刻知道这人住哪栋哪间哪床;反过来,看某间房,知道现在住了谁。这两个方向的查询对应后台两个接口,都建立在第 2 章那张beds表的冗余字段上。

按房间查入住名单,直接从beds出发带上关联:

// controllers/adminController.js const { Bed, Room, User } = require('../models'); async function roomOccupancy(req, res, next) { try { const beds = await Bed.findAll({ where: { roomId: req.params.roomId }, include: [ { model: Room, as: 'room', attributes: ['roomNo', 'building', 'floor'] }, { model: User, as: 'occupant', // 需要在 models/index.js 里声明 Bed.belongsTo(User) attributes: ['id', 'name', 'studentNo'], required: false } ], order: [['bedNo', 'ASC']] }); const data = beds.map((b) => ({ bedId: b.id, bedNo: b.bedNo, status: b.status, occupant: b.occupant ? { name: b.occupant.name, studentNo: b.occupant.studentNo } : null })); res.json({ code: 'OK', data }); } catch (err) { next(err); } }

注意这里返回了studentNo,因为只有管理员能进这个路由,权限边界由第 3 章的requireRole('admin')保证。同一个字段在学生接口里就必须排除,角色不同、返回字段不同,这个差异要在设计接口时就想清楚,不要等联调时才发现。

按学号反查则从User出发:

// 通过学号定位当前入住信息 async function findByStudentNo(req, res, next) { try { const user = await User.findOne({ where: { studentNo: req.query.studentNo }, attributes: ['id', 'name', 'studentNo'], include: [{ model: Bed, as: 'bed', required: false, include: [{ model: Room, as: 'room', attributes: ['roomNo', 'building', 'floor'] }] }] }); if (!user) return res.status(404).json({ code: 'USER_NOT_FOUND' }); res.json({ code: 'OK', data: user }); } catch (err) { next(err); } }

这个设计的前提是Bed.occupantId和User之间的关联必须在models/index.js里显式声明,并且as别名要和查询里一致。Sequelize 的关联别名不一致时不会报错,只会返回null,这是非常隐蔽的坑,第 5 章会单独讲。

统计口径也要提前定好。管理员首页常见的三个数字:总床位数、已占用数、可预订数。用一条GROUP BY status的聚合查询就能出,不要用findAll拉全表再在 JS 里数,床位上千时性能差异很明显。

4.4 前端页面组织:Bootstrap4 布局与 jQuery 请求封装

前端只有四个页面:登录注册、房间列表、我的预订、管理员后台。用 Bootstrap4 的栅格把房间卡片排成响应式网格,col-12 col-md-6 col-lg-4三档,手机上一列、平板两列、桌面三列,不用写任何媒体查询。

真正需要设计的是请求层。如果每个页面都手写一遍$.ajax,token 怎么带、401 怎么处理、错误怎么提示,会散落在十几个地方,改一次要改十几处。抽一个api.js统一封装:

// public/js/api.js const API_BASE = '/api'; function request(path, method, data) { return $.ajax({ url: API_BASE + path, method: method || 'GET', contentType: 'application/json', data: data ? JSON.stringify(data) : undefined, dataType: 'json', headers: { Authorization: 'Bearer ' + (localStorage.getItem('token') || '') } }).fail(function (xhr) { const body = xhr.responseJSON || {}; if (xhr.status === 401) { // token 过期或无效,清掉本地状态回登录页 localStorage.removeItem('token'); localStorage.removeItem('user'); window.location.href = '/login.html'; return; } // 409 这类业务冲突交给页面自己提示,其余走统一 toast if (xhr.status !== 409) { $('#toast').text(body.message || '网络异常,请稍后重试').fadeIn(150).delay(2200).fadeOut(150); } }); } // 房间列表渲染:用模板字符串拼卡片,避免大量 DOM 操作 function renderRooms(list) { const html = list.map(function (room) { const disabled = room.availableBeds === 0 ? 'disabled' : ''; const badge = room.availableBeds > 0 ? '<span class="badge badge-success">余 ' + room.availableBeds + ' 床</span>' : '<span class="badge badge-secondary">已满</span>'; return '' + '<div class="col-12 col-md-6 col-lg-4 mb-3">' + ' <div class="card h-100">' + ' <div class="card-body">' + ' <h5 class="card-title">' + room.building + ' ' + room.roomNo + '</h5>' + ' <p class="card-text text-muted">' + room.floor + ' 层 · ' + (room.genderType === 'male' ? '男生' : '女生') + '宿舍</p>' + ' ' + badge + ' </div>' + ' <div class="card-footer bg-white">' + ' <button class="btn btn-primary btn-sm btn-book">// 事件委托:卡片重绘后依然有效 $('#roomList').on('click', '.btn-book', function () { const roomId = $(this).data('room'); request('/rooms/' + roomId + '/beds').done(function (res) { renderBedPicker(res.data); }); });

这套前端结构不复杂,但每一处都对应一个实际会踩的坑:请求封装对应 token 管理,委托对应动态 DOM,模板字符串对应渲染性能。把这些讲清楚,方案才算完整。

5. 常见问题排查:五个最容易翻车的点

5.1 并发与数据一致性类的三个坑

坑一:两个人订到同一张床。现象是管理员看板上一个床位显示两条入住记录,或者学生端显示预订成功但床位已被别人占用。原因基本可以锁定在预订逻辑没有用事务包裹,查状态和改状态分成了两次独立请求,中间存在时间窗口。解决方式是按 4.2 的写法把整个流程放进sequelize.transaction,查询时加lock: t.LOCK.UPDATE。验证方法也简单,用两个终端同时发同一个床位的预订请求,看是否只有一个返回 201、另一个返回 409,如果两个都成功就说明锁没生效。还有一个容易漏的点是 MySQL 引擎必须是 InnoDB,MyISAM 不支持行锁,FOR UPDATE会静默退化成表锁甚至不锁。

坑二:取消预订后床位没释放。现象是学生取消了预订,房间列表里那个床位仍然是「已占用」。原因是取消逻辑只更新了bookings.status = 'cancelled',忘了同步把beds.status改回available并清空occupant_id。这两步必须在同一个事务里,否则中途失败会留下永久占用的床位。更稳妥的做法是在床位表上不做物理删除,只改状态,同时给bookings加一条规则:同一个bed_id在pending/checked_in状态下最多一条记录,用部分唯一索引或者应用层加事务内查询来保证。

坑三:关联查询返回空数组。现象是Room.findAll({ include: [{ model: Bed }] })明明数据库里有床位,返回的beds却是空数组或null。原因通常是models/index.js里没声明关联,或者声明时用了as: 'beds'但查询时没写别名。Sequelize 在这两种情况下的表现很不一样:完全没声明关联会直接报错说模型未关联;声明了别名但查询漏写,则静默返回空。排查办法是在模型加载后打印一次Room.associations,看看别名到底叫什么。这个黑匣子我吃过亏,后来养成习惯,凡是带include的查询先在服务启动时打一行日志确认关联表。

5.2 认证与前后端联调类的两个坑

坑四:刷新页面就掉登录。现象是登录成功后跳转正常,一刷新就回到登录页。原因有两个常见版本:一是 token 存在了内存变量里而不是localStorage,刷新即丢;二是前端在请求头里写成了Authorization: token而不是Bearer token,后端slice(7)切出来的字符串是错的,校验必然失败。排查时先看浏览器 Network 面板里请求头的实际值,再在requireAuth里打一行console.log(header)确认服务端收到的是什么。这两个位置对不上,其余地方怎么改都没用。

坑五:管理员接口返回 403 但 token 明明有效。现象是管理员登录后调后台接口一直 403。原因多半是签发 token 时role字段读的是req.body.role而不是数据库里查出来的用户角色,或者注册时允许前端传role,导致库里存的是student。这类问题的根因在数据写入阶段,排查时先直接查一次数据库确认users.role的真实值,再回头看签发逻辑。修复方案是把角色完全交给服务端决定:注册接口固定写role: 'student',管理员账号通过初始化脚本或后台手动改,不接受任何来自请求体的角色字段。

这五个坑有个共同特征:都不会在单元测试里暴露,只有并发、刷新、跨角色这些真实操作路径才会触发。所以验收阶段的手工测试用例必须覆盖到,下一章给的清单就是照这个思路列的。

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

验收这件事,很多人栽在「功能都点得通」这个自我判断上。点得通只说明正常路径能走,而宿舍分配系统的关键价值恰恰在异常路径:抢床位、重复预订、越权访问。答辩时评委最爱问的也是这些。

我一般按三层来验。第一层是数据层,直接查库确认约束生效:往beds表插两条相同(room_id, bed_no)的记录,必须报唯一索引冲突;把users.email插重复,必须被挡住。第二层是接口层,用 Postman 或 curl 跑一遍角色矩阵,学生 token 调/api/admin/occupancy必须返回 403,无 token 调/api/rooms必须返回 401,学生 A 的 token 取消学生 B 的预订必须返回 403 而不是 200。第三层是场景层,也就是下面这张表。

验收项操作步骤期望结果
并发抢床两个浏览器同时预订同一床位一个 201,一个 409
重复预订同一学生连续预订两个床位第二次返回 ALREADY_BOOKED
取消释放取消预订后查看房间列表床位状态回到 available
隐私隔离学生端调房间列表接口响应中不含 occupantId、studentNo
权限隔离学生 token 访问管理员接口返回 403
登录过期手动改坏 localStorage 里的 token自动跳回登录页

答辩演示的顺序也有讲究。不要从登录页开始一路点,那个流程太平淡,评委看不到你在设计上的取舍。我的建议是先讲数据模型,把四张表和唯一约束画在 PPT 上,说明「并发问题我在数据库层就挡住了一半」;然后开两个浏览器窗口演示并发抢床,一个成功一个收到冲突提示,这是整场最有说服力的三十秒;最后展示管理员看板,输入学号立刻定位房间床位,把「减少排队」这个需求闭环讲完。技术细节留到提问环节,评委问到 JWT 就讲三层中间件,问到事务就讲行锁和回滚,都有具体代码可以翻。

从那以后我每次做这类带资源占用的系统,都会在上线前强制走一遍「两个人同时点同一个按钮」的测试,不管需求方有没有提。这个动作花五分钟,能省掉后面几天的数据修复。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询