餐饮行业的点餐系统,我前后帮人搭过好几套了。早年间用的是那种传统前后端不分离的项目,JSP 页面套后端,一套代码既要管后台管理又要管前台展示,后来又要适配手机浏览器,折腾到后面自己都嫌烦。所以当这次要做“SpringBoot 加微信小程序”的餐厅点餐订餐管理系统时,我其实挺有底的,这套组合算得上目前中小餐饮商家做线上化的主流方案了。市面上大量现成模板和教学项目都长这样,但真正靠谱的、能跑得顺的并不多。这篇就把我实际开发这套系统的完整过程写出来,从技术选型、数据库设计、后端接口实现到小程序端联调,以及那些不跑一遍绝对发现不了的坑,全部交代清楚。如果你正打算做毕业设计,或者给亲戚朋友的餐厅搞一套点餐小程序,这篇文章可以直接照着抄作业。
1. 项目定位与技术选型解析
1.1 为什么选择 SpringBoot 加微信小程序这个组合
先说 SpringBoot。现在 Java 后端开发基本已经默认用它了,原因不复杂,它把 Spring 那一套繁琐的 XML 配置几乎全干掉了,内嵌 Tomcat,打成一个 jar 包就能跑。接手一个老项目你可能还得折腾各种配置,但 SpringBoot 项目拿来就能启动,这对开发效率和后期维护来说都是实打实的优势。
再说微信小程序。对餐厅场景来说,小程序最大的好处就是“轻”,用户扫个码或者搜一下就能用,不用装 App。而且微信自带支付体系,用户下单后用微信支付非常顺滑,省去了自己做支付网关的麻烦。对商家来说,小程序后台还能沉淀用户数据,配合微信的推送能力做会员营销也比较方便。
技术上这套组合的成熟度也高。SpringBoot 负责提供 RESTful API,小程序端通过wx.request调用后端接口。数据格式统一用 JSON,前后端完全分离,分工清晰。调试的时候,小程序开发者工具配好后端地址就能直接联调,体验比传统 Web 项目顺畅得多。
提示:如果你是做毕业设计,这套组合也几乎是最稳妥的选择。SpringBoot 体现工程化能力,微信小程序体现移动端开发能力,两者结合的业务场景完整度又足够,答辩时能讲的东西非常多。
1.2 功能模块全景拆解
这套点餐订餐管理系统,我从需求层面给它拆成了三类角色:用户端(小程序)、商家端(后台管理)、系统端(运营维护)。
用户端的功能一般包含:
- 微信授权登录,自动获取用户基本信息
- 浏览菜品分类和菜品列表
- 查看菜品详情,包括图片、描述、价格、口味标签
- 加入购物车、修改数量、删除
- 提交订单、选择就餐方式(堂食/自提)
- 微信支付(或者模拟支付)
- 查看历史订单和订单状态
- 个人信息管理、地址维护
商家端的功能相对重一些,我用的是 Web 管理后台:
- 菜品管理:新增、上下架、改价格、维护库存
- 分类管理:菜品分类的增删改
- 订单管理:查看所有订单、处理订单状态
- 数据看板:今日订单数、营业额、热销菜品排名
- 会员管理:查看注册用户、订单统计
系统端在单体项目里一般不做独立拆分,直接把权限挂在管理员用户上就行。
实际上,开发的时候我更建议先把用户端的核心链路(浏览、加购、下单、支付回调)跑通,再回头补管理后台的功能。因为小程序端的体验直接决定项目能不能用,而后台管理功能再复杂也只是 CRUD。
1.3 角色体系与权限设计
权限设计这里,我用的是最简单也最实用的方案——基于角色的访问控制。
- 普通用户:默认角色,登录后自动创建,能访问小程序端所有接口
- 管理员:固定账号,在后台登录后进入管理界面,能访问后台所有管理接口
- 超级管理员:配置在配置文件中,理论上拥有最高权限,但在实际项目中我经常把超级管理员和管理员合并处理
后端实现上,用拦截器(HandlerInterceptor)做接口层面的鉴权。普通用户请求时校验 token,管理员接口校验管理员身份。项目规模不大时完全够用,如果硬要上 Spring Security 加 JWT 那套,反而增加复杂度。
注意:小程序端的用户登录状态我用的是自签发 token,就是把用户 id 加个盐做签名,存 Redis 设置过期时间。这个方案比 JWT 的优点是方便随时踢人下线,缺点是每次请求都要查 Redis。小项目无所谓,两种都可以。
2. 数据库设计与核心业务模型
2.1 业务表结构规划
数据库我用的是 MySQL 5.7,字符集选 utf8mb4,因为菜品描述里经常有 emoji 表情,不选 utf8mb4 会直接报错。核心表一共规划了 8 张,我列一下典型的建表结构,方便参考。
用户表(user):
- id
- openid(微信用户唯一标识)
- nickname(昵称)
- avatar(头像URL)
- phone(手机号)
- gender(性别)
- create_time(创建时间)
菜品分类表(category):
- id
- name(分类名称)
- sort(排序字段)
- status(是否启用)
菜品表(dish):
- id
- category_id(关联分类)
- name(菜品名)
- image(图片)
- description(描述)
- price(价格)
- stock(库存)
- sales(销量)
- status(上下架状态)
- create_time
购物车表(cart):
- id
- user_id
- dish_id
- quantity(数量)
- is_selected(是否选中,结算时有用)
订单表(orders):
- id
- order_no(订单编号,唯一)
- user_id
- total_amount(总金额)
- status(订单状态)
- pay_status(支付状态)
- remark(备注)
- address(收货/自提信息)
- create_time
- pay_time
- finish_time
订单明细表(order_detail):
- id
- order_id
- dish_id
- dish_name(冗余,防止菜品删除后订单没记录)
- dish_image(冗余)
- price(下单时的单价)
- quantity
管理员表(admin):
- id
- username
- password(BCrypt 加密存储)
- create_time
我特意在订单明细里冗余了菜品名称和图片,这一点非常重要。因为菜品下架或者删除后,如果订单去联查菜品表,历史订单就会显示异常。做点餐类项目,历史订单数据一定要能独立展示。
2.2 订单状态机设计
订单状态是点餐系统最核心的领域逻辑之一,我把它设计成如下流转:
- 待支付(0):用户提交订单但未支付
- 待接单(1):支付成功,等待商家确认
- 进行中(2):商家已接单,开始制作
- 待取餐/待送达(3):制作完成,等待取餐
- 已完成(4):用户确认收货或取餐
- 已取消(5):用户取消或超时未支付自动取消
实际开发中,状态字段我用的是 int 类型,并在代码里定义一个常量类来管理,避免魔法数字散落在各处。处理订单状态时只允许按照状态机的方向转移,非法状态操作直接抛异常。
支付状态和订单状态我拆成了两个字段。为什么要拆?因为订单状态是业务层面的,支付状态是资金层面的。举个例子,一个订单支付成功后退款了,订单状态还可能是已完成,但支付状态变成已退款。如果混在一个字段里,后面做对账和统计会让你怀疑人生。
2.3 购物车与库存联动设计
购物车表设计得比较简单,核心就是user_id加dish_id联合唯一。用户往购物车里加同一个菜品时,直接走ON DUPLICATE KEY UPDATE把数量加一,避免重复记录。
库存的联动我踩过坑。最初版本是下单时不检查库存,审核订单时人工看,结果节日爆单的时候后台差点乱套。后来改成下单时预扣库存,而且必须在数据库层面做原子扣减操作,用UPDATE dish SET stock = stock - #{count} WHERE id = #{dishId} AND stock >= #{count}这样的 SQL 保证不超卖。
如果扣减失败,说明库存不足,直接抛出异常让事务回滚,并返回给小程序端“某某菜品库存不足”的提示。
注意:订单创建时预扣库存,用户超时未支付取消订单时要回补库存,否则会出现库存显示有货但实际买不了的情况。这一点一定要在取消订单的逻辑里写清楚。
3. 后端核心接口实现
3.1 微信登录与用户体系打通
小程序端调用wx.login()拿到一个临时 code,然后把 code 传给后端。后端拿着 code 调用微信官方的code2session接口,换取 openid 和 session_key。
我建议这一层封装成独立的 Service,方便统一处理。用户第一次登录时,根据 openid 查不到记录就自动创建,查到了就更新昵称头像,最后签发 token 返回给小程序端。
关键代码大概是这个结构:
@Override public String login(String code, String nickname, String avatar) { // 使用 code 调用微信接口获取 openid WxLoginResult result = getOpenId(code); String openid = result.getOpenid(); User user = userMapper.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname(nickname); user.setAvatar(avatar); userMapper.insert(user); } else { userMapper.updateProfile(user.getId(), nickname, avatar); } String token = UUID.randomUUID().toString().replace("-", ""); redisTemplate.opsForValue().set(TOKEN_PREFIX + token, String.valueOf(user.getId()), 2, TimeUnit.DAYS); return token; }这里有两个细节值得注意。
第一,code2session接口是有调用频率限制的,不能让小程序端每次打开页面就登录一次。我是在小程序端把 token 存到了wx.setStorageSync,每次启动时先检查本地 token 是否存在,存在就直接用,不存在才走登录流程。
第二,session_key绝对不能返回给前端。这个值是微信用来解密敏感数据的密钥,泄露出去等于把用户手机号等信息的安全交给别人。后端拿到后只保存在服务端即可,如果业务上用不到甚至可以不管。
3.2 点餐下单与订单提交
下单接口是这个系统里事务最长、最容易出问题的地方。用户点击“提交订单”按钮,后端要做的事情按顺序排列:
- 校验用户登录状态
- 查询用户的购物车记录,校验菜品状态和库存
- 计算订单总金额
- 预扣库存
- 创建订单主表记录
- 批量创建订单明细
- 清空购物车
- 下单成功后生成支付参数(调用微信支付统一下单接口)
这里必须在方法上加@Transactional注解,保证任何一个步骤失败,整个操作都能回滚。一开始我图省事,没有对库存扣减做行锁或原子操作,测试时用两个账号同时下单买同一个菜品,发现库存直接变成负数了。后来改成UPDATE ... WHERE stock >= #{count}这种原子 SQL 才解决并发问题。
生成订单编号的规则我是这样定的:日期时间加随机数,格式类似20250607123045001。规则是日期到秒再加 3 位随机数。如果订单量特别大的话建议换成雪花算法,但小餐馆的并发量用这个规则加唯一索引就足够了。
3.3 订单状态流转控制
订单状态的每一次变更,我都要求必须在后端完成,前端只负责展示和发起请求。这样做的好处是,业务规则都收敛在后端,前端只是被动展示结果。
举个例子,用户点击“取消订单”按钮,前端把orderId和action=cancel传给后端,后端判断订单当前状态是否为“待支付”或“待接单”,如果是就更新状态,同时把预扣的库存回补。如果订单已经进入了制作中状态,则后端返回“当前订单状态不允许取消”。前端拿到这个提示后,弹窗告知用户。
为了跟踪每一步操作,我加了一个order_log表,记录订单状态变更历史。这样做的好处有两个:一是排查问题时能还原现场,二是解决顾客投诉时能明确责任。
4. 小程序端开发实操
4.1 页面结构与跳转逻辑
微信小程序的页面结构是典型的单页应用模式。我的页面规划如下:
- 首页(index):轮播图、分类导航、推荐菜品
- 菜品分类页(category):分类列表、菜品列表
- 菜品详情页(detail):菜品图片、价格、口味选择、加入购物车
- 购物车页(cart):购物车列表、数量加减、结算按钮
- 确认订单页(confirm):选择就餐方式、备注、显示总金额
- 支付结果页(payResult):支付成功/失败状态展示
- 订单列表页(orderList):全部订单、按状态筛选
- 订单详情页(orderDetail):订单详情、状态操作按钮
- 个人中心页(mine):用户信息、订单入口、设置
页面跳转我全部用wx.navigateTo,因为它支持返回。唯一例外是首页 tabBar 页面之间的切换,用的是wx.switchTab。
购物车数据的存储,这里推荐两种方案:
- 方案一:每次加购都调用后端接口,购物车数据存在服务端
- 方案二:先存在本地 Storage,结算时一次性提交后端
我刚做的时候图省事选了方案二,结果发现用户换了手机或者清理缓存后购物车就丢了,体验明显不行。后来改成纯服务端方案,页面每次onShow时从后端拉最新购物车数据。虽然会多一点网络请求,但数据稳了。
4.2 购物车与结算流程
购物车页的核心是数量加减和选中状态。数量加减调用后端接口更新数据库并重算总价,选中状态同时上报后端。结算按钮触发时,前端读取当前选中的购物车项生成订单。
这里有一个交互细节值得说:用户在购物车页面连续快速点加减时,前端容易把旧的响应覆盖新的请求结果。我做了个简单的防抖处理,loading状态为 true 时不允许再次点击,等当前请求返回后才恢复。这个小细节一开始没做,测试时发现网络慢的时候数量会被覆盖回旧值,体验很怪。
确认订单页要做的事情很清晰:展示购物车已选菜品列表和总金额、选择就餐方式、填备注。页面加载时需要一次性拿齐这些数据,我用了一个getOrderConfirmData接口合并返回,减少了前端多次请求导致的数据不一致问题。
4.3 网络层封装与鉴权拦截
小程序端的网络请求,我封装了一个request.js工具函数,核心逻辑是:
const request = (url, method, data) => { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'Authorization': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 200) { resolve(res.data.data) } else if (res.data.code === 401) { // token 过期,重新登录 wx.removeStorageSync('token') wx.navigateTo({ url: '/pages/login/login' }) reject(res.data) } else { wx.showToast({ title: res.data.msg, icon: 'none' }) reject(res.data) } }, fail: (err) => { reject(err) } }) }) }所有请求都走这个封装,统一处理 token 注入、错误提示和 401 跳转,省去每个页面重复写异常分支的工作。后端也有一层登录拦截器,对未带 token 或 token 过期的请求统一返回 401 状态码。前后端约定同一套响应格式,联调效率会高很多。
注意:小程序真机调试时,必须在小程序管理后台把请求域名配置到“合法域名”列表里,否则请求会被直接拦截。开发阶段可以勾选“不校验合法域名”,但发布前必须配置正确,这个验证环节经常有同学忽略导致上线后接口全部失败。
5. 常见问题与排查技巧实录
5.1 并发下单与库存超卖
这个问题我在测试阶段撞到过一次比较严重的。当时用 JMeter 模拟 100 个并发请求同时下单同一个菜品,结果库存从 30 直接变成了负数。原因就是代码里先查询库存再更新库存,中间有间隙,并发请求读到的都是同一个旧值。
解决办法是使用原子更新的 SQL 语句:
UPDATE dish SET stock = stock - #{count}, sales = sales + #{count} WHERE id = #{dishId} AND stock >= #{count}数据库层面保证扣减的原子性。如果更新的影响行数为 0,说明库存不足,抛异常触发事务回滚。这个方案在小型餐饮系统的并发量下完全够用。
5.2 小程序真机联调常见坑
开发阶段用模拟器跑得好好的,但一上真机就各种问题。我汇总了几次真机联调遇到的坑:
第一,wx.request超时设置。默认超时时间是 60 秒,但如果后端接口处理时间比较长(比如首次启动时初始化数据库连接池),真机上容易出现请求超时。我给后端配置了合适的连接池初始化策略,并把前端的超时时间设置为 10 秒,实测体验稳定。
第二,真机上对 HTTPS 的要求。开发工具勾了不校验域名后模拟器能跑,但真机不行。真机上必须用 HTTPS 域名,并且 SSL 证书要有效。我当时图省事把后端接口地址写成局域网 IP,真机联调直接全部失败,最后在内网环境搭了反向代理才解决。
第三,不同微信版本的 API 兼容性。比如getUserProfile接口在某个版本之后获取用户信息时需要先授权按钮,直接调用会失败。我在个人中心页把获取头像昵称的入口改成了用户主动点击按钮触发授权,兼容性和体验都好了许多。
5.3 数据一致性与事务管理
订单模块涉及多张表的修改,最容易出现数据一致性问题。我的原则是:一个业务操作涉及的数据库写操作,全部放在一个方法里并用事务包裹。
最初我在提交订单时只对订单主表和明细加了事务,漏了库存扣减操作,导致订单创建成功但库存没有扣减。后来把整个下单链路都收敛到一个createOrder方法中,加上事务注解,问题才彻底解决。
事务中还要注意自调用失效的问题。Spring 的@Transactional是基于代理机制的,同一个类内部方法调用不会走代理,所以事务注解要放在对外暴露的入口方法上,而且要确保这个入口是被外部调用进来的。
另外我建议给事务里涉及的数据库操作加上合理的超时时间,避免某个死锁导致事务长时间不释放连接池。排查的时候从日志堆栈和数据库连接池状态入手,基本能快速定位是死锁还是慢 SQL。
5.4 管理后台和菜品图片存储方案
关于图片存储,一开始我直接把菜品图片存到了服务器本地目录,用 Nginx 映射静态资源访问。小规模内部使用没问题,但考虑到数据库迁移和服务器扩容会比较麻烦,后期改成了对象存储方案。图片上传后返回一个 URL 存到数据库。
如果只是做开发学习和毕业设计,本地存储完全够用。给个建议:避免把图片二进制直接存到 MySQL 的 BLOB 字段里,会让数据库文件飞速膨胀,备份恢复都很痛苦。用文件路径或者 URL 存储,展示时前端拼接完整地址就行,运维也方便。
6. 从代码到交付:项目整理与演示复盘
6.1 为什么“源码加文档加视频”这种组合特别适合做项目交付
做这个系统的时候,甲方是一家做餐饮数字化服务的公司,要求交付的不仅是一套能跑的代码,还包括详细的开发文档、部署说明和演示视频。接触多了你会发现,真正完整的项目交付不只是“代码能跑”那么简单。
我给这套系统整理交付时会包含:
- 开箱即用的 SQL 脚本(包括建库、建表和初始化数据)
- README 部署文档(环境要求、启动步骤、账号密码)
- 接口文档(用 Markdown 或者在线文档维护,字段含义写清楚)
- 演示视频(录制小程序端操作流程和管理后台功能)
源码层级也会刻意划分清楚,避免 controller、service、mapper 全堆在一起。对于一个交接项目来说,别人拿到后能不能快速看懂,比代码本身多“高级”更重要。
6.2 部署环境与启动步骤
配套环境清单大概是这些:
- JDK 1.8 或 11
- Maven 3.6+
- MySQL 5.7 或 8.0
- Redis 6.x(做 token 存储)
- 微信小程序开发者工具
启动顺序是:先初始化数据库,修改配置文件里的数据库连接和 Redis 地址,然后运行主启动类,最后用小程序开发者工具导入前端工程并修改BASE_URL。
部署到云服务器时,后端打成 jar 包用nohup后台运行即可。nginx 反代配置一下指向 SpringBoot 的 8080 端口,再把本地图片目录映射出去。
6.3 实机演示讲解的技巧
录制演示视频和讲解视频时,我的经验是按照业务链路来讲,不要按照代码文件来讲。
推荐顺序:
- 先用真实账号从小程序端走一遍完整下单流程
- 切到管理后台处理订单状态
- 再回到小程序端看订单状态变化
- 最后演示基础数据维护
这样讲的好处是逻辑清晰,对方能直观地看到系统的每个功能是干什么用的。讲解代码时再按模块逐层展开:先看核心表设计,再看接口实现,最后看前端页面。
写在最后:这套系统还能怎么扩展
做到后期,我又帮对方落地了两个实际生产可用的扩展点,虽然不是必须,但能明显提升系统的实用程度。
第一个是“桌码点餐”,顾客进店扫桌上的二维码,自动带入桌号参数,下单时把桌号存进订单备注里,后厨出餐就能知道是哪一桌的。这个改动并不复杂,只需在二维码链接里带一个参数,订单表中加一列桌号字段,前端在确认订单页把桌号回显出来就好。
第二个是“菜品推荐位”,在后端增加一个推荐菜品查询接口,按照销量排序返回 top N,首页展示热销榜。数据量大了之后,再配合定时任务统计昨天的销量,把排名存缓存,接口性能就比较稳定了。
做这套系统最大的体会是:技术选型不重要,业务链路通不通最重要。下单、支付、接单、出餐、完成这一整条链路,任何一个环节断了都会让餐厅老板觉得“不好用”。哪怕 SpringBoot 用得再熟练,小程序写得再花哨,核心链路不扎实,项目交付也谈不上真正落地。反过来,只要把这条链路跑得顺畅扎实,再往上加营销、会员、数据统计这些功能,都只是时间问题。