资讯动态

酒店点餐系统开发实战:Spring Boot + Vue前后端分离构建完整业务闭环

发布时间:2026/10/17 7:40:21 来源:尧图企业网站定制
1. 先搞清需求酒店点餐系统到底在解决什么1.1 从点餐到结账的完整业务链条前阵子朋友所在的商务酒店找到我说他们餐饮区域的点餐流程还停留在纸质菜单勾选 服务员口头往厨房传菜的阶段。高峰期经常出现漏单客人加菜要扯着嗓子喊结账的时候账目对不上后厨也不知道先做哪一桌。听完我就明白了他们真正需要的不是一个能录入菜品的后台而是一条从客人点餐、后厨出菜到前台结账全部打通的业务链路。这类系统在酒店场景里有个特点它不是单纯给顾客用的也不是单纯给收银员用的而是同时服务几类人——前厅服务员、后厨厨师、收银员、还有管理员。每个角色看到的界面和操作权限都完全不一样。所以我在动手前把整个业务链条拆成了四个环节点餐环节、制作环节、出餐环节、结算环节。点餐环节解决点了什么、点了多少、哪桌点的制作环节解决后厨按什么顺序做、做到哪一步了出餐环节解决菜上了没、是否还需要催结算环节解决总价多少、怎么付、账单怎么留底。把这四个环节想明白后面设计接口和页面时就顺多了。很多人做这类项目一上来就写代码结果做到一半发现下单和结账的状态对不上或者后厨看不到新订单本质都是需求没拆透。1.2 技术栈选型Spring Boot Vue的理由技术栈方面我选了Spring Boot Vue这对组合原因是这套系统的核心诉求是快速开发、结构清晰、前后端分离、方便后续维护。酒店管理系统属于典型的中台型业务系统数据模型明确、接口数量适中不需要微服务那套重架构。之前也考虑过用纯单体模板渲染但很快就否掉了——因为前厅收银台、后厨大屏、管理员后台是三套完全不同的交互界面强行用一套模板渲染会让页面逻辑非常臃肿后续每改一个角色视图都要动服务端代码成本太高。Spring Boot这边我用了3.x版本配合MyBatis-Plus做数据访问层JWT做登录鉴权Redis存token和桌台状态缓存。Vue这边用了Vue 3 Vite Pinia Element Plus。选择Vite而不是Webpack主要看重它的开发启动速度和热更新体验在做点餐界面这种交互频繁的页面时改一行样式立刻能看到效果体验比Webpack舒服太多。这套选型还有一个现实考量Spring Boot和Vue的生态成熟社区资料多遇到问题几乎都能搜到解决方案。对于做课程设计或者想拿项目练手的人来说这也意味着你卡住的时候大概率能找到现成参考而不是在冷门框架里自己摸索。1.3 功能清单与角色权限把业务链条理清楚之后我列了一份功能清单按角色划分前厅收银/服务员端查看桌台状态空闲/用餐中/待结账选择菜品加入购物车、调整数量、下单加菜、退菜操作订单结账支持现金、扫码等多种支付方式登记后厨厨师端实时查看新订单按时间顺序排列标记开始制作和出餐完毕查看各桌订单的菜品进度管理员端菜品信息管理新增、上下架、改价菜品分类管理桌台管理用户账号管理、营业数据查看权限这块我在用户表里设计了角色字段后厨账号即使知道接口地址也无法调用菜品管理的接口。这一点在后端接口设计时就要考虑进去而不是只靠前端藏按钮。功能清单看着不多但每个功能背后都有对应的表结构、接口和页面实际开发量并不小。2. 数据库建模把一次点餐拆成表与状态2.1 核心表结构与建表SQL数据库设计是整个系统的地基表结构定不好后面写接口会各种别扭。我最终定了六张核心表用户表、菜品分类表、菜品表、桌台表、订单主表、订单明细表。其中订单主表和订单明细表是最关键的它们把一次下单这件事拆成了订单头和订单行。订单主表记录一次下单的整体信息订单编号、桌台、总金额、订单状态、支付方式、创建时间、操作人。订单明细表记录这次下单里的每一道菜属于哪个订单、哪道菜、下单时的单价、数量、小计。为什么要拆两张表因为一张表没法表达一个订单包含多个菜品的关系。比如一桌点了三菜一汤订单主表只有一行记录订单明细表就有四行。拆开之后查询这个订单包含哪些菜和这个订单花了多少钱都非常直接。我当时建的菜品表和订单相关表的SQL大概长这样CREATE TABLE dish ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL COMMENT 菜品名称, category_id BIGINT NOT NULL COMMENT 所属分类, price DECIMAL(10,2) NOT NULL COMMENT 单价, stock INT DEFAULT 999 COMMENT 库存-1表示不限量, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, image VARCHAR(255) DEFAULT , create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, table_id BIGINT NOT NULL COMMENT 桌台ID, status TINYINT DEFAULT 0 COMMENT 0待接单 1制作中 2待上菜 3已上菜 4待结账 5已结账 6已取消, total_amount DECIMAL(10,2) NOT NULL DEFAULT 0, pay_type TINYINT DEFAULT NULL COMMENT 1现金 2扫码 3挂账, create_by VARCHAR(20) DEFAULT , create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(50) NOT NULL COMMENT 冗余菜品名, price DECIMAL(10,2) NOT NULL COMMENT 冗余下单时单价, quantity INT NOT NULL, subtotal DECIMAL(10,2) NOT NULL COMMENT 小计 );2.2 订单状态机从待接单到已结账订单状态是整个系统里最容易出Bug的地方。如果不对状态做约束任何人都能把一个已结账的订单改回制作中业务流程就乱套了。我设计了七种状态0 待接单下单成功后厨还未确认1 制作中后厨已经开始做2 待上菜菜品制作完成等服务员端菜3 已上菜菜已经端到客人桌上4 待结账所有菜上齐客人准备买单5 已结账支付完成订单关闭6 已取消整单取消状态的流转不是随便跳的必须走合法路径。比如待接单可以流转到制作中或已取消但绝不能直接跳到已结账。待上菜可以流转到已上菜也可以因为菜品问题退回制作中重新做。这个合法流转逻辑我在后端用一张状态机表维护每个接口更新状态前先校验当前状态和目标状态是否匹配。这样虽然多写了几行代码但换来了整个订单流程的稳定性——后厨再怎么误操作订单也不会跑到一个逻辑上不可能的状态去。2.3 容易忽略的字段设计细节三个细节我踩过坑值得单独说一下。第一金额一律用DECIMAL(10,2)绝对不用FLOAT或DOUBLE。菜品单价、小计、总金额都是钱浮点数在计算机里是近似值0.1加0.2可能等于0.30000000000000004放到金额上就是账目纠纷。Java后端接收金额时建议用BigDecimal数据库用DECIMAL前后端传输时用字符串避免精度丢失。第二订单明细里一定要冗余菜品名称和下单时单价。刚开始我觉得冗余字段很脏后来发现不冗余才是麻烦的开始。因为菜品是可以改价、下架的如果订单明细只存菜品ID等客人结账时菜品已经涨价了历史订单的金额就变了。把名称和单价在下单那一刻快照进明细表订单就成了不可变的历史记录怎么改菜品都影响不到它。这一点在同类型系统里非常实用。第三订单编号不要用自增ID直接暴露给业务。我生成订单号的方式是前缀 时间戳 随机数比如D202506231030001234。这样做的原因有两个一是避免客户通过订单号猜到当天订单量二是多端并发时自增ID在分布式场景下容易冲突。虽然单体项目里自增ID够用但从一开始养成生成业务订单号的习惯后面接小程序或对接第三方平台都不用返工。3. Spring Boot后端接口怎么划分才好扩展3.1 工程结构和统一响应封装后端工程我按常见的分层方式组织controller、service、mapper、entity、dto五个包。Controller只负责接收参数、调用Service、返回结果不写任何业务逻辑。Service层放业务规则比如计算总价、校验状态流转、扣库存。Mapper层就是MyBatis-Plus的接口基本上单表操作都直接继承BaseMapper只有复杂查询才手写XML。接口返回结构我做了统一封装所有接口都返回同样的JSON格式code、message、data三件套。前端axios拦截器拿到响应后先看code200表示成功401表示未登录500表示服务端异常。这个约定看着简单但能让前后端联调的时候少扯很多皮。不然有的接口返回数组、有的返回对象、错误信息字段还不一样前端每个页面都要写一套错误处理逻辑非常崩溃。3.2 下单接口事务和金额计算下单接口是整个系统的心脏。我把它设计成一个事务方法先创建订单主表记录再循环插入订单明细同时累加计算总金额最后更新订单主表的总金额。只要任何一个环节出错整单回滚数据库不会出现有明细没主单或者有主单没明细的脏数据。核心代码大致是这样的Transactional(rollbackFor Exception.class) public Long createOrder(OrderDTO dto) { // 1. 创建主单 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setTableId(dto.getTableId()); order.setStatus(0); orderMapper.insert(order); // 2. 逐条处理菜品明细累加总金额 BigDecimal total BigDecimal.ZERO; for (OrderItemDTO item : dto.getItems()) { Dish dish dishMapper.selectById(item.getDishId()); if (dish null || dish.getStatus() ! 1) { throw new BusinessException(菜品已下架 item.getDishId()); } OrderItem detail new OrderItem(); detail.setOrderId(order.getId()); detail.setDishId(dish.getId()); detail.setDishName(dish.getName()); detail.setPrice(dish.getPrice()); detail.setQuantity(item.getQuantity()); detail.setSubtotal(dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); orderItemMapper.insert(detail); total total.add(detail.getSubtotal()); } // 3. 回写总金额 order.setTotalAmount(total); orderMapper.updateById(order); return order.getId(); }这里有几个细节要注意。下单时我直接用数据库里菜品表的当前价格计算而不是信任前端传过来的价格。原因很简单前端页面上显示的价格可以被篡改如果信任前端传价客人改个请求把菜价改成0.01元酒店就亏大了。这也是所有涉及金额的系统必须遵守的底线——计算永远以服务端为准。另外如果菜品有库存我会先把库存压到负数再插入明细避免超卖。最简单的做法是在菜品表加一个stock字段更新时用UPDATE dish SET stock stock - #{quantity} WHERE id #{id} AND stock #{quantity}判断影响行数如果返回0说明库存不足直接抛异常回滚。3.3 状态更新接单、上菜、结账一条线订单状态的更新我统一收口到一个方法里而不是每个状态写一个独立接口。原因是这样状态校验的逻辑可以集中管理可读性和可维护性都好很多。接口设计为PUT /api/order/status/{id}?status目标状态后端根据当前状态判断是否允许跳转到目标状态。private static final MapInteger, SetInteger STATUS_FLOW new HashMap(); static { STATUS_FLOW.put(0, Set.of(1, 6)); // 待接单 - 制作中/取消 STATUS_FLOW.put(1, Set.of(2, 6)); // 制作中 - 待上菜/取消 STATUS_FLOW.put(2, Set.of(3)); // 待上菜 - 已上菜 STATUS_FLOW.put(3, Set.of(4)); // 已上菜 - 待结账 STATUS_FLOW.put(4, Set.of(5, 6)); // 待结账 - 已结账/取消 } public void updateStatus(Long orderId, Integer targetStatus) { Order order orderMapper.selectById(orderId); if (order null) { throw new BusinessException(订单不存在); } SetInteger allowed STATUS_FLOW.get(order.getStatus()); if (allowed null || !allowed.contains(targetStatus)) { throw new BusinessException(非法的状态流转); } // 结账时校验支付方式和金额 if (targetStatus 5) { // 处理结算逻辑 } order.setStatus(targetStatus); orderMapper.updateById(order); }结账接口我单独拆了出来后端收到结账请求时会把订单状态改成已结账同时记录支付方式和结账时间。这里要注意的是结账操作和桌台状态要联动——订单结账后桌台状态要恢复成空闲方便下一个客人入座。如果忘了联动就会出现客人已经走了桌台还显示用餐中的尴尬情况。3.4 JWT鉴权店员端与后厨端分开权限控制我用了JWT加拦截器的方式。没有引入全套Spring Security的原因是这个系统的角色和权限模型很简单就三种角色用Security反而显得杀鸡用牛刀配置繁琐还容易出错。登录接口校验用户名密码成功后生成一个JWT tokentoken里放入用户ID和角色信息返回给前端。前端在后续请求的请求头里带上Authorization: Bearer token后端写一个拦截器统一解析token并把用户信息放到ThreadLocal里。需要鉴权的接口加上自定义注解拦截器判断token里的角色是否满足要求。核心逻辑其实很短Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (handler instanceof HandlerMethod) { HandlerMethod hm (HandlerMethod) handler; RequireRole role hm.getMethodAnnotation(RequireRole.class); if (role ! null) { String token request.getHeader(Authorization).replace(Bearer , ); LoginUser user JwtUtil.parseToken(token); if (!role.value().equals(user.getRole())) { throw new BusinessException(无权限操作); } UserContext.set(user); } } return true; } }这套东西写下来不难但一定要放在项目早期就做好不要等接口都写完了再补鉴权。否则每个接口都要回头改工作量会翻倍。4. Vue前端点餐交互与后厨看板的实现4.1 路由和页面组织方式前端我用Vue 3的Composition API组织代码按功能模块划分目录。路由方面一共有五个页面登录页、收银点餐页、后厨看板页、菜品管理页、桌台管理页。路由配置用了懒加载每个页面在访问时才加载对应的JS文件首屏速度会好一些。菜单栏的展示逻辑按角色动态渲染登录后根据用户角色决定显示哪些菜单入口。收银员进去看到的是点餐台后厨进去看到的是订单看板管理员看到的是菜品和桌台管理。前端做角色控制的同时后端接口也必须做同样的校验前端隐藏按钮只是体验优化后端拦截才是真正的安全边界。4.2 点餐台购物车联动与菜品分类点餐页是交互最复杂的页面。我采用左中右三段式布局左侧是菜品分类列表中间是当前分类下的菜品卡片右侧是购物车区域。点击菜品卡片可以在购物车加一份购物车里可以调整数量或删除。这里有几个交互细节我觉得做得比较到位。已下单的菜品在中间区域会变成灰色半透明并且右下角显示已下单 ×N的角标防止服务员重复下单而不自知。购物车里显示的是本次新加的菜和已下单的菜分开两个区块加菜时服务员能一眼看到哪些是刚加的。桌台状态用顶部的颜色标签区分绿色空闲、红色用餐中、黄色待结账。点餐桌台选择器只能选择用餐中的桌台进行加菜避免给空桌乱下单。分类切换时菜品列表会重新渲染这个在Element Plus里只要修改v-model绑定的分类ID并监听变化请求新数据即可。要注意的是接口返回的菜品列表最好按分类ID一次性全部加载前端做分类筛选而不是每次切换分类都请求一遍接口。酒店菜品的数量通常就几十上百道全量加载的响应体很小但交互会流畅很多。4.3 axios封装与token刷新axios封装这块我做了三件事请求拦截器自动加token、响应拦截器统一处理错误、请求超时设置。import axios from axios const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res.data }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } else { ElMessage.error(网络异常请稍后重试) } return Promise.reject(error) } )统一封装的好处是业务代码里只需要写const data await createOrder(params)完全不用关心token怎么带、错误怎么提示。token过期的情况在响应拦截器里统一处理成跳回登录页不用每个页面都写一遍。企业开发里也是这个套路自己搭项目时把这一层做好后面写页面会轻松非常多。4.4 后厨大屏轮询刷新菜品进度后厨看板是给厨师看的大屏页面上面按时间顺序排列新订单每行显示桌号、下单时间、菜品清单和状态操作按钮。后厨场景的实时性要求不算高几秒钟内有新订单可以接受所以我直接用定时轮询接口的方式获取最新订单列表没有上WebSocket。轮询的代码非常简单let timer null function startPolling() { timer setInterval(async () { const list await getKitchenOrders() orders.value list }, 5000) } onMounted(() startPolling()) onBeforeUnmount(() clearInterval(timer))为什么不用WebSocket因为轮询的实现成本低、逻辑直观、排错容易后厨的场景对秒级实时没有硬性要求。WebSocket引入之后要考虑连接管理、断线重连、心跳检测复杂度一下子上去。如果以后要做顾客扫码点餐这种需要即时推送的场景再升级成WebSocket也不迟。做项目的原则是能满足需求的方案里选最简单的而不是最酷炫的。新订单的提示效果我加了一个轻微的弹窗和声音提示厨师不用一直盯着屏幕。这个操作很简单就是轮询发现订单列表比上次多了内容时触发一次提示。5. 联调部署阶段绕不开的几个坑5.1 跨域问题的正确处理方式前后端分离开发时前端跑在5173端口后端跑在8080端口直接请求必然触发跨域。开发环境我用了Vite的proxy代理把/api开头的请求转发到后端前端代码里只写相对路径不需要知道后端地址// vite.config.js server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } }生产环境部署时前端构建出的静态文件交给Nginx托管Nginx把/api开头的请求反向代理到后端服务。这样前后端在浏览器眼里是同源的完全不存在跨域问题也避免了直接暴露后端端口。关键配置长这样server { listen 80; server_name your_domain; location / { root /opt/hotel-fe/dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有个很容易踩的坑proxy_pass http://127.0.0.1:8080;结尾带不带斜杠转发的路径结果完全不一样。带斜杠会把/api前缀去掉再转发不带斜杠会保留。我实际项目里踩过一次前端请求/api/order/list后端一直报404排查了半天发现是Nginx配置少了个斜杠。5.2 BigDecimal与金额计算的精度陷阱联调时发现一个隐蔽的问题前端把金额数字传给后端时如果数字很大或者带了很多位小数JSON解析时会变成科学计数法比如1.2345678E7后端解析成BigDecimal就直接报错。解决办法是前端拿到金额数据后统一当成字符串处理JSON.stringify时如果原始数据是number也不要紧关键是后端DTO里金额字段用String接收再在Service层转成BigDecimal计算。这样既不丢精度也不会被科学计数法搞崩。另一个坑是前后端展示金额时的精度一致性问题。前端JS里0.1 0.2不等于0.3所以在做购物车金额汇总时一定不要直接用JS浮点累加。我的做法是后端在创建订单时返回每个明细的小计和订单总金额前端把这些后端算好的值直接用来展示不在前端重新计算。这样既保证了精度也保证了和数据库里的最终账单一致。5.3 前后端分离打包与部署部署环境我用一台Linux服务器前后端分开部署。后端Spring Boot打jar包用java -jar启动建议用systemd托管服务这样进程挂了能自动重启日志也方便查看。前端在项目目录执行npm run build生成dist目录拷贝到Nginx的静态目录即可。一个容易被忽略的问题是后端接口地址的配置。我在前端代码里坚持用相对路径/api开头发请求而不是写死http://localhost:8080。道理很简单开发环境有Vite proxy生产环境有Nginx反代前端根本不需要知道后端部署在哪。如果写死了IP和端口换一台服务器部署就要重新构建前端非常麻烦。数据库这边我写了一个init.sql脚本包含建库建表和初始化数据。我在脚本里预置了管理员账号、常用菜品分类和示例菜品部署时直接执行一遍就能跑起来。这个脚本本身就是项目的一部分一定要放进仓库持续维护。6. 并发场景验证与下一步扩展思路6.1 热点菜品和并发下单的取舍系统上线前我用模拟脚本压了压接口发现有个问题客流高峰期多个服务员对同一桌同时下单时订单明细可能出现交叉错乱。比如A服务员加了两个菜B服务员也同时加了两个菜订单虽然能创建成功但总金额偶尔会少算。排查下来是创建订单时先读菜品价格再插入明细两个请求并发时读到的是同一份快照后一个请求的插入覆盖了前一个的部分数据。我的处理方式是在订单明细插入时直接使用菜品表的当前价格并给菜品表加一个version乐观锁字段更新库存时用UPDATE dish SET stock stock - ?, version version 1 WHERE id ? AND version ?的方式保证并发安全。选乐观锁而不是悲观锁是因为酒店点餐的并发量远没到数据库行锁会拖垮性能的程度乐观锁实现简单且对大多数场景够用。另外我对菜品估清做了个简单处理库存为0的菜品前端直接置灰无法加入购物车后端下单时也会做一次库存校验双保险避免超卖。对于不涉及库存的菜品比如现做炒菜库存字段设为-1表示不限量下单时跳过库存扣减。6.2 扩展方向扫码点餐、库存、报表这个系统做完之后我其实还留了几个很自然的扩展方向如果后面继续迭代优先级大概是这样的扫码点餐给每张桌台生成一个二维码客人用手机扫码就能看到菜品并自助下单。实现上只需要增加一个H5页面复用现有的下单接口但要把用户体系从收银员扩展成顾客新增一个顾客角色订单创建时关联桌台和顾客ID。菜品估清与库存预警菜品数量少于阈值时给管理员发提示这个把现有的库存字段加上一个提醒逻辑就能做。营业报表按天、按周统计营业额、热门菜品、翻台率。这个需要用一条GROUP BY的聚合SQL查询orders和order_item前端用图表库展示。小票打印结账后自动生成小票模板对接浏览器打印或者蓝牙打印机。这个需要单独做一个打印模板页面把订单信息格式化成小票样式。每次想到这些扩展点我都会提醒自己当初把订单明细冗余了下单单价和菜品名把订单状态机定义成可扩展的合法流转表就是为这些后续功能留的余量。如果一开始图省事把金额和菜品名都设计成关联查询现在做报表和小票打印就得先写一堆兼容代码。我个人做完这套系统的最大体会是做业务系统的关键不在代码多炫而在于把业务流程吃透、把状态和边界想清楚。数据库的表结构、订单的状态机、金额的精度处理、前后端的权限配合这些看似基础的事情才是项目不翻车的真正底牌。如果你正在做同类的点餐或餐饮管理系统建议先从这几点入手把地基打结实了再往上层堆页面会比上来就写代码顺得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑