简介:这套会议室预订预约小程序前后台源码,面向写字楼、高校、创业园等场景,提供小程序前端与PHP管理后台一整套实现。小程序端让用户免下载即可查看空闲会议室、选择时段并提交预订;后端由原生PHP构建,处理订单管理、资源维护、数据统计等核心逻辑,并支持二维码现场核销,参会者扫码验证身份,简化入场流程。资源包共1185个文件,约1021KB,含WXML、WXSS、JS、TS、JSON等,对应页面结构、样式、交互逻辑与配置信息,PHP后台及数据库相关文件也在其中,目录划分清晰,便于按模块学习。已有4151人学习浏览,适合有基础前端或PHP知识、想掌握小程序+后台开发流程的开发者。通过这套源码,可深入理解会议室预约业务逻辑、管理端功能设计及二维码核销实现,并能二次开发,快速搭建类似预约应用。
1. 会议室预订小程序:看起来像张表,跑起来全是状态机
一提到会议室预订,很多人第一反应是「这不就是一张预约表吗」,但真正动手写过一版的人都知道,这个需求是典型的「小功能、大坑位」。会议室要按楼层、容量、设备筛选,预约要避开时间冲突,取消要释放资源,超时未签到要自动回收,管理员还要能审批和统计——这些链路串起来,就是一个前后台完整的小型业务系统。
这篇文章拆的是一套会议室预订预约小程序的完整源码,用户端是微信小程序,管理端是 Web 后台,服务端提供接口和定时任务。适合两类人看:一类是刚接触小程序开发、想找一个有真实业务逻辑的完整项目来跑通全流程的新手;另一类是手里已经接了会议室、工位、设备这类预约需求,正愁怎么设计冲突校验和状态流转的开发者。下文我会从整体架构、数据库设计、核心代码、常见坑一路讲到二次开发,照着这份思路,你拿到源码后一个小时就能跑起来。
2. 整体架构:小程序端、服务端、后台管理端三者怎么协作
预约类业务有一个特点:核心逻辑不在界面,而在「状态管理」。从用户提交预约到会议室真正被使用,中间要经历待审批、已确认、已签到、已结束、已取消等多个状态,而且每个状态都有对应的资源释放动作。这一章先把架构讲清楚,后面看代码才不会迷路。
2.1 技术选型:为什么是原生小程序加 Node.js?
这套源码的技术栈选型走的是「稳妥路线」:小程序端用微信小程序原生语法,没有引入 uni-app 或 Taro,原因是预约类页面交互不算复杂,原生语法在真机调试时反而少一层编译问题;服务端用 Node.js 的 Express 框架,配 MySQL 数据库;后台管理端用 Vue 3 加 Element Plus 做页面。
我拆过很多类似项目,这个选型是比较接地气的。Node.js 做中间层,前后端都用 JavaScript,对小程序出身或前端背景的开发者非常友好——你不需要另学一门后端语言就能改接口。如果你所在团队是 Java 技术栈,把 Express 换成 Spring Boot 也不难,因为接口设计、数据库表结构是通用的,换的只是 Router 和 ORM 那层写法。
2.2 目录结构:拿到源码先看这几个文件夹
meeting-room-booking/ ├── miniprogram/ # 小程序端(原生微信小程序) │ ├── pages/ │ │ ├── index/ # 首页:会议室列表与筛选 │ │ ├── booking/ # 预约提交页 │ │ ├── my-booking/ # 我的预约列表 │ │ └── check-in/ # 扫码签到 │ ├── utils/ │ │ ├── request.js # 封装 wx.request │ │ └── auth.js # 登录态管理 │ └── app.js ├── server/ # 服务端(Node.js Express) │ ├── routes/ # 接口路由 │ ├── controllers/ # 业务逻辑 │ ├── models/ # 数据模型 │ ├── jobs/ # 定时任务(超时释放) │ └── app.js ├── admin/ # 后台管理端(Vue 3 + Element Plus) │ ├── src/ │ │ ├── views/ │ │ │ ├── RoomManage.vue # 会议室管理 │ │ │ ├── BookingAudit.vue # 预约审批 │ │ │ └── Statistics.vue # 使用率统计 │ └── vite.config.js └── sql/ └── init.sql # 建库建表脚本这套目录的最大优点是把「端」分得很干净:小程序、服务端、后台管理端三个目录互不嵌套,你只想跑后台管理端,单独npm install就行,不用被小程序配置文件干扰。服务端按 MVC 分层,controller 里写业务、model 里操作数据库,想加一个「会议室禁用时段」的功能,基本就是在 model 层加表、controller 加接口、小程序加页面的流程。
2.3 数据库设计:三张核心表与状态机
预约系统的表设计,核心是三张表:会议室表(meeting_room)、预约记录表(book_record)、用户表(user)。其中预约记录表是最关键的,它承载了全部状态流转。
CREATE TABLE `meeting_room` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL, `floor` INT NOT NULL COMMENT '所在楼层', `capacity` INT NOT NULL COMMENT '可容纳人数', `facilities` VARCHAR(255) DEFAULT '' COMMENT '投影/视频/白板,逗号分隔', `status` TINYINT DEFAULT 1 COMMENT '1可用 0停用' ); CREATE TABLE `book_record` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `room_id` INT NOT NULL, `user_id` INT NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `topic` VARCHAR(100) COMMENT '会议主题', `status` TINYINT DEFAULT 0 COMMENT '0待审批 1已确认 2已签到 3已结束 4已取消', `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX `idx_room_time` (`room_id`, `start_time`, `end_time`) ); CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) UNIQUE NOT NULL, `name` VARCHAR(20), `department` VARCHAR(50) );状态机说明:book_record 表的 status 字段是整个系统的支点。 0 待审批 → 1 已确认(管理员审批通过或免审批自动通过) 1 已确认 → 2 已签到(扫码签到) 1 已确认 → 4 已取消(用户取消) 1 已确认 → 3 已结束(会议时间自然结束) 2 已签到 → 3 已结束这里有个容易被忽略的参数:idx_room_time这个联合索引。线上跑预约系统,最频繁的查询就是「查某会议室在某时间段是否已被占用」,如果表里数据量过万,这个索引能直接把查询从全表扫描降成索引查找。另外 status 一定要用数字枚举而不是字符串,排序、统计、写条件查询都方便得多,后面讲后台统计时你会看到这个设计的好处。
3. 核心模块拆解:从选会议室到签到的完整链路
这一章直接进入代码。我会按用户操作路径来拆:首页列表、提交预约、取消预约、签到。每段代码都是单独可用的片段,你甚至可以先把它们摘出去,放到自己的项目里改改字段名就能用。
3.1 首页会议室列表:楼层筛选与动态容量过滤
首页的难点不在列表渲染,而在「筛选条件如何与预约状态联动」。比如某间会议室当前时段的预约已经满了,列表里应该置灰,而不是让用户点进去才发现约不了。
// miniprogram/pages/index/index.js async loadRooms(filter) { const { date, floor, capacity } = filter; // 传日期是因为很多会议室的占用是按天滚动的 const res = await request({ url: '/api/rooms', data: { date, floor, capacity } }); const rooms = res.data.map(room => { // 该会议室当天所有未取消的预约时段 const bookedSlots = room.booked_slots || []; room.isAvailable = bookedSlots.every(slot => { // 当前时间不在任何已预约的时段内,才算可订 return !isTimeInRange(new Date(), slot.start_time, slot.end_time); }); return room; }); this.setData({ rooms }); }capacity参数传的是最低容纳人数,后端会用WHERE capacity >= ?过滤;floor则直接等值匹配。这里的isTimeInRange是一个时间区间判断函数,小程序端做一次判断只是为了优化体验,真正的冲突校验在后端,前端这层防君子不防小人。
3.2 提交预约:前后端双重时间冲突校验
这是预约系统里最容易出 bug 的地方。时间冲突的数学本质是:两个区间[s1, e1]和[s2, e2]有重叠,当且仅当s1 < e2 && s2 < e1。很多人写反成s1 < s2 && e2 < e1,结果只能判断「完全包含」,漏掉了交叉情况。
// server/controllers/booking.js function isConflict(existing, startTime, endTime) { // 重叠判断:新预约开始时间 < 已有预约结束时间 // 并且新预约结束时间 > 已有预约开始时间 const hasOverlap = startTime < existing.end_time && endTime > existing.start_time; // 注意是严格小于,如果新预约开始时间和旧预约结束时间 // 完全重合,不算冲突,因为前一场会议到点就结束了 return hasOverlap; } async function createBooking(req, res) { const { roomId, startTime, endTime } = req.body; // 1. 参数合法性校验 if (new Date(startTime) >= new Date(endTime)) { return res.status(400).json({ msg: '开始时间必须早于结束时间' }); } // 2. 查询该会议室该时段所有未取消的预约 const existing = await db.query( `SELECT * FROM book_record WHERE room_id = ? AND status IN (0,1) AND start_time < ? AND end_time > ?`, [roomId, endTime, startTime] ); // 3. 有冲突直接拒绝 if (existing.length > 0) { return res.status(409).json({ msg: '该时段已被预订' }); } // 4. 无冲突则插入新预约 const result = await db.query( `INSERT INTO book_record (room_id, user_id, start_time, end_time, topic, status) VALUES (?, ?, ?, ?, ?, 0)`, [roomId, req.user.id, startTime, endTime, req.body.topic] ); res.json({ id: result.insertId }); }这段 SQL 里有个细节值得注意:查询条件只用了start_time < ? AND end_time > ?两个条件,而不是四个。这正好对应前面说的重叠公式,?分别传入新预约的endTime和startTime。加上status IN (0,1)是因为已取消的记录不该参与冲突判断——这是新手最容易漏的点,漏了就会导致用户取消预约后自己反而约不了。
3.3 取消预约:先改状态,再释放资源
取消逻辑看起来简单,实际翻车率很高。很多初版代码只把status改成 4 就完事了,但如果你在前端用「该会议室在某时段是否可订」来展示列表,这个状态就够用;可如果系统里还有「会议室占用数量统计」「门禁联动」「邮件通知」,那取消后同步释放这些资源才是完整的。
// server/controllers/booking.js async function cancelBooking(req, res) { const { bookingId } = req.params; // 开启事务:改状态和清理关联资源必须同时成功 const conn = await db.getConnection(); await conn.beginTransaction(); try { // 1. 只能取消待审批或已确认的预约 const updated = await conn.query( `UPDATE book_record SET status = 4 WHERE id = ? AND status IN (0,1)`, [bookingId] ); if (updated.affectedRows === 0) { throw new Error('该预约当前状态不可取消'); } // 2. 回写会议室状态(如果有占用标记) await conn.query( `UPDATE meeting_room SET occupy_flag = 0 WHERE id = (SELECT room_id FROM book_record WHERE id = ?)`, [bookingId] ); await conn.commit(); res.json({ msg: '取消成功' }); } catch (err) { await conn.rollback(); res.status(400).json({ msg: err.message }); } }事务在这里是必须的。取消动作涉及两步:业务状态变更和资源释放。如果不放在事务里,万一回写会议室状态时数据库报错,就会出现「预约状态已取消,但会议室仍被标记为占用」的脏数据。运营后台查出来这种数据,基本只能靠手工改库,属于血泪经验。
3.4 扫码签到:小程序调用摄像头识别会议室码
签到的实现方案有两种:一种是扫房间内张贴的二维码,另一种是管理员在后台生成动态签到码。这套源码用的是前者,二维码内容是会议室 ID,小程序端用wx.scanCode拿到 ID 后,请求签到接口。
// miniprogram/pages/check-in/check-in.js async scanAndCheckIn() { const result = await wx.scanCode({ onlyFromCamera: true }); const roomId = decodeRoomId(result.result); // 解析二维码中的会议室ID const res = await request({ url: '/api/booking/check-in', method: 'POST', data: { roomId } }); if (res.code === 0) { wx.showToast({ title: '签到成功', icon: 'success' }); } else { // 常见失败:当前没有该会议室的待开始预约 wx.showToast({ title: res.msg, icon: 'none' }); } }签到接口的核心逻辑是:找到当前用户当天、这个会议室、状态为已确认、且当前时间落在预约时段内的记录,把它改成已签到。如果找不到,返回的提示信息要明确区分三种情况——没预约、不在预约时段内、预约已取消。很多版本只返回一个「签到失败」,用户根本不知道是自己走错房间还是时间没到,这个交互细节决定了一个系统是否真正可用。
4. 后台管理端:审批流、统计与定时任务
后台管理端看起来只是 CRUD,但它承担了预约系统里最「重」的两件事:管理员人工审批和高频统计报表。这一章拆接口设计、定时释放任务和审批流三个部分。
4.1 接口设计:RESTful API 一览
POST /api/rooms 新增会议室 PUT /api/rooms/:id 修改会议室信息 / 停用 GET /api/rooms 会议室列表(含当日占用情况) POST /api/booking 提交预约 GET /api/booking/my 我的预约列表 POST /api/booking/:id/cancel 取消预约 POST /api/booking/:id/approve 审批通过 POST /api/booking/:id/reject 审批驳回 POST /api/booking/check-in 扫码签到 GET /api/admin/statistics 周/月使用率统计接口设计遵循一个原则:动作尽量用动词放在 URL 末尾,而不是在 body 里传 action 字段。比如审批通过和驳回,源码里拆成了两个接口,而不是POST /api/booking/:id/audit传 status。拆开的好处是参数校验逻辑各自独立,联调时也不容易把状态传错。booking/my这种带用户上下文的接口,通常通过请求头里的 token 解析出 user_id,不必额外传参。
4.2 审批流:管理员确认与免审批并行
这套系统的审批流有两种模式:全局免审批(提交即确认)和需要管理员审批。mode 开关放在系统配置表里,小程序端提交预约后请求接口,后台根据 mode 决定直接置为已确认还是保持待审批。
// server/controllers/booking.js async function submitBooking(req, res) { // ... 前面是冲突校验逻辑 const config = await getSystemConfig(); // 读取系统配置 const initialStatus = config.autoApprove ? 1 : 0; const result = await db.query( `INSERT INTO book_record (...) VALUES (?, ?, ?, ?, ?, ?)`, [roomId, req.user.id, startTime, endTime, topic, initialStatus] ); // 如果配置了自动审批,立刻通知小程序端预约成功 res.json({ id: result.insertId, status: initialStatus }); }很多中小团队在实际使用时会开自动审批,因为人工审批的延迟会导致用户体验变差——本来想约 10 点开会,提交后管理员 10 点才看到,会议早黄花菜了。所以源码里这个配置位很重要,二开时可以接成「非工作时间自动通过、工作时间转人工」的规则,后面改造章节我会细讲。
4.3 定时任务:超时未签到,自动释放会议室
预约了不来,是会议室资源浪费的最大来源。源码在服务端设置了一个 node-cron 定时任务,每五分钟扫描一次「已确认但已过开始时间 15 分钟且未签到」的预约,自动置为已取消并释放会议室。
// server/jobs/autoRelease.js const cron = require('node-cron'); function scheduleRelease() { // 每5分钟执行一次 cron.schedule('*/5 * * * *', async () => { const now = new Date(); const threshold = new Date(now.getTime() - 15 * 60 * 1000); // 找出已过开始时间15分钟、状态仍是已确认、未签到的预约 const expired = await db.query( `UPDATE book_record SET status = 4 WHERE status = 1 AND start_time < ? AND start_time > ? AND id NOT IN (SELECT booking_id FROM check_in_record)`, [threshold, now] ); if (expired.affectedRows > 0) { // 批量回写会议室释放,并记录日志 console.log(`[auto-release] 释放 ${expired.affectedRows} 条超时预约`); } }); }这段代码里start_time < ?的?传的是threshold(当前时间减去 15 分钟),意思是「开始时间已经过去了至少 15 分钟」;第二个start_time > ?传的是now,这个条件会让人困惑,实际上它是个约束保护——确保不会误处理掉未来日期的预约,虽然理论上start_time < threshold已经覆盖了,但加上它更保险。定时任务的坑通常在服务器时区,如果服务器配置成 UTC,new Date()会比北京时间慢 8 小时,凌晨执行的任务会在错误的时点触发,这点在第 5 章细说。
4.4 统计报表:使用率怎么算才不误导
后台统计页有一个很常见的设计陷阱:直接用count(*) / 总会议室数当使用率。真实业务里,会议室在非工作时段根本不该被预订,这种算法会把使用率拉得很低,老板看到报表会以为会议室利用率不足,然后要求行政多采购设备。
更合理的计算公式是:
使用率 = 实际被预订的时段总时长 / 工作时段总时长 工作时长 = 8:00 - 20:00,按 30 分钟粒度切分-- admin/BookingAudit.vue 对应接口的查询思路 SELECT room_id, SUM(TIMESTAMPDIFF(MINUTE, start_time, end_time)) / 60 AS booked_hours FROM book_record WHERE status IN (1, 2, 3) -- 已确认、已签到、已结束 AND start_time >= ? -- 统计周期起始 AND start_time < ? -- 统计周期截止 GROUP BY room_id;注意status IN (1, 2, 3)排除了「已取消」和「待审批」,前者不算真实占用,后者还没最终确定。如果你把待审批也算进去,报表里的数字会虚高——因为这些预约里可能有相当一部分最终被取消或驳回。
5. 避坑记录:预约系统开发中五个典型案例
预约模块的逻辑不复杂,但边界情况特别多。这一章整理我从源码和实际运行中整理出的高发问题,每一条都是真实踩过的,写出来给你们当后悔药。
5.1 并发重复预订:两个人同时提交同一时段
- 现象:同一会议室、同一时段,两个用户同时提交,前端都提示提交成功,后台出现两条冲突记录。
- 原因:接口层的 SELECT 冲突校验和 INSERT 插入之间有时差,两个请求都通过了 SELECT 校验,然后先后 INSERT 成功。传统的校验逻辑无法应对并发。
- 解决:需要在数据库层面加约束。最可靠的做法是让
(room_id, start_time)加上唯一约束,或者把冲突校验改成事务内SELECT FOR UPDATE锁行。源码里用了后者:
BEGIN; SELECT * FROM book_record WHERE room_id = 1 AND start_time < '2024-06-01 10:30:00' AND end_time > '2024-06-01 09:00:00' FOR UPDATE; -- 如果无冲突,执行 INSERT COMMIT;FOR UPDATE会把命中的行锁住,第二个事务必须等第一个事务提交才知道冲突,这就是"先到先得"。
5.2 时间边界误判:相邻时间段被当成冲突
- 现象:A 会议 9:00-10:00 结束,B 会议 10:00-11:00 提交时被提示「该时段已被预订」。
- 原因:冲突判断用了
<=和>=,把首尾相接的两个时段误判为重叠。预约系统里start_time < existing.end_time应该用严格小于,边界重合不算冲突。 - 解决:全局搜索后端所有时间比较逻辑,统一改成严格不等号。前端小程序的校验也要同步改,否则会出现前端提示成功、后端报冲突的诡异情况——用户会以为系统出 bug 了。
5.3 服务器时区不对,定时任务半夜释放会议室
- 现象:系统配置了「超时 15 分钟自动释放」,但每天早上 8 点用户发现当天早上的预约全部被取消了。
- 原因:服务器运行在 UTC 时区,
new Date()返回的是 UTC 时间,比北京时间慢 8 小时。凌晨 2 点(UTC)时,cron 任务扫描到当天早上 8 点的预约,发现「开始时间已过去 15 分钟」,就把它们全取消了。 - 解决:在服务端启动时强制设置
process.env.TZ = 'Asia/Shanghai',并且所有时间字段用 DATETIME 存取,不要用 INT 时间戳。MySQL 连接串里也要加timezone=+08:00,否则 ORM 写入的时间仍可能偏移。这是最容易翻车且排查成本最高的问题,建议项目初始化时就固定时区。
5.4 取消预约只改订单状态,会议室占用标记没释放
- 现象:用户取消预约后,后台「会议室占用」列表仍然显示该会议室被占用,导致其他人无法预订。
- 原因:取消接口只 UPDATE 了
book_record.status,没有处理meeting_room.occupy_flag,或者根本没有这个回写逻辑。如果列表页是直接实时查预约表,这个问题不大;但如果有一个独立维护的占用标记,就必须保证状态同步。 - 解决:方案一是在取消接口加事务回写;方案二更彻底——不维护额外的
occupy_flag字段,任何会议室状态都实时从book_record查出来计算,从根源上避免数据不一致。我一般推荐方案二,少一个状态字段,就少一种不一致的可能性。
5.5 微信登录态过期,用户提交预约时静默失败
- 现象:小程序挂后台半小时再打开,用户点提交预约,接口返回 401,前端没处理,一直转圈或提示「预约失败」。
- 原因:小程序的
wx.login获取的 code 换取的 session_key 有效期内,wx.request携带的 token 可能已过期。很多初版代码只在启动时登录一次,没有做 token 失效后的自动续期。 - 解决:在封装的
request.js里统一拦截 401 状态码,遇到后先静默调用wx.login重新换取 session,再用新 token 重放原请求。注意要做一个请求重放队列,避免多个并发请求同时触发重新登录。
6. 二次开发改造:状态机重构与第三方系统对接
到这一步,你手里的源码已经能跑通完整预约流程了。接下来聊改造思路,我给两个方向:一个治内部——把散落的魔法数字状态改成状态机;一个治外部——对接企业通讯工具的审批流。
先看状态机重构。book_record.status目前是 0 到 4 的整数枚举,分散在各个接口里判断。二开时建议把它收敛成一个状态表配置:
// server/models/bookingStatus.js const BOOKING_STATUS = { PENDING: 0, // 待审批 CONFIRMED: 1, // 已确认 CHECKED_IN: 2, // 已签到 FINISHED: 3, // 已结束 CANCELLED: 4 // 已取消 }; const TRANSITIONS = { [BOOKING_STATUS.PENDING]: [BOOKING_STATUS.CONFIRMED, BOOKING_STATUS.CANCELLED], [BOOKING_STATUS.CONFIRMED]: [BOOKING_STATUS.CHECKED_IN, BOOKING_STATUS.CANCELLED, BOOKING_STATUS.FINISHED], [BOOKING_STATUS.CHECKED_IN]: [BOOKING_STATUS.FINISHED] };改造收益:所有接口不再直接写 if (status === 0),而是调用一个 changeStatus(record, targetStatus) 函数,函数内部校验目标状态 是否在 TRANSITIONS 允许的转移列表里,不在就抛异常。这套重构的价值体现在两个地方:第一,新增状态(比如「已锁定」)时只改状态配置表,不用满文件搜索所有status ===判断;第二,非法状态迁移会被拦截,避免出现「已取消的预约被签到」这种逻辑漏洞。
外部对接方向,优先级最高的改造是审批流升级。源码里的自动审批只有开和关两个选项,二开时可以接企业通讯软件的自建应用——工作日提交的预约走自动通过,非工作日的转入人工审批队列,并推送通知给管理员。对接方式是在submitBooking里加一个规则引擎判断,规则为「工作日 AND 9:00-18:00 AND 房间非 VIP 专属」则自动通过,其余转人工。这样既保证效率,又保留了对重要会议室的控制。
最后说一个我自己的习惯。拆过三个预约类项目之后,我每次拿到新源码,做的第一件事永远不是跑npm install,而是先打开sql/init.sql看时间字段的类型——是 DATETIME 还是 INT,再看时区配置。因为这个坑一旦踩进去,日志全是乱的,你根本分不清是业务 bug 还是时间错位。从那以后我每接手一个预约或排班系统都强制先查这两处,省下的排障时间远超十分钟。希望帮到你。
本文还有配套的精品资源,点击获取