拓冰建站拓冰建站
首页 / 资讯中心 / 正文

微信小程序结合Node.js实现美甲店座位预约系统

简介基于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.jsconst 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:45statusTEXT预约状态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 classdate-picker picker modedate bindchangeonDateChange view classdate-current{{currentDate}}/view /picker /view view classseat-grid view classseat-card wx:for{{seats}} wx:keyid wx:for-itemseat view classseat-name{{seat.name}}{{seat.position}}/view view classslot-list view classslot {{item.available ? free : busy}} wx:for{{seat.slots}} wx:keytimeSlot >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本地网络不通。按表格逐项排查比反复重启项目要快得多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门