简介:这是一套面向计算机相关专业学生与Web开发初学者的宿舍预订系统完整源码,基于Node.js与Express.js搭建后端,配合MySQL与Sequelize完成数据持久化,前端采用HTML5、CSS3、Bootstrap4与jQuery构建响应式页面,并通过JWT实现登录鉴权与路由保护。项目区分学生与管理员两类角色:学生可在线查看房间、自助预订,管理员可新增房间、查看住宿分配与预订记录,从而减少线下排队和人员接触。资源包共22个文件、约34KB,以JavaScript为主(含app.js、路由、认证与数据库连接等后端逻辑),另有4个HTML页面、CSS样式、package.json依赖清单及README说明,还附带了本地HTTPS调试用的证书与密钥文件,结构紧凑、便于导入运行与二次开发。目前已有117人学习。读者可从中获取JWT鉴权、Sequelize模型设计、Express路由分层与前端表单交互的完整实现范例,适合作为课程设计、毕业设计参考或全栈入门练手项目。
1. 从开学季那条长队说起:Hostel-Allocation 到底解决了什么
每年新生报到那两天,宿舍楼下的队伍能拐两个弯,辅导员抱着一摞纸质表格挨个问“你叫什么、哪个专业、想住几楼”,写完再翻另一本册子找空床位。这套流程最大的问题不是慢,是信息不对称——学生不知道自己能选哪间,管理员不知道谁已经住进去了。Hostel-Allocation 就是把这个柜台搬到浏览器里:学生登录后远程预订房间,管理员登录后看到一张“谁住在哪个房间”的实时表。技术栈是 Node.js + Express + MySQL + Sequelize,鉴权用 JWT,前端 jQuery + Bootstrap4 直接由 Express 托管静态文件。它适合两类人:一是想找一个“有完整业务闭环、又不是玩具”的 Node 全栈练手项目的人;二是真要在小规模场景里落地一套住宿登记系统、需要拿去改的人。
2. 技术选型与目录结构:Express + Sequelize 撑起的三层骨架
2.1 为什么这套栈适合宿舍分配场景
宿舍分配这类系统的业务复杂度其实很低:三张表、两种角色、一个“占位”动作。真正难的地方在两处——一是并发,两个学生同时点同一间房的“预订”,占用量不能变成 5/4;二是权限,学生只能看自己的分配记录,管理员才能看全量。这两个诉求直接决定了技术选型的方向。
选 Express 而不是更重的框架,理由很实在:这个项目 90% 的接口都是“收一个 JSON、查一次库、返回一个 JSON”,路由层薄得几乎没有存在感。Express 的中间件模型刚好能把 JWT 校验、角色判断、错误兜底串成一条链,一个app.use就把所有/api/admin/*挡在门外,不用在每个控制器里重复写 if。
选 Sequelize 而不是手写 SQL,理由在关联查询上。管理员看板要的不是allocations表本身,而是“学生姓名 + 学号 + 楼栋 + 房间号 + 入住时间”拼起来的一行。手写的话就是三表 JOIN 加字段别名,改一次字段就要同步改三处;Sequelize 的include把这个拼装过程声明化了,模型里写一次关联,查询处直接include: [{ model: Student }, { model: Room }]。
选 JWT 而不是 session,是因为这套系统天然是前后端分离的形态:前端是纯静态的 HTML + jQuery,由 Express 的express.static直接吐出去,没有任何模板渲染。session 需要服务端存状态、需要 cookie 跨请求带上、部署多实例还要考虑共享存储;JWT 把{ id, role }塞进 token 里,服务端验签即可,横向扩容时不用动任何配置。代价是 token 签发后无法主动吊销,只能靠短过期时间兜底——这一点在后面避坑章节会细说。
前端用 jQuery 而不是 Vue/React,看起来“落后”,但在这个项目里是合理的:页面只有登录、房间列表、管理员看板三个,交互就是“拉列表、点按钮、弹提示”,引入一套构建工具链的收益远小于成本。Bootstrap4 负责栅格和表格样式,jQuery 负责$.ajax和事件委托,整个前端零构建,改完文件刷新浏览器就生效。
MySQL 这边有一个容易被忽略的细节:选房要用行锁,而行锁只有 InnoDB 支持。MyISAM 只支持表锁,两个学生抢不同房间也会互相阻塞。所以建库时必须确认ENGINE=InnoDB,MySQL 5.7 之后默认就是,但如果是接手别人的老库,这一点要专门查一遍。
2.2 目录怎么分层,路由和控制器别混着写
项目目录按职责切分,不按文件类型切分。很多新手会把所有路由写进一个routes.js,业务逻辑直接写在路由回调里,写到第三个接口就开始互相引用。这个项目的做法是路由只做“路径到函数”的映射,控制器只做“参数校验 + 调模型 + 返回”,模型只做“表结构和关联声明”。
hostel-allocation/ ├── app.js # 入口:中间件装配、静态目录、错误兜底 ├── config/ │ └── database.js # 数据库连接配置,按环境导出 ├── models/ │ ├── index.js # 实例化 sequelize,统一声明关联 │ ├── student.js │ ├── room.js │ └── allocation.js ├── controllers/ │ ├── authController.js # 注册、登录、签发 token │ ├── roomController.js # 房间列表、详情 │ └── allocationController.js # 选房、退宿 ├── routes/ │ ├── auth.js │ ├── rooms.js │ └── admin.js ├── middleware/ │ ├── auth.js # verifyToken / requireRole │ └── errorHandler.js ├── public/ # 前端静态资源,Express 直接托管 │ ├── index.html # 登录 + 注册 │ ├── rooms.html # 学生选房页 │ ├── admin.html # 管理员看板 │ ├── css/ │ └── js/ │ ├── login.js │ ├── rooms.js │ └── admin.js ├── migrations/ ├── seeders/ # 初始房间数据 ├── .env.example └── package.jsonapp.js的装配顺序有讲究,中间件是洋葱模型,谁先注册谁先执行。静态目录要放在路由之前,否则访问/rooms.html会被后面的路由通配符截走;错误处理中间件必须放在所有路由之后,而且签名必须是四个参数(err, req, res, next),少一个 Express 就不会把它当错误处理器。
// app.js const express = require('express'); const path = require('path'); const authRoutes = require('./routes/auth'); const roomRoutes = require('./routes/rooms'); const adminRoutes = require('./routes/admin'); const errorHandler = require('./middleware/errorHandler'); const app = express(); app.use(express.json()); // 解析 application/json app.use(express.urlencoded({ extended: false })); // 解析表单提交 app.use(express.static(path.join(__dirname, 'public'))); // 静态资源优先命中 app.use('/api/auth', authRoutes); // 放行:注册、登录 app.use('/api/rooms', roomRoutes); // 内部各自挂 verifyToken app.use('/api/admin', adminRoutes); // 内部挂 verifyToken + requireRole('admin') app.use(errorHandler); // 四参数签名,必须最后注册 const port = process.env.PORT || 3000; app.listen(port, () => console.log(`server on ${port}`));这里express.json()和express.urlencoded()两个都要挂,因为前端 jQuery 的$.ajax有时候用contentType: 'application/json'发 JSON,有时候用默认的表单编码,少挂一个就会出现req.body是undefined的情况。express.static放在最前面是为了让rooms.html、css/、js/这类请求直接被文件系统处理掉,不进入后面的路由匹配,省一次判断。
2.3 从零把项目跑起来:环境变量与建表脚本
落地第一步不是写代码,是把数据库连通。很多人卡在这里,报错信息往往是ER_ACCESS_DENIED_ERROR或者ECONNREFUSED,前者是账号密码不对,后者是 MySQL 服务没起或者端口不对。
# 初始化并安装依赖 npm init -y npm i express sequelize mysql2 jsonwebtoken bcryptjs dotenv cors npm i -D sequelize-cli nodemon # 建库(字符集必须 utf8mb4,否则学生姓名里的生僻字会变成问号) mysql -u root -p -e "CREATE DATABASE hostel DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"mysql2是驱动,sequelize是 ORM,两个是配套的;bcryptjs是纯 JS 实现的密码哈希,比原生的bcrypt少一次编译,在 Windows 上装起来省事;dotenv用来读.env,避免把数据库密码硬编码进仓库。
.env里至少要放这几项,注意JWT_SECRET不要用示例值上线,随便一个 32 位随机串都行:
# .env PORT=3000 DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=hostel DB_USER=root DB_PASS=your_password JWT_SECRET=change_this_to_a_random_32_chars JWT_EXPIRES=2h连接配置单独抽一个文件,因为sequelize-cli做迁移时也读同一份配置,两处写死容易出现“应用连 A 库、迁移跑 B 库”的经典翻车:
// config/database.js require('dotenv').config(); module.exports = { development: { username: process.env.DB_USER, password: process.env.DB_PASS, database: process.env.DB_NAME, host: process.env.DB_HOST || '127.0.0.1', port: Number(process.env.DB_PORT) || 3306, dialect: 'mysql', timezone: '+08:00', // 关键:不写会按 UTC 存,页面上的入住时间差 8 小时 logging: false, // 关掉 SQL 打印,开发时想看得清楚可以改成 console.log define: { underscored: true, // JS 里写 camelCase,数据库自动落成 snake_case timestamps: true // 自动维护 created_at / updated_at }, pool: { max: 10, min: 0, acquire: 30000, idle: 10000 } } };timezone: '+08:00'这一行是血泪经验。Sequelize 默认按 UTC 处理DATETIME,写入的new Date()会被转换一次,读出来再转换一次,如果 MySQL 服务端本身又是 UTC,就是双重偏移。表现出来就是“学生明明今天入住,看板上显示昨天”。
模型实例化和关联声明统一收在models/index.js,这是整个项目里最容易被忽略又最容易出错的文件:
// models/index.js const { Sequelize, DataTypes } = require('sequelize'); const config = require('../config/database')[process.env.NODE_ENV || 'development']; const sequelize = new Sequelize( config.database, config.username, config.password, config ); const Student = require('./student')(sequelize, DataTypes); const Room = require('./room')(sequelize, DataTypes); const Allocation = require('./allocation')(sequelize, DataTypes); // 关联必须在 sync/migration 之前声明,否则外键列不会建出来 Student.hasOne(Allocation, { foreignKey: 'studentId', as: 'allocation' }); Allocation.belongsTo(Student, { foreignKey: 'studentId', as: 'student' }); Room.hasMany(Allocation, { foreignKey: 'roomId', as: 'allocations' }); Allocation.belongsTo(Room, { foreignKey: 'roomId', as: 'room' }); module.exports = { sequelize, Sequelize, Student, Room, Allocation };as别名不是可有可无的装饰。include的时候用的就是这个名字,include: [{ model: Student, as: 'student' }],别名对不上会报student is not associated to allocation,这个报错信息很误导人,看着像没定义关联,其实是名字写错了。
第一次跑,用sync快速把表建出来,加个初始房间数据:
// scripts/init-db.js const { sequelize, Room } = require('../models'); (async () => { await sequelize.sync({ force: true }); // 注意:force 会删表重建,只能本地用 const rows = []; for (let floor = 1; floor <= 6; floor++) { for (let n = 1; n <= 10; n++) { rows.push({ roomNo: `${floor}${String(n).padStart(2, '0')}`, building: 'A', floor, capacity: 4, occupied: 0, genderType: floor <= 3 ? 'male' : 'female' }); } } await Room.bulkCreate(rows); console.log(`已初始化 ${rows.length} 间房`); await sequelize.close(); })();bulkCreate一次插入 60 条比循环create快一个数量级,因为它会拼成单条INSERT ... VALUES (...), (...)。force: true会先DROP TABLE再建,开发阶段表结构改来改去很方便,但一旦有真实数据就是灾难,所以这个脚本我只放在scripts/目录,绝不进npm start的链路。
跑起来就是三步:node scripts/init-db.js建表 →npm run dev(配置成nodemon app.js)→ 浏览器打开http://localhost:3000/index.html。
3. 数据模型与鉴权:三张表撑起学生和管理员两套视图
3.1 学生表、房间表、分配记录表的建法
三张表的关系很清晰:一个学生同时只能有一条有效分配,一间房可以被多个学生占用,直到occupied追上capacity。这里的关键设计决策是——把“谁住哪”单独抽成allocations表,而不是在students表上挂一个room_id字段。
原因在于退宿。学生退宿后这条记录不应该消失,管理员要能查到“上学期谁住过 302”,这是审计需求。如果room_id直接挂在学生表上,退宿就是置空,历史信息就丢了。抽成独立表之后,退宿只是把status从active改成released,记录还在,房间占用数减一即可。
// models/room.js module.exports = (sequelize, DataTypes) => { const Room = sequelize.define('Room', { roomNo: { type: DataTypes.STRING(16), allowNull: false, unique: true, field: 'room_no' }, building: { type: DataTypes.STRING(32), allowNull: false, defaultValue: 'A' }, floor: { type: DataTypes.INTEGER, allowNull: false, validate: { min: 1, max: 30 } }, capacity: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 4, validate: { min: 1, max: 8 } }, occupied: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, genderType: { type: DataTypes.ENUM('male', 'female'), allowNull: false, field: 'gender_type' } }, { tableName: 'rooms', underscored: true, indexes: [ { fields: ['building', 'floor'] } // 看板按楼栋楼层筛选时走这个索引 ] }); return Room; };三个字段值得单独说。roomNo加unique,这是房间的唯一标识,重复插入会直接被数据库拒掉,比在应用层查一遍再插更可靠。capacity限定 1 到 8,超出的值一律拒绝,防止管理员手滑填个 40 导致后面超卖判断失效。genderType用ENUM而不是字符串,一是省空间,二是非法值在写入时就会报错,不用在业务层再写一遍白名单校验。
students表的重点是密码字段的命名——叫passwordHash而不是password,让每一个读代码的人都知道这里存的不是明文:
// models/student.js module.exports = (sequelize, DataTypes) => { const Student = sequelize.define('Student', { studentNo: { type: DataTypes.STRING(20), allowNull: false, unique: true, field: 'student_no' }, name: { type: DataTypes.STRING(32), allowNull: false }, email: { type: DataTypes.STRING(64), validate: { isEmail: true } }, passwordHash: { type: DataTypes.STRING(72), // bcrypt 输出固定 60 位,留点余量 allowNull: false, field: 'password_hash' }, gender: { type: DataTypes.ENUM('male', 'female'), allowNull: false }, role: { type: DataTypes.ENUM('student', 'admin'), allowNull: false, defaultValue: 'student' } }, { tableName: 'students', underscored: true, defaultScope: { // 默认查询不带出密码字段,避免不小心序列化进响应 attributes: { exclude: ['passwordHash'] } }, scopes: { // 登录校验时显式带上 withPassword: { attributes: { include: ['passwordHash'] } } } }); return Student; };defaultScope这一招很实用。不加的话,早晚会有一次res.json(student)把密码哈希吐到前端,虽然是哈希,但也没必要给。加上默认排除之后,登录接口用Student.scope('withPassword').findOne(...)显式拿一次,其他地方想拿都拿不到。
字段设计汇总一下:
| 表 | 关键字段 | 约束 | 作用 |
|---|---|---|---|
| students | student_no | UNIQUE, NOT NULL | 学号登录名,天然去重 |
| students | password_hash | NOT NULL, VARCHAR(72) | bcrypt 哈希,不存明文 |
| students | role | ENUM('student','admin') | 单表双角色,省一张权限表 |
| rooms | room_no | UNIQUE, NOT NULL | 房间唯一标识 |
| rooms | capacity / occupied | NOT NULL, 默认 4 / 0 | 满房判断的两个输入 |
| rooms | gender_type | ENUM | 男女混住校验的基准 |
| allocations | student_id / room_id | 外键 | 关联两张主表 |
| allocations | status | ENUM('active','released') | 退宿用软状态,不删记录 |
allocations表还有一个隐藏的坑:单纯加student_id的唯一索引不行,因为学生退宿后再入住会产生第二条active记录吗?不会,但历史记录会有多条released,唯一索引会冲突。常见的解法是加一个可空的active_flag列,入住时置 1、退宿时置 NULL,唯一索引建在(student_id, active_flag)上——MySQL 的唯一索引允许多个 NULL,所以历史记录怎么堆都不冲突,而同一个学生的两条active记录(active_flag = 1)必然被卡住。这层数据库兜底,比在应用层写findOne判断要可靠得多。
3.2 JWT 双角色中间件:学生和管理员走不同的门
鉴权这一层,我一般拆成两个函数而不是一个。verifyToken只负责“这个 token 是不是我们签的、有没有过期”,requireRole才负责“这个角色能不能进”。混在一起写的话,某天要给管理员加一个“可以访问但只读”的端点,就得把这个函数拆开重写。
// middleware/auth.js const jwt = require('jsonwebtoken'); function verifyToken(req, res, next) { const header = req.headers.authorization || ''; const [scheme, token] = header.split(' '); if (scheme !== 'Bearer' || !token) { return res.status(401).json({ message: '缺少访问令牌' }); } try { // payload 结构固定为 { id, role },role 只有 student / admin req.user = jwt.verify(token, process.env.JWT_SECRET); next(); } catch (err) { const expired = err.name === 'TokenExpiredError'; return res.status(401).json({ message: expired ? '登录已过期,请重新登录' : '令牌无效' }); } } function requireRole(...roles) { return (req, res, next) => { if (!req.user || !roles.includes(req.user.role)) { return res.status(403).json({ message: '无权限访问该资源' }); } next(); }; } module.exports = { verifyToken, requireRole };requireRole用剩余参数...roles而不是单个字符串,是为了支持后续加第三种角色(比如宿管),到时候requireRole('admin', 'warden')一行就够,不用改函数签名。401 和 403 的区分也很关键:401 是“你没登录”,前端应该跳登录页;403 是“你登录了但不够格”,前端应该弹提示而不是把用户踢出去。很多项目一律返 401,结果学生误点管理员链接就被登出了。
签发端在authController里,登录成功后把id和role塞进 payload:
// controllers/authController.js(登录部分) const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const { Student } = require('../models'); async function login(req, res, next) { try { const { studentNo, password } = req.body; if (!studentNo || !password) { return res.status(400).json({ message: '学号和密码不能为空' }); } // scope 把默认排除的 passwordHash 显式取回来 const stu = await Student.scope('withPassword') .findOne({ where: { studentNo } }); // 用户不存在和密码错误返回同一句话,不给撞库的人提供枚举信息 if (!stu || !(await bcrypt.compare(password, stu.passwordHash))) { return res.status(401).json({ message: '学号或密码错误' }); } const token = jwt.sign( { id: stu.id, role: stu.role }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRES || '2h' } ); res.json({ token, user: { id: stu.id, name: stu.name, role: stu.role } }); } catch (err) { next(err); } } module.exports = { login };jwt.sign的第三个参数expiresIn直接决定 token 生命周期。设太短,学生选房选到一半要重登;设太长,token 泄露的风险窗口就大。住宿分配这种场景 2 小时是合理的折中——够走完整个流程,又不是常年有效。payload里只放id和role,不放姓名、学号这些,因为 JWT 是 base64 编码不是加密,任何人都能解开看内容,别把它当保险箱。
3.3 Sequelize 关联查询:管理员看板一条 SQL 拿到全部信息
管理员最核心的诉求就一句话:“现在谁住在哪儿”。这句话翻译成数据库操作,就是以allocations为主表、active为过滤条件、关联出学生和房间信息。findAndCountAll一次返回数据和总数,省掉单独发一条count查询:
// controllers/adminController.js const { Allocation, Student, Room } = require('../models'); async function listOccupancy(req, res, next) { try { const page = Math.max(parseInt(req.query.page, 10) || 1, 1); const size = Math.min(parseInt(req.query.size, 10) || 20, 100); const { count, rows } = await Allocation.findAndCountAll({ where: { status: 'active' }, include: [ { model: Student, as: 'student', attributes: ['studentNo', 'name', 'gender'] }, { model: Room, as: 'room', attributes: ['roomNo', 'building', 'floor'] } ], order: [['allocatedAt', 'DESC']], limit: size, offset: (page - 1) * size }); res.json({ total: count, page, size, data: rows }); } catch (err) { next(err); } }size上限卡在 100,不是为了性能炫技,是防止有人手改 URL 传个size=999999把内存打满。order按入住时间倒序,管理员打开页面看到的是最新的记录,符合直觉。
attributes里显式列出需要的字段,而不是让 Sequelize 全查回来。学生表里排除了密码哈希,但email、created_at这些在看板上根本不用,多查一列就多一次网络传输,尤其findAndCountAll在分页时还要为count再发一条查询。
如果看板要加一个“各楼栋当前入住率”的小卡片,用sequelize.fn做聚合:
// 各楼栋入住率统计 const stats = await Room.findAll({ attributes: [ 'building', [sequelize.fn('SUM', sequelize.col('capacity')), 'totalBeds'], [sequelize.fn('SUM', sequelize.col('occupied')), 'usedBeds'] ], group: ['building'], raw: true // 不要模型包装,直接拿普通对象 });raw: true在这里是必须的,不加的话返回的是带dataValues的实例数组,聚合字段还得.get('totalBeds')才能取到,前端处理起来别扭。group只按building分,attributes里的其他字段也必须是聚合函数或被group的列,这是 SQL 的ONLY_FULL_GROUP_BY规则,MySQL 5.7 之后默认开启,写错了会直接报错而不是静默给个随机值。
4. 核心业务实现:一次选房请求从进入到落库的完整链路
4.1 选房接口:事务加行锁,把超卖挡在数据库层
整个项目只有这一个地方需要认真写并发。朴素的做法是先查房间、看occupied < capacity、再occupied + 1、再插入分配记录。问题在于“查”和“改”之间有一个时间窗口,两个请求同时进来,都读到3/4,都判断“还有空位”,都执行+1,结果就是5/4。开学季几百人同时点,这个窗口几乎必然被撞上。
解法是把“读”和“写”圈进一个事务,并在读的时候加行锁:
// controllers/allocationController.js const { sequelize, Room, Allocation, Student } = require('../models'); async function allocate(req, res, next) { const studentId = req.user.id; // 来自 JWT,不接受前端传 const { roomId } = req.body; const t = await sequelize.transaction(); try { // 1. 加排他锁读房间,别的请求在这一行上会等,不会读到中间的占用数 const room = await Room.findByPk(roomId, { lock: t.LOCK.UPDATE, transaction: t }); if (!room) throw Object.assign(new Error('房间不存在'), { status: 404 }); if (room.occupied >= room.capacity) { throw Object.assign(new Error('该房间已满,请换一间'), { status: 409 }); } // 2. 性别校验:房间是 male 就只能男生选 const stu = await Student.findByPk(studentId, { transaction: t }); if (!stu || stu.gender !== room.genderType) { throw Object.assign(new Error('该房间与你的性别信息不匹配'), { status: 400 }); } // 3. 写入分配记录 + 占用数自增,两步必须同生共死 await Allocation.create( { studentId, roomId, status: 'active', allocatedAt: new Date() }, { transaction: t } ); await room.increment('occupied', { by: 1, transaction: t }); await t.commit(); res.status(201).json({ ok: true, roomId }); } catch (err) { await t.rollback(); // 任何一步失败都整体回滚,不会留下半条记录 next(err); } }lock: t.LOCK.UPDATE生成的是SELECT ... FOR UPDATE,MySQL 会在这一行上加排他锁,第二个请求过来会阻塞在findByPk上,等第一个事务提交后才继续,此时读到的occupied已经是 1 了。这是最直接的串行化手段,代价是同一间房的请求排队,但不同房间互不影响,实际并发度足够。
studentId必须从req.user.id取,绝不能从前端 body 里读。否则改个请求体就能替别人选房,这是设计上最容易犯的低级错误。
increment而不是先读再save,是因为save会把整个模型所有字段写回,如果两个字段同时被改会互相覆盖;increment生成的是UPDATE rooms SET occupied = occupied + 1,是原子操作。
除了事务,前面提到的唯一索引兜底也值得加上。万一哪天有人绕过这个接口直接往库里写,或者事务隔离级别被人改动过,数据库层还能再挡一次:
// migrations/xxx-create-allocations.js(节选) await queryInterface.createTable('allocations', { id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, student_id: { type: Sequelize.INTEGER, allowNull: false, references: { model: 'students', key: 'id' } }, room_id: { type: Sequelize.INTEGER, allowNull: false, references: { model: 'rooms', key: 'id' } }, status: { type: Sequelize.ENUM('active', 'released'), defaultValue: 'active' }, // 入住置 1,退宿置 NULL,多个 NULL 不冲突,一个人只能有一条 active active_flag: { type: Sequelize.TINYINT, allowNull: true, defaultValue: 1 }, allocated_at: { type: Sequelize.DATE, allowNull: false } }); await queryInterface.addIndex('allocations', ['student_id', 'active_flag'], { unique: true, name: 'uk_student_active' });这一层索引的价值在于,即使业务代码写漏了判断,第二个active插入时会直接抛ER_DUP_ENTRY,错误被catch住回滚,数据依然是干净的。数据库的约束比代码里的 if 更可信,因为它不会因为某个新接口忘了写就失效。
4.2 管理员看板:分页查询与实时占用统计
管理员的第二个诉求是“哪栋楼还有空房”。这个查询可以直接落在rooms表上,用where条件筛出未满的房间:
// controllers/roomController.js async function listAvailable(req, res, next) { try { const { building, gender } = req.query; const where = { // 关键:列与列比较要用 sequelize.where + sequelize.col [Op.and]: [ sequelize.where( sequelize.col('occupied'), { [Op.lt]: sequelize.col('capacity') } ) ] }; if (building) where.building = building; if (gender) where.genderType = gender; const rooms = await Room.findAll({ where, order: [['building', 'ASC'], ['floor', 'ASC'], ['roomNo', 'ASC']] }); res.json({ total: rooms.length, data: rooms }); } catch (err) { next(err); } }这里有个 Sequelize 的语法陷阱:不能写where: { occupied: { [Op.lt]: sequelize.col('capacity') } },那样会被当成“和字符串capacity比较”或者直接报错。列与列比较必须用sequelize.where(sequelize.col('a'), op, sequelize.col('b'))包一层。这个写法不熟的话会卡很久,报错信息也不直观。
排序按楼栋、楼层、房间号三级,是因为管理员看列表时的心理模型就是“先找楼、再找层、再看房号”,顺序对上了,扫一眼就能定位。
学生端看到的列表还要额外做一件事——把已经分到房的学生挡在选房页之外。这个判断放在接口里而不是前端:
// 学生进入选房页前先查一次自己的状态 async function myAllocation(req, res, next) { try { const record = await Allocation.findOne({ where: { studentId: req.user.id, status: 'active' }, include: [{ model: Room, as: 'room', attributes: ['roomNo', 'building', 'floor'] }] }); res.json({ hasRoom: !!record, data: record }); } catch (err) { next(err); } }前端拿到hasRoom: true就不渲染房间列表,直接显示“你已分配到 A 栋 302”。这只是体验优化,真正的拦截在allocate里的唯一索引和事务判断上——前端可以绕过,数据库不能。
4.3 jQuery 前端:把后端错误原样展示给学生看
前端这一层最容易写歪的地方是错误处理。很多人写成$.ajax({...}).done(...),失败分支直接console.log,学生点完按钮页面毫无反应,以为卡了,又点了一次,结果触发两次请求。
正确的做法是统一在fail里读xhr.responseJSON.message,这是后端res.status(4xx).json({ message })约定好的结构:
// public/js/rooms.js $(function () { const token = localStorage.getItem('token'); if (!token) { location.href = '/index.html'; return; } const authHeader = { Authorization: 'Bearer ' + token }; function renderRooms(list) { const $tbody = $('#roomTable tbody').empty(); if (!list.length) { $tbody.append('<tr><td colspan="5" class="text-center">暂无可用房间</td></tr>'); return; } list.forEach(room => { const full = room.occupied >= room.capacity; $tbody.append(` <tr> <td>${room.roomNo}</td> <td>${room.building}</td> <td>${room.occupied}/${room.capacity}</td> <td>${full ? '<span class="badge badge-secondary">已满</span>' : '<span class="badge badge-success">可预订</span>'}</td> <td><button class="btn btn-sm btn-primary js-book" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />