微信小程序结合Node.js实现美甲店座位预约系统
2026/9/16 2:14:45 网站建设 项目流程

简介:基于Node.js与微信小程序开发的美甲店铺座位预约系统,是一份适合毕业设计、课程设计及小程序入门学习的完整项目资源。项目围绕美甲店预约场景,实现了用户注册登录、座位预约、座位管理、顾客管理等核心功能,前端包含小程序用户端与Web管理后台,后端采用Node.js提供接口服务,配合MySQL数据库完成业务数据持久化。资源包共1252个文件,大小约29.67MB;其中vue与js文件构成前后端主体,wxml和wxss对应小程序页面结构与样式,png、svg、jpg等为界面图片资源,sql文件提供数据库初始化脚本,另有bat脚本用于一键部署,便于快速搭建本地运行环境。包内还保留了若干备份文件,可辅助理解功能迭代与排错思路。整体目录结构清晰,覆盖前端页面、后台接口、数据库设计等模块,能帮助读者快速掌握项目架构与预约业务流程。已有209人学习下载,适合需要参考完整源码、二次开发或学习Node.js与小程序前后端联调的开发者。

1. 预约小程序带上 Node.js 后端,不只是为了“能存数据”

美甲店座位预约,最麻烦的不是选座界面,而是“同一把美甲椅在同一小时到底被谁占了”。如果只做前端页面,用户 A 刚选完 14:00 的 1 号位,用户 B 刷新后依然能看到 1 号位可约,到店后就会出现两张预约单对应同一张椅子。Node.js 后端在这里的作用是当唯一的“状态中心”:保存座位、校验时间段、写预约记录、释放超时订单。这套方案适合有 JavaScript 基础、想独立完成前后端的开发者,也适合美甲店用一台轻量云服务器把预约业务真正跑起来。下面按环境、数据库、接口、小程序端、排错顺序,把最小可复现系统拆开写清楚。

2. Node.js 环境初始化与美甲座位表设计

预约系统对 Node.js 版本要求不高,LTS 版本足够。真正的坑集中在 npm 脚本、数据库选型和字段设计这三个地方,先把环境理顺再写代码,能省掉大量排错时间。

2.1 安装 Node.js 并处理“npm 脚本禁止运行”报错

从 nodejs.org 下载 LTS 版本,安装包一路默认即可。装完后打开 PowerShell 验证:

node -v npm -v

如果 node 能输出版本号,但 npm 报错无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本,这不是 npm 没装好,而是 PowerShell 默认禁止执行 .ps1 脚本。用管理员身份打开 PowerShell,执行:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

输入 Y 确认。RemoteSigned表示本地创建的脚本可以直接运行,从网上下载的脚本必须带有效数字签名,符合日常开发的安全预期。改完之后重新打开终端,npm -v就能正常输出版本号。

接着创建后端项目目录并安装依赖:

mkdir nail-reservation-server cd nail-reservation-server npm init -y npm install express better-sqlite3 cors

然后打开 package.json,把main改为app.js,在scripts里加一条"start": "node app.js"。这里选 better-sqlite3 是因为美甲店预约的并发量不大,它不需要单独安装数据库服务,生成的nail.db就是一个文件,备份、迁移都简单。如果安装 better-sqlite3 时出现 node-gyp 编译失败,常见原因是缺少 Visual Studio Build Tools;嫌麻烦可以直接换npm install mysql2,只需要把后面代码里的 Database 对象替换成 mysql 连接池。cors 中间件解决小程序真机调试时的跨域限制,express.json 用来解析 POST 请求体。

2.2 用 init.js 建出座位表和预约表

数据库文件用 better-sqlite3 创建,表结构是预约系统最核心的部分。新建 init.js:

const Database = require('better-sqlite3'); const db = new Database('nail.db'); db.exec(` CREATE TABLE IF NOT EXISTS seat ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT DEFAULT '', is_active INTEGER DEFAULT 1 ); CREATE TABLE IF NOT EXISTS reservation ( id INTEGER PRIMARY KEY AUTOINCREMENT, seat_id INTEGER NOT NULL, date TEXT NOT NULL, time_slot TEXT NOT NULL, customer_name TEXT NOT NULL, phone TEXT NOT NULL, status TEXT DEFAULT 'pending', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE(seat_id, date, time_slot) ); `); const count = db.prepare('SELECT COUNT(*) AS c FROM seat').get().c; if (count === 0) { const insert = db.prepare('INSERT INTO seat (name, position) VALUES (?, ?)'); insert.run('1号位', '靠窗'); insert.run('2号位', '过道'); insert.run('3号位', 'VIP室'); insert.run('4号位', '大厅'); } console.log('init ok');

执行node init.js。使用IF NOT EXISTS是为了让脚本能重复执行;插入座位前先判断数量,避免重复造数据。

2.3 数据表字段与时间粒度说明

reservation 表的关键字段如下:

字段类型说明
seat_idINTEGER对应的座位 id,逻辑上必须指向 seat 表
dateTEXT预约日期,按YYYY-MM-DD格式存字符串
time_slotTEXT时间段,例如14:00-14:45
statusTEXT预约状态:pending 待确认、confirmed 已确认、cancelled 已取消
create_timeDATETIME下单时间,用于释放超时未确认的座位

UNIQUE(seat_id, date, time_slot)三列唯一约束是防并发抢座的兜底方案。就算两个请求同时通过查询判断“座位空闲”,数据库也会让第二个 INSERT 报唯一约束冲突。time_slot 为什么不拆成开始时间和结束时间两个字段?因为预约粒度和店铺排班几乎总是固定长度,比如 45 分钟一档,用字符串反而方便前端直接展示,也方便后端用常量数组去校验。

3. 用 Express 把座位查询、预约、确认三个接口串起来

后端接口只做三件事:查可约座位、新建预约、确认预约。所有接口统一返回{ code, msg, data },小程序端拿 code 判断业务成功还是失败,比 HTTP 状态码更直观。

3.1 服务入口与时间段常量

创建 app.js,先写公共部分:

const express = require('express'); const cors = require('cors'); const Database = require('better-sqlite3'); const app = express(); const db = new Database('nail.db'); const TIME_SLOTS = [ '10:00-10:45', '11:00-11:45', '14:00-14:45', '15:00-15:45', '16:00-16:45', '19:00-19:45' ]; app.use(cors()); app.use(express.json()); function ok(data) { return { code: 0, msg: 'ok', data }; } function fail(msg, code = 1) { return { code, msg, data: null }; } app.listen(3000, () => console.log('nodejs reservation server on 3000'));

TIME_SLOTS 是所有预约的时间“格子”,必须和后端查询、校验使用同一份。如果后续店铺改排班,比如下午多加一个时段,只需要改这里,不需要改数据库。小程序端的时段列表也从这个接口返回,而不是在小程序里硬编码。

3.2 查询某个日期的可约座位

查询接口逻辑是:查出所有激活座位,再查出该日期下未取消的预约,把被占用的“座位 id + 时间段”放进 Set,最后给每个座位附上 availableSlots。

app.get('/api/seats', (req, res) => { const { date } = req.query; if (!date) return res.json(fail('缺少 date 参数')); const seats = db.prepare( 'SELECT id, name, position FROM seat WHERE is_active = 1 ORDER BY id' ).all(); const takens = db.prepare( `SELECT seat_id, time_slot FROM reservation WHERE date = ? AND status IN ('pending', 'confirmed')` ).all(date); const occupied = new Set(takens.map(t => `${t.seat_id}_${t.time_slot}`)); const data = seats.map(seat => ({ id: seat.id, name: seat.name, position: seat.position, availableSlots: TIME_SLOTS.filter(slot => !occupied.has(`${seat.id}_${slot}`)) })); res.json(ok(data)); });

这里状态过滤用了IN ('pending', 'confirmed'),意思是只要还没取消,座位就算被占。查询时不需要 JOIN 预约表,两次查询后在 JavaScript 里做组合,逻辑反而更直观,但默认带上了所有时段的展示,比如用户选了 10 点,那么 10 点的时段在这些座位的 availableSlots 中。对于不需要的时区,可以只过滤今天的日期,这里注意 date 校验必须严格,避免查询过去日期时所有座位都“可约”。可以在接口里加一句if (date < today) return fail('不能预约过去日期')

3.3 新建预约接口

POST /api/reserve 接收 seatId、date、timeSlot、customerName、phone。关键点在 try-catch 里捕获唯一约束冲突。

app.post('/api/reserve', (req, res) => { const { seatId, date, timeSlot, customerName, phone } = req.body; if (!seatId || !date || !timeSlot || !customerName) { return res.json(fail('缺少必填参数')); } if (!TIME_SLOTS.includes(timeSlot)) { return res.json(fail('非法的预约时间段')); } try { const info = db.prepare( `INSERT INTO reservation (seat_id, date, time_slot, customer_name, phone) VALUES (?, ?, ?, ?, ?)` ).run(seatId, date, timeSlot, customerName, phone); res.json(ok({ id: info.lastInsertRowid })); } catch (e) { if (e.code === 'SQLITE_CONSTRAINT_UNIQUE') { return res.json(fail('该座位在这个时间段已被预约', 1001)); } res.json(fail('服务端错误: ' + e.message)); } });

先检查TIME_SLOTS.includes(timeSlot)这一步容易被忽略。如果直接拿用户传的字符串去数据库比对,可能会有人绕过界面试探,存入不在营业时间内的数据。INSERT 的 catch 分支里,数据库唯一约束是最后一道防线,不要在插入前自己先 SELECT 一次再判断,并发场景下 SELECT 和 INSERT 之间有间隙,两个请求会同时提交成功。

3.4 店主确认预约接口

预约从用户提交到商家接单,中间隔一个“待确认”状态。确认接口:

app.post('/api/confirm', (req, res) => { const { reservationId } = req.body; if (!reservationId) return res.json(fail('缺少 reservationId')); const info = db.prepare( `UPDATE reservation SET status = 'confirmed' WHERE id = ? AND status = 'pending'` ).run(reservationId); if (info.changes === 0) { return res.json(fail('预约不存在或已被处理', 1002)); } res.json(ok(true)); });

WHERE id = ? AND status = 'pending'条件更新保证只有待确认的预约能被确认。如果订单已经取消,这条 SQL 不会影响任何行,接口返回 1002 而不是继续把状态改成 confirmed。这是一种轻量的状态机保护,比先 SELECT 再 UPDATE 更安全。

3.5 返回码与小程序端约定

code场景小程序端处理
0请求成功继续后续逻辑
1001座位时间冲突提示“该座位已被预约”,并重新拉取座位
1002预约不存在或已处理提示“预约已失效,请重新查询”
1缺参或服务异常直接弹出 msg 中的信息

实际项目中,小程序端 wx.request 的 success 回调拿到的 res.data 就是这个结构。失败时不要再根据自己的判断去给用户拼提示语,直接用后端 msg,这样后端改文案不用发版小程序。

4. 微信小程序原生实现座位面板与预约交互

小程序端不需要引入 uniapp 或 HBuilderX 的额外框架,原生微信小程序足够承接预约流程。项目目录里保证 pages/index 是第一顺位,这就是“修改刚进入的加载页面”的核心操作。

4.1 app.json 设置首页与导航栏

{ "pages": ["pages/index/index"], "window": { "navigationBarTitleText": "美甲座位预约", "enablePullDownRefresh": false } }

pages 数组第一项就是启动首页。如果项目里还有其他页面,比如订单记录页,需要把pages/index/index放在数组最前面。如果想在加载时显示 loading 动画,可以在 app.js 的 onLaunch 里调用wx.showLoading,在首页接口返回后调用wx.hideLoading,不要用wx.setNavigationBarTitle去替代 loading 提示。

4.2 座位网格与时段按钮的 WXML

页面结构分三块:日期选择器、座位网格、操作按钮。这里只展示座位核心部分:

<view class="date-picker"> <picker mode="date" bindchange="onDateChange"> <view class="date-current">{{currentDate}}</view> </picker> </view> <view class="seat-grid"> <view class="seat-card" wx:for="{{seats}}" wx:key="id" wx:for-item="seat"> <view class="seat-name">{{seat.name}}({{seat.position}})</view> <view class="slot-list"> <view class="slot {{item.available ? 'free' : 'busy'}}" wx:for="{{seat.slots}}" wx:key="timeSlot" >const apiBase = 'http://127.0.0.1:3000'; Page({ data: { currentDate: '', seats: [], selectedSeat: null, selectedSlot: '', customerName: '', phone: '' }, onLoad() { const today = this.formatDate(new Date()); this.setData({ currentDate: today }); this.loadSeats(today); }, formatDate(date) { const m = `${date.getMonth() + 1}`.padStart(2, '0'); const d = `${date.getDate()}`.padStart(2, '0'); return `${date.getFullYear()}-${m}-${d}`; }, loadSeats(date) { wx.request({ url: `${apiBase}/api/seats?date=${date}`, success: (res) => { if (res.data.code === 0) { const seats = res.data.data.map(seat => ({ id: seat.id, name: seat.name, position: seat.position, slots: seat.availableSlots.map(slot => ({ timeSlot: slot, available: true })) })); this.setData({ seats }); } } }); }, onSlotTap(e) { this.setData({ selectedSeat: e.currentTarget.dataset.seatId, selectedSlot: e.currentTarget.dataset.slot }); this.showReserveDialog(); }, submitReservation() { const { selectedSeat, selectedSlot, customerName, phone } = this.data; if (!selectedSeat || !selectedSlot) { wx.showToast({ title: '请选择座位和时间段', icon: 'none' }); return; } wx.request({ url: `${apiBase}/api/reserve`, method: 'POST', data: { seatId: selectedSeat, date: this.data.currentDate, timeSlot: selectedSlot, customerName, phone }, success: (res) => { if (res.data.code === 0) { wx.showToast({ title: '预约成功,等待商家确认' }); this.loadSeats(this.data.currentDate); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); } }, fail: () => { wx.showToast({ title: '网络异常,请检查 nodejs 服务', icon: 'none' }); } }); } });

loadSeats在 onLoad 时调用,也在 close 预约对话框后重新调用。注意 wx.request 是异步的,提交成功后必须等接口把 data 写入数据库后再重新 loadSeats,不能在 submitReservation 外面直接刷新,否则会读到旧数据。

4.4 真机调试时最容易踩的两个请求问题

手机真机访问后端,访问不了127.0.0.1,因为那是手机自己的回环地址。需要把 apiBase 改成电脑在局域网里的 IP,比如http://192.168.1.20:3000,并且手机和电脑连同一个 Wi-Fi。

现象原因处理方式
wx.request 一直 failapiBase 用了 localhost 或 127.0.0.1改成电脑局域网 IP,确认端口处于监听状态
真机预览时请求报“不在合法域名列表中”小程序后台没配 request 合法域名开发阶段在右上角打开“调试”模式,正式发布时配置 HTTPS 域名

开发工具里还要在“详情-本地设置”勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。这一步只影响调试,不解决生产环境域名配置。

5. 预约核心冲突:时间锁、取消释放与改座

预约系统的难点不在 CURD,而在“状态什么时候释放”。用户下单后如果只是 pending,座位会一直占着。所以必须有超时自动取消机制,也要有管理员手动改座的能力。

5.1 预约状态流转与超时释放

预约的状态只有三个:pending、confirmed、cancelled。用户提交后 15 分钟内商家未确认,系统应自动取消,释放座位。这个释放动作不需要依赖前端定时器,因为用户一旦退出小程序,前端定时器就失效了。更可靠的做法是在每次查询座位前执行一次“惰性清理”:

function releaseExpiredReservations(expireMinutes = 15) { const result = db.prepare( `UPDATE reservation SET status = 'cancelled' WHERE status = 'pending' AND create_time < datetime('now', ?)` ).run(`-${expireMinutes} minutes`); return result.changes; }

调用时机放在 /api/seats 接口的开头。这样就算服务器重启过,下一次查询座位时也会自动把历史超时订单清掉。datetime('now', '-15 minutes')是 SQLite 内置时间计算,不需要在 Node.js 里手动处理时区。

5.2 防冲突的两层保护

防重复预约必须靠两个层面:

  • 查询时的 Set 判断:只影响界面显示,不承担数据一致性;
  • 数据库 UNIQUE 约束:真正拦截并发插入。

不要在接口里用SELECT COUNT(*)先判断再插入。两个请求同时进来时,SELECT 都可能返回 0,然后都走到 INSERT,最后靠唯一约束保底。唯一约束冲突后返回 1001,前端刷新座位列表即可。

5.3 管理员换座接口

店里实际操作里,顾客可能临时要求换到靠窗位置,或者店主想把 VIP 室留给熟客。换座不能简单 UPDATE,必须检查新座位在同一个时间段是否空闲,并且排除当前预约自己:

app.post('/api/admin/change-seat', (req, res) => { const { reservationId, newSeatId } = req.body; const reservation = db.prepare( 'SELECT * FROM reservation WHERE id = ?' ).get(reservationId); if (!reservation) { return res.json(fail('预约不存在')); } const conflict = db.prepare( `SELECT id FROM reservation WHERE seat_id = ? AND date = ? AND time_slot = ? AND status IN ('pending', 'confirmed') AND id != ?` ).get(newSeatId, reservation.date, reservation.time_slot, reservationId); if (conflict) { return res.json(fail('新座位已被占用', 1001)); } db.prepare('UPDATE reservation SET seat_id = ? WHERE id = ?') .run(newSeatId, reservationId); res.json(ok(true)); });

这个接口必须放在独立的 admin 前缀下,并且实际上线时还需要加一层管理口令验证。AND id != ?是唯一容易忽略的点,不加的话,查询时会把预约自己也算作冲突,导致任何换座都失败。

5.4 时间段间隔与排班约束

TIME_SLOTS 的时间间隔决定了每天最大可预约单人。比如 45 分钟一个时段,10:00-10:45 和 11:00-11:45 之间留有 15 分钟清洁或者缓冲。如果店铺临时放假,可以在 seat 表加一个disabled_date关联表,或者直接在后端维护一个节假日数组。最简单的做法是给 reservation 表加一个唯一约束时,把特殊的闭店日期过滤出查询接口,不做溢出设计。

6. 上线前检查与 pm2 守护,附 npm 报错处理

部署到云服务器之前,先用本地真机做一轮完整验收。最值得测的用例是“两个手机同时点同一个座位”,你要看到其中一部手机返回 1001;第二重要的是断开 Wi-Fi 再提交预约,确认错误提示是“网络异常”而不是无响应。

6.1 用 pm2 让 Node.js 服务常驻

本地node app.js一关终端,服务就停了。云服务器上建议用 pm2 管理进程:

npm install -g pm2 pm2 start app.js --name nail-reserve pm2 save pm2 startup pm2 logs nail-reserve

--name给进程起别名,后续用pm2 restart nail-reserve重启;pm2 save把当前进程列表存下来;pm2 startup会生成一条开机启动命令,按输出内容在服务器上执行一次即可。pm2 logs能看到接口请求日志,前端反馈“预约没反应”时,第一件事就是来这里看有没有新请求进来。

6.2 高频报错速查

下表是预约项目从开发到上线最常遇到的四个问题:

你在做什么报错或表现解决办法
安装 Node 后执行 npmnpm.ps1 禁止运行脚本PowerShell 执行Set-ExecutionPolicy RemoteSigned
安装 better-sqlite3 失败node-gyp rebuild 报错安装 Visual Studio Build Tools,或改用 mysql2
真机请求 Node 服务不在合法域名列表中开发版打开右上角调试,发布版配 HTTPS 域名
提交预约后数据没刷新列表数据没变化确认在 wx.request success 回调内部调用 loadSeats

最后一条是异步执行顺序问题。wx.request 发出后,页面不会等接口返回再走下一行代码,所以刷新列表的调用必须写在 success 回调里,而不是写在 wx.request 外部。真机调试时看到request:fail后,先区分是-1(常见于 TLS 或域名问题)还是errno 600002(本地网络不通)。按表格逐项排查,比反复重启项目要快得多。

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

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

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

立即咨询