ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

微信小程序座位预约系统开发:Node.js后端与并发控制全解析

2026/9/15 19:08:48 拓冰建站 浏览量
微信小程序座位预约系统开发:Node.js后端与并发控制全解析 简介一份基于 Node.js 与微信小程序开发的美甲店铺座位预约系统源码面向毕业设计、小程序开发者及美甲店数字化管理场景。系统围绕用户注册登录、座位预约、座位状态管理与顾客信息维护等完整流程设计前端由微信小程序原生页面构成后端采用 Node.js 提供接口配合 MySQL 存储数据能够帮学习者理清预约类项目的前后端协作方式与数据库设计思路。资源包共 1252 个文件约 29.67MB涵盖 323 张页面/界面图片、229 个 JavaScript 逻辑文件、140 个 Vue 后台管理组件、96 个 WXML 页面结构、98 个 WXSS 样式文件及 SQL 数据库脚本另有启动脚本、备份文件和演示视频便于直接运行体验与按需修改。已有 209 人学习下载适合需要快速上手微信小程序预约功能、完成课设或拓展商业项目的开发者可在此基础上增加支付、消息提醒等模块二次开发空间充足。1. 从排队叫号到线上预约这套小程序要解决的不只是“选座位”美甲店和理发店不一样椅子数量少、服务时间长高峰期经常出现顾客到店干等半小时低峰期技师又空着。基于 nodejs 的美甲店铺座位预约微信小程序本质上是把“到店排队”改成“先约时间、再选座位”减少顾客流失也让店主能按预约批次安排技师。一个完整的该小程序项目通常由三部分组成前端是微信小程序原生页面后端是 nodejs 提供的 HTTP 接口数据存储在 MySQL 或 MongoDB 里。适合想快速抄作业的微信小程序开发者、有 Node.js 基础的独立开发者以及美业 SaaS 方向的产品经理参考。从一个可运行的 .zip 项目入手重点不是看它封好了多少页面而是把“座位状态流转”这条主线拆明白——可约、已约、锁定、取消、过期再补上接口设计这套架构能直接复用到美容、理发、推拿等所有工位预约场景。下面按“把项目跑起来 → 数据怎么设计 → 小程序怎么调接口 → 联调排错 → 并发验证”的顺序完整走一遍。2. 跑通环境nodejs 安装、npm 脚本限制与小程序项目结构拿到压缩包后第一步不是急着看代码而是确认本地 nodejs 环境能不能支撑项目启动。预约类小程序的后端一般会依赖 express 或 koa 框架外加 mysql、cors、jsonwebtoken 这些 npm 包如果 nodejs 版本过低装依赖时会报错或不兼容。2.1 nodejs 安装及环境配置的版本选择最常见的坑是装了太老的 Node.js比如 8.x 或 10.x然后跑一个依赖 ES2020 语法的新项目。当前主流预约项目后端建议直接装 Node.js 16 LTS 或 18 LTS这两个版本对 async/await、Optional Chaining、Array.flat()的支持都很稳定。安装完成后打开终端验证一下node -v npm -v如果输入命令后提示“不是内部或外部命令”说明环境变量没配好需要手动把 nodejs 安装目录和 npm 全局包目录Windows 下通常是%APPDATA%\npm加到 Path 里。提示不要用太新的奇数版本例如 17、19、21它们不是 LTS生产环境跑预约服务稳定性优先。2.2 npm : 无法加载文件 … 因为在此系统上禁止运行脚本解开 .zip 之后在项目根目录执行npm install时Windows PowerShell 用户经常会看到一个红色报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这是 PowerShell 的执行策略Execution Policy限制不是 nodejs 或 npm 本身坏了。原因是 npm 的ps1脚本默认不被允许在当前会话里运行。临时解决是给当前会话放开限制Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass如果希望以后每个终端都能直接跑 npm就用管理员身份打开 PowerShellSet-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned参数说明RemoteSigned表示本地创建的脚本可以运行从网络下载的脚本必须带有效数字签名。设置完可以再跑一下node -v和npm -v确认。2.3 微信小程序端的目录与入口逻辑后端依赖装完后把压缩包里的miniprogram文件夹导入微信开发者工具。一个典型的预约小程序前端目录结构如下目录/文件作用pages/index首页展示店铺信息与座位预约入口pages/booking预约页选择日期、技师、座位、时间段utils/request.js封装的 wx.request 请求函数统一处理 baseURL 和 tokenapp.js全局逻辑启动时调用wx.login换取 openidproject.config.json开发者工具的项目配置appid 和基础库版本在这里改小程序端要能正常编译最关键的是修改utils/request.js里的baseURL把它指向本机后端地址const baseURL http://127.0.0.1:3000/api/v1 function request(path, method GET, data {}) { return new Promise((resolve, reject) { wx.request({ url: baseURL path, method: method, data: data, header: { Content-Type: application/json, Authorization: wx.getStorageSync(token) || }, success: (res) { if (res.statusCode 200) { resolve(res.data) } else { wx.showToast({ title: 请求失败: res.statusCode, icon: none }) reject(res) } }, fail: (err) reject(err) }) }) } module.exports { request, baseURL }这段代码的逻辑在于所有接口调用统一走这一个 Promise 封装后端返回非 200 状态码时前端自动 Toast 提示避免每个页面重复写错误处理。Authorization头从本地存储中取出登录 token预约下单和取消预约的接口都需要这个字段来识别用户身份。注意微信开发者工具本地调试默认会拦截 http://127.0.0.1 以外的请求所以必须勾选右上角“详情 → 本地设置 → 不校验合法域名”否则请求直接 fail。3. 座位与预约的数据库模型设计从座位表到时间冲突校验预约系统的核心不是页面而是数据表怎么设计。座位、技师、时段、订单几个模型之间的关系没理清后面做冲突校验会非常痛苦。3.1 MySQL 建表脚本与字段含义常见做法是建四张表seat座位、technician技师、booking预约订单、user微信用户。下面给出一份最小可运行的建表脚本CREATE TABLE seat ( id INT PRIMARY KEY AUTO_INCREMENT, seat_name VARCHAR(30) NOT NULL, store_id INT NOT NULL DEFAULT 1, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可用 1维护中 2停用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE booking ( id INT PRIMARY KEY AUTO_INCREMENT, user_openid VARCHAR(64) NOT NULL, seat_id INT NOT NULL, technician_id INT DEFAULT NULL, booking_date DATE NOT NULL, time_slot VARCHAR(20) NOT NULL COMMENT 例如 10:00-11:00, status TINYINT NOT NULL DEFAULT 0 COMMENT 0已预约 1已取消 2已完成 3已过期, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_seat_slot (seat_id, booking_date, time_slot, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计上booking表里的uk_seat_slot唯一索引是防冲突的第一道防线它保证了同一个座位、同一天、同一时间段不会有两条状态为“已预约”的订单。status字段用 TINYINT 而不是 VARCHAR节省存储空间的同时查询时用WHERE status IN (0, 3)也能直接走索引。3.2 用 nodejs 写一个座位查询接口后端推荐用 express mysql2 连接池来写接口因为 mysql2 支持 Promiseasync/await 写起来比 callback 舒服一个量级。const express require(express) const router express.Router() const db require(../db) // GET /api/v1/seats?date2025-01-15 router.get(/seats, async (req, res) { const { date } req.query if (!date) { return res.status(400).json({ code: 400, msg: 缺少日期参数 }) } try { const [rows] await db.query( SELECT s.id, s.seat_name, CASE WHEN b.id IS NULL THEN 0 ELSE 1 END AS booked FROM seat s LEFT JOIN booking b ON b.seat_id s.id AND b.booking_date ? AND b.status 0 WHERE s.status 0 ORDER BY s.id, [date] ) res.json({ code: 0, data: rows }) } catch (e) { res.status(500).json({ code: 500, msg: e.message }) } }) module.exports router这段 SQL 的逻辑是以seat表为主表LEFT JOIN 预约表找到“该日期下状态为已预约”的记录然后用CASE WHEN b.id IS NULL判断是否已被占。关键参数date必须由前端传入且格式固定为YYYY-MM-DD否则 MySQL 的日期比较会出错。3.3 time_slot 的三种实现方案字符串、枚举、时间戳区间美甲预约的时间段通常按一小时切分比如 10:00-11:00、11:00-12:00。最直接的方式是time_slot直接存10:00-11:00这样的字符串查询时精确匹配。但字符串的缺点是没法做区间查询如果以后要支持“任意时长”的预约SQL 就要改。另一种方案是存两个 DATETIME 字段start_time和end_time预约冲突判断用SELECT id FROM booking WHERE seat_id ? AND status 0 AND start_time ? -- 新预约的结束时间 AND end_time ? -- 新预约的开始时间这个区间重叠公式一定要记牢start_time new_end AND end_time new_start等价于两个区间有交集。采用这个方案后booking_date其实可以用start_time的DATE()函数代替但保留独立字段有助于按天索引。方案优点缺点字符串 time_slot直观、适合固定时长无法跨时段合并查询开始/结束时间戳灵活支持任意时长前端展示要格式化时间段 ID 关联字典表便于做价格/技师维度扩展多一次 JOIN我一般建议直接用开始/结束时间方案因为美甲店后期一定会加“加急单”“情侣双人座”这种非标准时段现在用固定字符串后期改表成本很大。3.4 创建预约接口与事务控制用户提交预约时后端要做三件事检查座位是否存在、检查时段冲突、写入订单。这三步必须放在事务里否则并发时会出现两个用户同时约到同一个座位的情况。router.post(/booking, async (req, res) { const { seatId, technicianId, startTime, endTime, openid } req.body const conn await db.getConnection() try { await conn.beginTransaction() // 锁定座位行防止并发下同一座位重复预约 const [seatRows] await conn.query( SELECT id FROM seat WHERE id ? AND status 0 FOR UPDATE, [seatId] ) if (seatRows.length 0) { await conn.rollback() return res.status(400).json({ code: 400, msg: 座位不存在或已停用 }) } // 区间冲突检测 const [conflictRows] await conn.query( SELECT id FROM booking WHERE seat_id ? AND status 0 AND start_time ? AND end_time ? LIMIT 1, [seatId, endTime, startTime] ) if (conflictRows.length 0) { await conn.rollback() return res.status(409).json({ code: 409, msg: 该座位在所选时段已被预约 }) } await conn.query( INSERT INTO booking (user_openid, seat_id, technician_id, start_time, end_time, status) VALUES (?, ?, ?, ?, ?, 0), [openid, seatId, technicianId || null, startTime, endTime] ) await conn.commit() res.json({ code: 0, msg: 预约成功 }) } catch (e) { await conn.rollback() res.status(500).json({ code: 500, msg: e.message }) } finally { conn.release() } })SELECT ... FOR UPDATE是 MySQL InnoDB 的行级锁在事务内把该座位行锁住其他事务再对同一座位做预约时只能等待这个事务提交。conflictRows的检测利用 gap 锁配合唯一索引可以做到数据库层面的最终兜底。4. 小程序端预约页面的状态同步与加载交互后端接口写完之后小程序端要处理的核心问题有两个页面上座位格子的状态展示以及用户点击后的反馈反馈。4.1 用 wx.request 加载座位列表在pages/booking/booking.js里通过封装好的 request 模块拉取座位数据const { request } require(../../utils/request) Page({ data: { date: , seats: [], selectedSeatId: null, loading: false }, onLoad() { const today new Date() const dateStr ${today.getFullYear()}-${String(today.getMonth() 1).padStart(2, 0)}-${String(today.getDate()).padStart(2, 0)} this.setData({ date: dateStr }) this.loadSeats(dateStr) }, loadSeats(date) { this.setData({ loading: true }) request(/seats?date${date}, GET) .then((res) { this.setData({ seats: res.data, loading: false }) }) .catch(() { this.setData({ loading: false }) wx.showToast({ title: 座位加载失败, icon: none }) }) } })这里的loading状态很重要它用在 WXML 里控制加载动画避免用户在请求未返回时反复点击座位。注意setData是异步渲染的不能直接改this.data.seats再赋值必须通过this.setData触发页面更新。4.2 已约座位禁用态与点击状态切换在 WXML 中每个座位格子的 class 绑定三个状态view classseat-grid wx:for{{seats}} wx:keyid view class{{item.booked ? seat booked : (item.id selectedSeatId ? seat selected : seat free)}} bindtaponSeatTap >onSeatTap(e) { const id e.currentTarget.dataset.id const seat this.data.seats.find((s) s.id id) if (seat.booked) { wx.showToast({ title: 该时段已约满, icon: none }) return } this.setData({ selectedSeatId: this.data.selectedSeatId id ? null : id }) }dataset.id是从 WXML 的>confirmBooking() { if (!this.data.selectedSeatId) { wx.showToast({ title: 请先选择座位, icon: none }) return } const { date, selectedSeatId } this.data const startTime ${date} 10:00:00 const endTime ${date} 11:00:00 request(/booking, POST, { seatId: selectedSeatId, startTime: startTime, endTime: endTime, openid: wx.getStorageSync(openid) }).then((res) { wx.showToast({ title: 预约成功, icon: success }) this.loadSeats(this.data.date) }).catch((err) { const serverData err err.data if (serverData serverData.code 409) { // 座位刚被别人抢走本地标记为已约 const seats this.data.seats.map((s) s.id selectedSeatId ? { ...s, booked: true } : s ) this.setData({ seats, selectedSeatId: null }) } }) }这段处理的意义在于预约冲突不一定都是程序 bug更多时候是真实多人并发抢座。把本地状态直接置为已约比重新拉全量列表更快也避免用户反复尝试同一个座位触发后端锁等待。5. 前后端联调排错抓包、跨域与 Node.js 服务的进程管理从压缩包里的项目到真正能在真机上跑通中间隔着一堆环境问题。这一章挑三个最常被问到的点展开。5.1 开发环境跨域不校验合法域名与真机调试微信小程序对wx.request的跨域限制分两层开发工具里是“不校验合法域名”真机上必须是 HTTPS 且域名已在小程序后台配置。本地联调时后端 nodejs 服务必须开启 CORS 头最简单的方式是引入cors中间件const cors require(cors) app.use(cors()) // 手动指定也可以 app.use((req, res, next) { res.setHeader(Access-Control-Allow-Origin, *) res.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS) res.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization) if (req.method OPTIONS) return res.sendStatus(200) next() })OPTIONS预检请求在浏览器环境是自动发的但小程序客户端不会主动发预检不过保留这段处理可以让后端同时兼容 H5 端调试因为 H5 版的预约管理后台也会用到同一套接口。5.2 微信小程序抓包Charles 看请求响应参数前端报错时要看真实的后端响应常见做法是用 Charles 做中间人代理。配置思路是手机和电脑连同一个局域网在微信开发者工具的真机调试里把代理指向电脑 IP 的 8888 端口再安装 Charles 的 SSL 证书否则只能看到加密流量抓不到 HTTPS 的明文请求。抓包时重点核对三个信息request URL是否带上了/api/v1前缀、Authorization请求头是否为空、response body里的code字段。大多数预约失败的原因不是接口逻辑而是 openid 没传或者 token 过期。5.3 用 nodemon 和 pm2 管理本地与线上 nodejs 服务本地开发时改完代码不停重启 Node.js 是浪费时间nodemon可以监听文件变化自动重启npm install -g nodemon nodemon app.js线上部署则推荐用pm2预约服务属于长驻进程pm2 能处理崩溃重启和日志持久化pm2 start app.js --name nail-booking pm2 save pm2 logs nail-bookingpm2 save会把当前进程列表写入 dump 文件服务器重启后执行pm2 resurrect即可恢复服务。成熟的项目还会配合pm2 start ecosystem.config.js --env production用配置文件统一管理环境变量。5.4 排除 500 与 404 的检查清单错误现象可能原因定位手段接口 404express 路由没挂载或 URL 前缀不一致对比 request.js 中的 baseURL 和 router 挂载路径接口 500 且日志为空数据库连接池满了查看 mysql2 连接池的connectionLimit接口 500 显示 SQL 语法错误表名或字段名与建表脚本不一致在 MySQL 客户端执行同样的 SQL 做对照接口 200 但数据为空日期参数格式是 2025-1-5检查是否补齐为 2025-01-05很多情况下http://127.0.0.1:3000和http://localhost:3000都能访问但小程序端在部分安卓机型上解析 localhost 会指向手机本身所以客户端 baseURL 中写127.0.0.1比较稳妥。6. 用一个压力脚本验证预约并发冲突顺带清理过期订单进入上线前的最后一步验证并发冲突控制是否真的有效。打开一个终端用 nodejs 写一个并发抢座的小脚本模拟 20 个用户同时预约同一个座位const baseURL http://127.0.0.1:3000/api/v1 async function book(openid, seatId, startTime, endTime) { const res await fetch(baseURL /booking, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ openid, seatId, startTime, endTime }) }) const data await res.json() return { openid, status: res.status, code: data.code, msg: data.msg } } const tasks [] for (let i 0; i 20; i) { tasks.push(book(user_${i}, 1, 2025-01-20 10:00:00, 2025-01-20 11:00:00)) } Promise.all(tasks).then((results) { const succeeded results.filter((r) r.code 0) const conflicted results.filter((r) r.code 409) console.log(成功 ${succeeded.length} 条冲突 ${conflicted.length} 条) })预期结果是成功 1 条其余 19 条全部返回 409。如果多于 1 条成功说明事务隔离级别没设置好或者FOR UPDATE没有加在座位行上需要回头检查db.getConnection()是否真的拿到了同一个连接池里的新连接。除了并发验证还需要一个清理过期预约的定时任务。美甲预约的超时机制通常是到预约时间后 30 分钟未到店订单自动置为过期。UPDATE booking SET status 3 WHERE status 0 AND end_time NOW() - INTERVAL 30 MINUTE;这个 SQL 用时间条件批量更新把已结束超过 30 分钟的预约状态改为“已过期”。在 nodejs 中可以用node-cron包每小时执行一次npm install node-cronconst cron require(node-cron) cron.schedule(0 * * * *, async () { const [result] await db.query( UPDATE booking SET status 3 WHERE status 0 AND end_time NOW() - INTERVAL 30 MINUTE ) console.log(清理过期订单 ${result.affectedRows} 条) })0 * * * *是 cron 表达式表示每小时的第 0 分钟执行一次。清理过期订单的同时建议再跑一次DELETE FROM booking WHERE status 3 AND end_time NOW() - INTERVAL 90 DAY避免订单表膨胀导致查询变慢。把这段定时任务和并发验证脚本放进压缩包项目的根目录下次重启服务后预约系统才算真正闭环。本文还有配套的精品资源点击获取