资讯详情 Express+Sequelize宿舍分配系统:JWT鉴权与事务行锁防超卖
📅 2026/11/5 6:57:14
简介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/bookingsroutes/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 / adminroomsid, room_no, building, floor, capacity, gender_typeroom_no 与 building 联合唯一bedsid, room_id, bed_no, status, occupant_id(room_id, bed_no) 联合唯一status 取 available / occupied / disabledbookingsid, user_id, room_id, bed_id, status, booked_atbed_id 唯一且仅对有效状态生效床位表里冗余一个status和occupant_id是有意为之。如果每次查房态都去bookings表做聚合房间列表页会变成 N1 查询十个房间就是十次统计。把当前占用状态直接放在床位上列表接口一次查询就能出结果代价是写预订时必须在同一个事务里更新两张表这个一致性由第 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、rolestudent / 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三档手机上一列、平板两列、桌面三列不用写任何媒体查询。真正需要设计的是请求层。如果每个页面都手写一遍$.ajaxtoken 怎么带、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 classbadge badge-success余 room.availableBeds 床/span : span classbadge badge-secondary已满/span; return div classcol-12 col-md-6 col-lg-4 mb-3 div classcard h-100 div classcard-body h5 classcard-title room.building room.roomNo /h5 p classcard-text text-muted room.floor 层 · (room.genderType male ? 男生 : 女生) 宿舍/p badge /div div classcard-footer bg-white button classbtn 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 引擎必须是 InnoDBMyISAM 不支持行锁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 就讲三层中间件问到事务就讲行锁和回滚都有具体代码可以翻。从那以后我每次做这类带资源占用的系统都会在上线前强制走一遍「两个人同时点同一个按钮」的测试不管需求方有没有提。这个动作花五分钟能省掉后面几天的数据修复。希望帮到你。本文还有配套的精品资源点击获取