1. 项目背景与整体设计思路
1.1 餐厅预约这个需求到底在解决什么问题
先说个真实场景。我以前在一家连锁餐饮品牌做技术顾问,门店高峰期前台电话基本没停过,顾客问得最多的就是"现在还有没有位置""需要等多久"。线下排队叫号系统又贵又笨重,小餐厅根本用不起。后来我们内部用uniapp给一家日料店做了个微信小程序预约系统,上线第一个月就减少了前台大概三成电话咨询量,这个项目后来也成了我做同类业务时反复复用的基础模板。
餐厅预约系统的核心痛点其实很集中:第一个是信息不对称,顾客不知道餐厅当前忙不忙、还有没有空位;第二个是预约流程割裂,有些店用电话、有些用第三方平台、有些直接让顾客到店排,体验非常碎片化;第三个是商家侧管理粗放,翻台率、预约转化率这些数据基本靠猜。我用uniapp做这套系统,目标就是用一套代码同时覆盖微信小程序、H5甚至App端,把"顾客查餐厅—选时段—预约—到店核销"和"商家看订单—管理桌台—统计营收"这两条链路彻底打通。
从技术角度看,这个项目很适合作为uniapp的练手案例,因为它几乎包含了小程序开发的全部典型模块:列表渲染、表单校验、日期时间选择器、地图定位、本地缓存、登录授权、接口请求封装、自定义组件、打包发布。你可以把它理解成一个小而全的"五脏俱全"项目,做完这一套,微信小程序开发的基本功就算扎实了。
1.2 为什么我坚持选uniapp而不是原生小程序
先说结论:如果项目只跑微信小程序一个端,原生小程序确实够用;但凡是有一点多端诉求,或者团队本身熟悉Vue语法,uniapp的性价比就远高于原生开发。这个餐厅预约系统是我从原生小程序迁到uniapp的,迁移过程中最大的感受就是不用写两套逻辑。
具体来说,uniapp的优势体现在三个层面。第一是语法层面,它基于Vue.js,单文件组件、计算属性、生命周期这些概念可以直接平移到小程序端,团队里会Vue的人几乎零成本上手。第二是组件和API层,uni.request、uni.navigateTo、uni.setStorageSync这些API封装了微信小程序的原始接口,一套代码编译到多端后不用改逻辑。第三是生态层面,uni-ui组件库里的uni-datetime-picker、uni-list这些直接拿来就能用,比自己写picker省事太多。
当然uniapp也不是没有代价。它的性能损耗是客观存在的,尤其是一些复杂的渲染需求,比如长列表、canvas绘图、低端安卓机上的动画效果,uniapp编译后的代码会比原生小程序多一层转换开销。还有一点是原生组件兼容边界,像地图、video这些组件在uniapp里虽然能用,但一旦涉及高级定制,你还是要回头去写条件编译。所以我的决策标准很简单:业务逻辑复杂但界面交互相对标准的项目,无脑选uniapp;如果项目核心卖点就是极致流畅的动画和手势交互,那就老老实实写原生。
1.3 系统整体架构和功能模块划分
我做的这个餐厅预约系统,从功能上拆分为两大端。用户端包含餐厅列表与搜索、餐厅详情(菜品展示、营业时间、地址地图)、在线预约(日期、时段、人数、桌型选择)、我的预约(预约记录、取消预约、核销状态)、个人中心(登录、手机号绑定、优惠券)。管理端我用了一个非常轻量的方案,没有单独做PC管理后台,而是直接在小程序内嵌了一个商家视角页面,通过角色权限控制菜单显示,这样小餐厅老板用手机就能处理预约审核和桌台管理,省去了服务器端再开发一套Web管理界面的成本。
后端我用的是Node.js + Express + MySQL的组合,部署在一台2核4G的云服务器上。为什么没选PHP或Java?因为Node.js跟前端的JavaScript语言栈统一,写接口的人不用切换语言思维,开发效率最高。数据库表设计上主要就是用户表、餐厅表、桌型表、预约订单表、预约时段表这五张核心表,后面我会详细展开表结构和字段设计。上传这个项目我公开了一份完整的接口文档和数据库SQL脚本,跟着文章一步步做就能把整个系统跑起来。
2. 项目初始化和基础配置
2.1 HBuilderX创建项目和manifest配置
搭建这个项目,我用的IDE是HBuilderX,它目前是uniapp官方推荐度最高的开发工具,内置了项目模板、模拟器和打包流程。如果你更习惯VS Code,也可以用vue-cli命令行方式创建项目,但微信小程序调试时还是要依赖微信开发者工具,来回切换不如HBuilderX一步到位。
创建项目的流程很简单:打开HBuilderX,选择 文件 - 新建 - 项目,在弹出的面板里选择"uni-app"模板,输入项目名和存储路径,点击创建就完成了。这里我提醒一个细节:模板选择不要用"默认模板",而是选"默认模板(Vue3)"或者根据团队技术栈选择Vue2版本。我这次用的是Vue2,原因很现实——项目里要用的一些老组件库对Vue3的支持还不完善,而预约系统这种CRUD型业务用Vue2完全够用,没必要追新。
项目创建完以后,最关键的是manifest.json配置。不少初学者在这个文件上翻车,最常见的问题是微信小程序appid填错导致预览失败,以及没有配置小程序权限声明导致定位、保存相册等功能静默失效。我的配置习惯是分三步:先填基础配置里的应用名称和appid,再在"小程序配置"里勾选requiredPrivateInfos需要的权限(比如getLocation用于地图定位),最后到"APP常用其它设置"里检查一下各平台的SDK配置是否齐全。还需要注意minified选项,发布时勾选代码压缩,包体积能小不少。
下面贴一份我常用的manifest.json关键片段,非敏感字段,可以直接参考:
{ "name": "餐厅预约系统", "appid": "__UNI__XXXXXXX", "mp-weixin": { "appid": "wx你的小程序appid", "setting": { "urlCheck": false, "es6": true, "minified": true }, "permission": { "scope.userLocation": { "desc": "获取位置用于展示餐厅地图导航" } }, "requiredPrivateInfos": ["getLocation"] } }2.2 项目目录结构和页面路由设计
我的目录结构是迁移过几个项目后固定下来的模板,核心思想是按业务模块分包,公共资源单独抽层。pages目录下按照首页、预约、订单、我的四个Tab模块拆分子目录,每个子目录里放各自的页面vue文件。components目录放自定义组件,比如餐厅卡片组件RestaurantCard、日期选择组件DateSelectBar。api目录统一管理所有后端接口请求,utils目录放工具函数和全局常量,store目录放Vuex状态管理。static目录存放静态图片资源,这个目录下还可以按平台分子目录,比如static/logo.png和static/tabbar/分别放不同用途的图片。
页面路由设计上,我用了pages.json的tabBar配置来管理底部四个主Tab页。这里有个坑我必须提一下:tabBar页面的icon图标不能放在static目录的深层子文件夹里,最好直接放在static/tabbar下,而且图片大小不能超过40kb。我之前把tabbar图标放在static/icons/目录下,小程序端怎么都不显示,排查了半天才发现是路径和尺寸问题。各Tab页的标题文字、选中态颜色也都在pages.json里配置,不需要在页面上写死。
2.3 状态管理和全局登录态处理
登录态是整个预约系统的地基,如果这里设计不好,后面所有涉及用户身份的操作都会出问题。我采用的方案是token + 本地缓存 + Vuex持久化三件套。用户首次进入小程序时,通过uni.login获取微信code,传给后端换取openid,后端生成一个token返回前端,前端把它存到uni.setStorageSync('token', res.token)里,同时写入Vuex的state中。之后的每个请求,在拦截器里统一在header中带上Authorization: Bearer ${token},后端通过token解析用户身份。
为什么不用uni.getUserInfo直接拿用户信息?这里要说明一下:微信官方早就调整了规则,getUserInfo弹窗获取用户昵称头像的能力已经被收紧了,现在的推荐做法是使用头像昵称填写能力,也就是用户主动点击授权按钮,调起chooseAvatar和input输入昵称,然后把用户填的头像昵称传给后端更新到用户表。我这个项目就是在个人中心页做了两个按钮,分别实现头像选择和昵称修改,用户完成一次手动填写后,后续就自动关联微信openid了,不需要重复授权。这样做的好处一是合规,二是用户体验更自然,不会一进来就弹窗吓到用户。
Vuex这边我做了持久化处理,写了一个简单的storage插件,在store的state变化时自动同步到uni.setStorageSync,每次App启动时再拉取本地缓存恢复Vuex状态。这样用户在杀掉小程序重进时,登录态不会丢,也不用每次都重新请求uni.login。
3. 首页餐厅列表与核心预约功能实现
3.1 餐厅列表的加载与渲染优化
首页是这个系统的门面,用户的第一个印象基本就在这上面。我做的餐厅列表是上拉加载更多 + 下拉刷新的分页结构,每页加载10条餐厅记录,按评分倒序排列。数据来源是/api/restaurants?page=1&pageSize=10,前端在onReachBottom生命周期里判断当前数据条数是否小于总数,继续请求下一页并concat到当前列表中。这里加载状态的展示很重要,我用了三种状态区分:首次加载显示全屏loading骨架屏,翻页加载显示底部"加载中",数据全部拉完显示"没有更多了"。
渲染性能方面,列表项如果直接用v-for渲染整个餐厅卡片组件,在低端安卓机上会出现白屏闪烁的现象。我的优化办法是给每个列表项加唯一的:key(必须是后台返回的餐厅id,不是数组index),同时餐厅卡片里的图片用lazy-load属性做懒加载,首屏只渲染用户看得到的几个卡片。还有一个细节是图片尺寸统一裁成750×420的比例,小程序端渲染时不用做二次缩放计算,能省去一部分CPU开销。
餐厅卡片组件RestaurantCard.vue的设计,我从UI角度也想得很清楚:左侧是餐厅海报大图,右侧上方是餐厅名称加认证标识,中部是评分和月售量,下方用两颗小tag展示"可预约"和"人均价格"。这个卡片上的所有数据都从props传入,不在子组件里直接发请求,保证组件的可复用性。如果你在后端返回的数据里额外带上了距离字段,还可以在卡片右下角显示"距你1.2km",这个对用户决策很有帮助。
3.2 预约流程设计的关键顺序
预约是这个系统的核心交易链路,设计得当能大幅降低用户的放弃率。最初我用的是一个"单页面直达预约表单"的思路,用户从餐厅列表点进详情页后直接看到一个预约表单,但实际测试下来发现成交率并不高。后来我调整了流程,改成**"选餐厅 → 选日期和时段 → 选桌型和人数 → 填写备注 → 确认提交"**五步引导式流程,每个步骤只让用户做最小的决策,整体体验清爽很多。
具体实现上,我建立了一个预约配置页BookingPage.vue,页面内部用currentStep变量控制当前步骤,配合一个顶部步骤条组件展示进度。日期这一步我用了uni-datetime-picker组件,但是限制只能选择当天起7天内的日期,这个限制在组件属性里可以通过start和end传入,不需要自己写判断逻辑。比较麻烦的是可选时段的判定——我希望实现的效果是:某个时段如果剩余座位数小于用户选择的人数,这个时段就置灰不可选。所以我在切日期的时候,会同步请求一次/api/reservations/slots?date=2024-01-15&restaurantId=xxx,拿到该餐厅当天的时段余量表,再渲染成时间按钮供用户点选。
时段数据结构我前后端约定好的是一个对象数组:
[ { "time": "11:30", "capacity": 20, "booked": 8 }, { "time": "12:00", "capacity": 20, "booked": 19 }, { "time": "12:30", "capacity": 20, "booked": 20 } ]前端渲染时对booked >= capacity的时段直接添加disabled样式和不可点击事件,用户当前选择的日期和时段状态会提交到Vuex的bookingInfo模块中,最后在确认页汇总展示,同时让用户填写用餐人数和备注。这一步我加了手机号输入框,虽然微信生态里经常可以直接拿到手机号,但我还是让用户手动填一个,因为预约通知短信要发给这个号码。
3.3 桌面型选择和预约提交逻辑
桌型选择这一块我踩过不少坑。一开始我直接让用户在四个桌型里单选,忽略了一个真实场景——晚餐时段高峰期,大桌往往已经满了,用户选了4人桌但系统又提示没位置,体验特别差。后来我把桌型选择和时段余量绑定在一起展示,例如在选择时段后下方显示"当前6人桌剩余3桌,建议选择6人桌更宽松"。这个信息很有用,能引导用户做出选择,也减少了商家端因桌位不足拒单的情况。
提交预约时的关键校验逻辑我放在前端做了两层。第一层在表单页,用uni.showToast提示必填项未填写,比如人数没有输入或者时段没有选择;第二层在确认页,提交前再对手机号格式做一次正则校验,正则用的/^1[3-9]\d{9}$/,校验不过就直接拦住不请求后端。前端校验做好之后,后端依然会做同样的参数校验,因为前端传过来的数据是不可信的,这一步不能省。
提交成功后页面跳转到"我的预约"列表页,新预约会排在最前面,状态为"待确认"。为什么是待确认而不是直接"已预约"?这背后是业务上对商家利益的保护——商家需要有时间确认桌台是否真的可用,如果直接确认了,后来店里来了熟客要订同一个时段,商家反而不好变通。所以我把状态机设计为"待确认 → 已确认 → 已完成"和"待确认 → 已取消"两条线,顾客取消则直接变为已取消,商家也可以操作取消,取消时填写原因。
4. 日期选择组件与时段数据交互
4.1 uni-datetime-picker的正确打开方式
日期选择是预约系统里使用频率最高的交互组件,我一开始直接用<picker mode="date">原生组件,样式简陋不说,限制可选范围还要自己拼日期字符串,烦得很。后来换成了uni-datetime-picker,虽然是uniapp生态里的老牌组件,但如果不懂它的特性,坑也不少。
先说uni-datetime-picker的核心特性,它支持三种模式:date(纯日期)、datetime(日期加时间)、dateTimeRange(日期时间范围)。预约场景我用的是date模式,配合start和end属性控制可选范围。这里有个容易忽略的点:start和end的格式必须是YYYY-MM-DD,如果你从接口拿到的日期是时间戳,需要先用uni.$u.timeFormat或者自己封装的时间函数转成这个格式再传进去。
还有一个常见的坑,就是v-model绑定日期值后,组件内部会缓存这个值,如果你中途通过uni.setStorageSync或者其他方式修改了默认值,组件可能不会立刻响应。解决办法是给组件加一个:key属性,强制它在日期变化时重新渲染。这个技巧我在多个项目里都验证过,确实可行。具体代码片段如下:
<uni-datetime-picker :key="datePickerKey" v-model="selectedDate" type="date" :start="startDate" :end="endDate" @change="onDateChange" />4.2 动态时段组件的前后端联动设计
日期和时段的关系是"先有日期,再有时段"的联动关系,所以前端在拿到用户选择的日期后,必须请求一次后端获取该日期下的可预约时段。这个接口我设计成了GET /api/reservations/time-slots,参数是restaurantId和date,返回的是当日时段列表及每个时段的可预约数量。
前端拿到数据后,我的做法是把时段数据存进Vuex的bookingInfo模块,而不是只存在预约页面的局部变量里。原因很简单,用户可能选择完时段后中途切到其他Tab再切回来,如果数据只在局部状态里,页面重建后时段就丢了,用户还得重新选一遍日期才能回来。存在Vuex配合storage持久化,就能保持住这个选择状态。
时段的UI呈现我用了一个横向滚动的胶囊按钮列表,每个胶囊显示时段的开始时间,下面小字显示剩余量。如果剩余量为0,胶囊变成灰色不可点,并且文案变成"已满";剩余量在5桌以内时文案变成"紧张",同时胶囊背景变成浅橙色提示用户尽快下单。这些交互细节看着小,但确实能在一定程度上影响用户的决策和转化。
4.3 日期状态同步的细节处理
预约系统里还有一类"看起来不复杂,实际很磨人"的问题,就是日期和时间的同步刷新。比如用户从首页的餐厅卡片上带着一个默认的预约日期参数跳转到预约页,这个日期是首页活动位传过来的,可能是三天后;但预约页的日期选择器默认值是今天,就导致用户看到的默认日期和实际要预约的日期不一致。我的解决办法是:进入预约页面时,先从Vuex里读一次预填数据,如果有就用预填数据初始化日期和时段,没有才用系统当前日期作为默认值。同时onLoad参数里如果有date字段也优先使用,优先级顺序为:路由参数 > Vuex预填数据 > 系统当前日期。这个优先级规则写清楚后,后续迭代加需求也不会乱。
另外提醒一个跨端兼容的细节,在微信小程序端Date对象解析"2024-01-15"这种带横杠的日期字符串时,在iOS上是Invalid Date,而在安卓上正常。这个坑相当隐蔽,很多人调试时用开发者工具看不出问题,一上真机iOS就崩。规避的方法是在工具函数里统一把横杠替换成斜杠:dateStr.replace(/-/g, '/'),再传给new Date()。这个bug我至少踩过两次,现在已经写进团队的utils代码注释里了,作为必查项。
5. 用户登录、授权和个人中心实现
5.1 登录态流程的完整梳理
个人中心页承担的功能不只是展示用户资料,还包括拉取我的预约列表、管理优惠券、联系客服等入口。所以这个页面的基础是可靠的登录态。我的登录流程设计有一点值得一提,就是按需触发登录而不是强制的"进门先登录"。用户浏览餐厅列表和餐厅详情都不需要登录,只有点击"我要预约"时才检查登录态,没有token就弹出登录引导,引导用户点击授权按钮完成登录。这样做的好处是降低了新用户的进入门槛,不会一进来就被登录页拦住。
登录弹窗我实现为一个自定义组件LoginPanel.vue,用uni.showModal的方式调起还是用半屏组件?这里我用的是半屏滑出的方式,底部弹层展示登录引导文案和"微信一键登录"按钮。点击按钮后执行uni.login获取code,然后调用后端/api/auth/login接口。后端接收到code后调用微信的code2Session接口换取openid,再查询用户表,如果不存在则插入一条新记录,最后返回token和用户基础信息。
关于获取手机号,微信小程序提供了一个getPhoneNumber能力,用户点击按钮会弹出授权窗口,同意后前端可以拿到加密的手机号数据。这个能力现在已经改版过,不再直接返回明文手机号,需要后端调用接口解密。我的做法是,登录时先用code拿到token和基础用户信息,手机号在用户主动点击"绑定手机号"时才获取,保证用户隐私的同时也让业务流程更清晰。
5.2 头像昵称填写能力的接入
头像昵称这块,我再说得细一点。新版微信要求昵称输入框不能固定显示用户微信昵称,而是由用户主动输入;头像也不是直接getUserInfo能拿到的,需要用户在button上触发chooseAvatar事件,选择图片后才能把图片临时路径传到后端存储。我在ProfilePage.vue里的实现是:
- 头像位置一个圆形按钮,绑定
open-type="chooseAvatar",选择完成后触发@chooseavatar事件拿到临时文件路径; - 昵称位置一个
input输入框,用户手动输入自己的昵称; - 点击"保存"按钮时,先把头像临时文件通过
uni.uploadFile上传到服务器的/api/upload接口,拿到图片URL后再连同昵称一起调用/api/user/update更新用户信息。
这个流程比较贴合官方规范,实测在微信开发者工具和真机上都能跑通。要特别注意的一点是,真机上chooseAvatar拿到的临时路径在页面刷新之后会失效,所以必须在拿到路径后马上上传,不要等用户填完昵称再一起处理。我当时没注意这个问题,测试时发现iOS上头像偶发显示白屏,就是因为临时缓存被系统清了,后来改成选择后立即上传才解决。
5.3 我的预约列表和状态展示
"我的预约"页是整个预约链路闭环的收尾。页面上半部分是几个状态tab,按下拉菜单的方式筛选:全部、待确认、已确认、已完成、已取消。每个tab对应一个接口查询条件,后端SQL里就是WHERE status = ?,非常简单。列表项展示关键字段:餐厅名称、预约日期、时段、人数、桌型、当前状态和操作按钮。
操作按钮根据状态动态渲染是很有讲究的。待确认状态下要突出"取消预约"(用暗红色文字按钮),已确认状态下要显示"取消预约"和"联系商家"两个按钮,已完成状态则显示"评价一下",点进去可以预约餐后评价。这个交互设计的核心是让用户在正确的时间做正确的事,不要出现已完成状态下还让用户点"取消预约"这样的逻辑矛盾。前端在渲染按钮时用v-if判断状态字段,后端接口返回的数据中也带上可以执行的操作类型数组,这样前后端逻辑保持一致,不会出现按钮和接口能力不匹配。
状态变更后需要刷新列表的问题,我用了一个事件总线方案:取消预约成功后uni.$emit('refreshReservationList'),列表页在onLoad时监听这个事件,收到后重新请求接口。移动端页面栈管理比较特殊,用uni.$emit比Vuex或者直接调用父组件方法更直接,也不容易产生数据源头不统一的问题。
6. 后端接口设计与数据表结构规划
6.1 预约系统的数据库表设计
后台数据表我设计得很精简,五张核心表而已。第一张users表,字段包含id、openid、nickname、avatar_url、phone、created_at。openid字段要有唯一索引,用户登录时通过openid查用户是否存在,这个索引能极大加快查询速度。avatar_url存的是用户上传头像后的CDN地址,不是临时路径,这个点前面提到过,再次强调是因为我在开发初期就是没注意,导致头像大量失效。
第二张restaurants表,字段有id、name、cover_image、address、latitude、longitude、rating、avg_price、business_hours、status。latitude和longitude是餐厅的经纬度,后续做地图导航需要。status用来控制餐厅是否接受新预约,餐厅内部维护桌台时可以临时关闭。
第三张table_types表,存桌型配置,字段有id、restaurant_id、name(如"4人桌")、capacity(该桌型人数)、quantity(该桌型总桌数)、price为可选字段用于预留押金场景。这张表和第四张reservations表通过table_type_id关联,一次预约只关联一个桌型,但一个桌型可以对应多条预约记录。
第四张reservations表是整个系统的核心,字段包括id、order_no(预约单号,用时间戳加随机数生成)、user_id、restaurant_id、table_type_id、reserve_date(预约日期)、reserve_time(预约时段,这个项目我把它单独存了字符串,简单直接)、people_count、remark、status(0待确认、1已确认、2已完成、3已取消)、created_at、updated_at。这张表加两个复合索引:(restaurant_id, reserve_date, reserve_time)用于商家端查当日各时段订单,(user_id, status)用于查询用户预约列表。
第五张reservation_slots表是我为了做时段余量统计额外加的,字段有id、restaurant_id、reserve_date、reserve_time、total_capacity、booked_count。这张表的作用是预计算每天每个时段的可用量,避免写复杂的SQL实时count。为什么不做实时count?因为预约高峰时段并发量大,实时count性能差,而且统计逻辑稍微复杂就容易算错。用这张表的好处是,每次预约成功或取消时,只需UPDATE reservation_slots SET booked_count = booked_count + 1 WHERE ...,拿总量判断是否还有余量,性能和准确性都有保障。
6.2 接口设计规范和请求封装
接口设计我遵循RESTful风格,核心接口有这几个:
POST /api/auth/login:微信code登录,参数是code,返回{ token, userInfo }GET /api/restaurants:分页获取餐厅列表,参数是page、pageSizeGET /api/restaurants/:id:获取餐厅详情GET /api/restaurants/:id/time-slots?date=xxx:获取指定日期各时段余量POST /api/reservations:创建预约,参数是restaurantId、tableTypeId、date、time、peopleCount、remarkGET /api/reservations:获取当前用户的预约列表,参数是statusPUT /api/reservations/:id/cancel:取消预约
接口的请求封装我统一放在了api/request.js里,使用uni.request封装一个http对象,暴露出get、post、put等常用方法。这个封装的核心逻辑是拦截器:请求前从uni.getStorageSync('token')里取出token加入header;响应后统一判断statusCode和返回数据的code字段,如果是401则清空登录态并跳转登录;如果是业务错误如"该时段已被约满",则用uni.showToast展示后端返回的错误信息,不需要每个页面重复写错误弹窗。
这里我特别说一下接口失败重试的机制。预约提交这类幂等性不强的请求,我不建议做自动重试,否则用户多点一次按钮可能产生两条预约单。我的解决办法是加了一个"提交中"状态,按钮在请求期间置灰并显示loading文案,请求结束后根据结果重置。如果有用户反馈偶尔出现重复预约的极端情况,后端还需要在创建预约逻辑里对同一用户、同一餐厅、同一日期的待确认和已确认记录做一次去重校验,从数据层面兜底。
6.3 预约余量的并发处理
高并发预约场景下,余量扣减的安全性是一个大问题。最经典的坑就是超卖:两个用户同时看到某个时段余量还剩1桌,同时提交预约,结果两个人都成功下单了,可店里实际只有1桌。如果只在应用逻辑里先查后改,在高并发下一定会出问题。
我的处理方案很简单但很有效:扣减放在一条SQL的原子操作里完成。例如:
UPDATE reservation_slots SET booked_count = booked_count + 1 WHERE restaurant_id = ? AND reserve_date = ? AND reserve_time = ? AND booked_count < total_capacity执行这条SQL如果影响行数为0,说明没有余量了,前端就提示"该时段已被约满"。因为UPDATE在数据库层面是行级锁的,同一时刻只有一条事务能成功修改同一行的数据,天然避免超卖。这个方案实现成本低,正确性高,非常适合餐厅预约这种并不算极高并发的业务。如果你的预约量级到秒杀级别,可能需要引入Redis分布式锁或者消息队列,但那个复杂度就不适用于这种小体量项目了。
事务方面,创建预约我要保证两步操作的一致性:往reservations表插入订单,同时把reservation_slots表的booked_count加1。这两步要么都成功,要么都失败。Node.js中使用MySQL的transaction连接池来包裹这两条SQL,任何一个失败就rollback,全部成功才commit。这里还要提醒一个细节:如果是用户取消预约,需要把booked_count减1,但要注意不能减成负数,所以SQL里要加AND booked_count > 0条件。
7. 常见问题排查与性能优化笔记
7.1 小程序端接口请求失败的排查套路
这个系统开发中有个高频问题:在H5端调试接口一切正常,编译到微信小程序端请求就失败了。排查思路我整理成了一套固定流程,项目中我基本照着走就能定位。第一步看微信开发者工具的Network面板,确认请求有没有发出去,如果没发出去,检查manifest.json里的appid是不是正确的。第二步看请求是否被拦截,微信小程序有域名白名单校验,开发阶段可以在详情 - 本地设置里勾选"不校验合法域名",上线前则必须把接口域名加到小程序后台的request合法域名里,并且域名必须是HTTPS。第三步看返回状态码,如果报url not in domain list,那是域名白名单问题;如果报request:fail,往往是证书过期或不是有效证书;如果报500,那就是后端接口自身的问题,去后端日志里看具体报错。
这类问题看起来很基础,但实际排查时很容易绕弯路。我建议在开发阶段就用真机调试,别老在开发者工具里试,因为开发者工具对域名校验的策略跟真机不完全一样,一些请求发到真机上才暴露问题。再有就是要给后端的接口加统一的请求日志中间件,输出请求路径、参数、响应状态和耗时,排查起来一目了然。
7.2 小程序端图片显示和尺寸适配问题
图片问题是uniapp跨端开发的老大难。我在餐厅列表轮播图和头像展示上都踩过坑。第一个坑是图片路径问题:在H5端直接引用本地静态图片正常,但微信小程序端不能直接引用static目录下的图片作为background-image的内联样式,必须用image标签的src属性引用。第二个坑是图片尺寸问题,小程序端的image组件默认有width: 320px; height: 240px的内置宽高,如果不对image设置mode属性,大图会被强行压缩变形。我常用的mode是aspectFill,保证图片裁切后铺满容器,配合lazy-load做懒加载,基本能满足大部分场景。
轮播图组件swiper在小程序里的高度默认是150px,但如果你的设计稿要求轮播区高度是300px,必须给swiper设置一个明确的高度,同时内部的swiper-item里的image也要设置width: 100%; height: 100%。不设置的话,会出现安卓正常、iOS图片被裁掉一半的诡异现象,原因就是iOS的swiper高度计算逻辑和安卓有差异。
还有之前我在开发时遇到的"轮播图安卓有黑边"问题,这个在uni-app社区里也是一个高频坑。原因是swiper的circular属性在安卓端某些基础库上会多渲染一帧,露出背景色。解决办法是给swiper外层容器设置和图片同色系的background-color,或者给swiper-item里的image设置border-radius和overflow: hidden,从视觉上掩盖黑边。虽说不算根治,但实际效果能接受。
7.3 微信小程序基础库版本兼容策略
不同用户手机上的微信客户端基础库版本差异很大,如果不处理版本兼容,一些API在旧版本上直接调用会报错或者静默失效。我的策略分三层。第一层是在manifest.json里配置mp-weixin的libVersion为当前微信团队推荐的稳定版本,保证编译时目标是新版本;第二层是在关键API调用前做能力检测,比如:
if (wx.getSystemInfoSync().SDKVersion) { // 调用新版API } else { // 走降级逻辑 }第三层是针对已知的版本bug做条件编译处理,比如之前提到的轮播图黑边问题在某个基础库版本上特别明显,我就用#ifdef MP-WEIXIN加了一段版本判断的样式补齐逻辑。基础库版本的设置在开发者工具里的"详情-本地设置-调试基础库"可以快速切换测试,我建议至少测一遍最近的三个稳定版本,因为用户群体的版本分布没你想的那么集中。
7.4 包体积优化和首屏加载提速
微信小程序对主包体积有2M的限制,超出后无法上传,这是我做这个项目时最实实在在的压力。项目刚做完时主包接近2.5M,超限了,我用了三个办法终于压到1.6M。
第一是图片压缩和CDN化。所有本地静态图片能合图的合图,能压缩的压缩。小程序里其实很少需要真正的PNG透明大图,大部分UI素材用JPG或者WebP格式就行,一个按钮图标压到几KB完全没问题。餐厅封面图这种数据类图片我全部放到服务器CDN上,不打包进小程序包里。
第二是组件按需加载和分包处理。我把不需要首次加载的页面,比如评价页面、优惠券页面、商家管理页面,都放到了subPackages分包里。分包可以放到发布时单独加载,不占用主包体积,但要注意subPackages页面之间的跳转不能直接写绝对路径,必须用相对路径或URL带参方式跳转。
第三是配置lazyCodeLoading。这是个非常有效的性能优化项,只需要在manifest.json里配置"lazyCodeLoading": "requiredComponents",小程序端会按需注入页面需要的组件代码,而不是一开始就全量加载。这个配置对首屏加载速度的提升非常明显,我实测首屏渲染时间能缩短30%左右,强烈建议打开。
8. 打包发布与后续扩展建议
8.1 微信小程序端的打包发布全流程
编译发布微信小程序端的流程很固定。在HBuilderX工具栏选择"运行-运行到小程序模拟器-微信开发者工具",HBuilderX会把uniapp项目编译成微信小程序原生代码,输出到一个unpackage/dist/dev/mp-weixin目录,同时自动唤起微信开发者工具打开这个目录。在开发阶段你可以在微信开发者工具里实时预览和调试,断点、看网络请求都没问题。
测试完成后要发布,在HBuilderX里点击"发行-小程序-微信",先把代码做一次生产环境编译,然后同样打开微信开发者工具,在工具右上角点"上传"版本,填写版本号和备注后提交到微信公众平台。接着登录微信小程序后台,在"版本管理"里找到提交的开发版本,先设为体验版让测试人员验证,确认没问题后再提交审核,审核通过后就可以发布上线了。整个流程虽然看起来步骤多,但走一遍就熟了,核心是要注意提交的是生产编译包,而不是开发编译包,否则有些环境常量没切到生产值会出bug。
8.2 安卓App打包与上架应用的注意要点
虽然这个项目主要面向微信小程序,但uniapp的价值就是可以顺便打包成安卓App。HBuilderX支持云打包和本地打包两种方式,免费账号用云打包比较方便,但打包次数有限制。云打包时需要配置App的包名(如com.example.restaurant)、图标、启动页图片,以及最重要的——安卓证书。证书可以用Android Studio生成,也可以用一个在线工具生成,生成的.keystore文件要妥善保存,密码要记牢,因为后续更新版本时使用同一个证书才能覆盖安装。
上架安卓应用市场时,我踩过比较大的坑是隐私合规检测。现在各大市场对上架App的隐私政策审核非常严格,如果App里涉及定位、存储权限,但没有展示完整的隐私政策弹窗,基本会被驳回。我的做法是:启动时用一个自定义弹窗展示隐私政策摘要,用户点击"同意并继续"后才初始化SDK和请求权限,同时把完整的隐私政策链接放在"设置"页里。还有一个坑是Android的权限声明,如果你在代码里用到了定位,但在manifest.json里没有声明ACCESS_FINE_LOCATION权限,运行在部分机型上会直接崩溃闪退。
8.3 项目后续可以怎么扩展
这个系统做完第一版后,我给自己规划了两个后续迭代方向。第一个是智能推荐,根据用户的预约历史偏好,在首页推荐符合他口味的餐厅和时段,这个可以利用协同过滤或者简单的标签匹配算法,后面有精力了可以把用户的历史预约记录做一个画像分析。第二个是预约营销工具,比如预约后自动发放优惠券、邀请好友同行得折扣、预约到店提醒推送,这些营销玩法能明显提升用户粘性和复购率。
另外技术上,我准备把后端从Node.js + Express迁移到NestJS。原因很简单,随着业务增长,接口数量增多,Express这种自由度过高的框架在多人协作和代码规范上有点失控,NestJS的模块化架构和依赖注入会让代码更可维护。不过这属于重架构调整,得等到预约量确实到达瓶颈再说,不能为了技术而技术,现阶段Express完全够用。
我在实际项目中体会到,一个预约系统跑通不是终点,关键是在用户和商家之间找到那种"刚刚好"的平衡感。用户觉得预约简单不费劲,商家觉得管理效率有提升,这就是一个合格系统的状态。希望这篇拆解对正在做类似项目的人有帮助,哪怕只是其中一个模块让你少踩一次坑,我也觉得值了。