☰
SpringBoot+Vue前后端分离餐厅点餐系统开发实践
2026/9/30 8:26:41 网站建设 项目流程

从客户扫码到后厨出单,整个点餐闭环看着简单,真正动手做起来细节多得吓人。SpringBoot加Vue这套前后端分离组合,是当前做餐厅点餐管理系统最常见也最稳妥的技术栈,不管是拿来当毕设、课程设计,还是给小餐馆做一套真正能用的信息化工具,都绕不开订单流转、库存扣减、桌台管理这些硬骨头。这篇就把我做这类项目从零到落地的完整思路和踩坑记录整理出来,希望能帮正在纠结怎么下手的朋友省点时间。

先说清楚这个系统到底做什么:顾客到店扫桌台码,在手机上浏览菜品、加购物车、下单支付;后厨或者吧台的大屏实时收到新订单提醒,按状态处理出餐;管理员在后台维护菜品分类、上下架、价格库存,查看营业报表。就这么一条主线,牵涉到用户端H5、商家管理端Web、后端服务、数据库,再加上支付和打印对接,是一个典型的前后端分离业务系统。

我见过太多人一上来就急着写代码,结果做到一半发现购物车和库存对不上,订单状态乱成一团。这篇文章会从业务建模、数据库设计、后端接口、前端页面、部署上线到问题排查,完整过一遍,每个关键决策我都会解释为什么这么做,以及我在实际开发中踩过哪些坑。

1. 项目速览:这个点餐系统到底在解决什么问题

1.1 需求场景与系统边界

餐厅点餐系统听起来很宽泛,但落到实际开发,必须先圈定场景。我做这个项目时,默认的业务模型是“到店扫码点餐”,而不是外卖平台那种复杂配送逻辑,后者要牵扯骑手、分账、平台对接,工作量完全不是一个量级。

核心角色有三类:

  • 顾客:扫码进入点餐H5,浏览菜单,加购,下单,支付,查订单状态。
  • 商家员工(后厨/吧台):接收新订单,更新制作进度,完成出餐。
  • 管理员(店长/老板):菜品管理、分类管理、桌台管理、订单查询、营业统计。

为什么选扫码点餐而不是服务员拿PAD点餐?因为扫码模式能大幅减少服务员人力成本,顾客手机就是点餐终端,商家不需要购买一堆硬件,部署成本最低。这在中小型餐厅尤其吃香,也是这类毕设和实际项目最常见的需求形态。

1.2 为什么是SpringBoot + Vue这套组合

技术选型不能拍脑袋,我当初对比过几套方案:纯JSP/Servlet老项目、SpringBoot + Thymeleaf服务端渲染、SpringBoot + Vue前后端分离。

方案优点缺点适用场景
JSP/Servlet结构简单,部署直接前后端耦合,维护痛苦老系统维护
SpringBoot + Thymeleaf开发快,不用跨域页面交互弱,动态刷新体验差简单后台
SpringBoot + Vue前后端解耦,团队并行,交互流畅跨域、鉴权、部署复杂度上升本项目首选

SpringBoot负责提供RESTful API,处理业务逻辑、数据持久化、权限校验;Vue负责页面渲染和用户交互,通过Axios请求后端接口。这套组合的优势在于:前端可以做成H5供顾客扫码访问,也可以做成管理后台供商家用,同一套后端API可以服务两个前端,复用率非常高。

另外从学习角度看,SpringBoot自动装配机制能帮你省掉大量XML配置,Vue的响应式数据绑定让页面开发效率成倍提升。这两样都是当前企业级开发的主流技能,做个完整项目练一遍,对找工作写简历也是实打实的帮助。

1.3 核心功能模块清单

我梳理了一下,一个能真正跑起来的点餐系统,功能模块大致如下:

  • 用户模块:顾客微信授权或手机号登录、员工账号登录、JWT鉴权、角色权限区分。
  • 菜品与分类模块:菜品分类树、菜品CRUD、上下架、图片上传、规格(大份/小份)、库存。
  • 桌台模块:桌台编号、二维码生成、桌台状态。
  • 购物车模块:加购、修改数量、清空、实时计算总价。
  • 订单模块:下单、订单状态流转、订单明细、支付对接(微信/支付宝/模拟支付)。
  • 消息推送模块:WebSocket实时推送新订单到后厨大屏。
  • 统计报表模块:营业额、订单量、菜品销量排行等。

这些模块不是一上来全做,我建议按照“用户登录 -> 菜品展示 -> 加购 -> 下单 -> 订单管理 -> 统计报表”这条主线迭代开发,每个环节可独立验证,最后再串起来。

2. 数据库设计:别急着建表,先把业务模型捋清楚

2.1 核心表结构解析

数据库设计是这类项目的地基。我见过不少人把订单和订单明细混在一张表里,或者购物车字段乱放,后面写SQL统计简直想哭。这里我按自己验证过的方案给出核心表结构。

用户表sys_user:主键id、username、password(BCrypt加密存储)、phone、role(枚举:CUSTOMER/EMPLOYEE/ADMIN)、avatar、create_time。

菜品分类表category:id、name、sort(排序权重)、status(1上架,0下架)。

菜品表dish:id、category_id(关联分类)、name、图片、price(存储为Decimal(10,2))、description、status、stock(库存数量)、sales(销量,冗余字段用于排行)。

桌台表table_info:id、table_number、qr_code(二维码图片地址)、status(空闲/占用)。

订单主表orders:id、order_no(订单编号,全局唯一)、table_id、user_id、total_amount、status、pay_type、pay_status、remark、create_time、pay_time。

订单明细表order_detail:id、order_id、dish_id、dish_name(冗余快照)、dish_image、price(下单时价格快照)、quantity、subtotal。

为什么订单明细要冗余冗余菜品名称和价格快照?因为菜品价格和名称未来可能修改,而历史订单必须保留下单那一刻的真实数据。这是电商系统里非常基础的设计约定,不做就是给自己埋坑。

状态字段我建议直接用TINYINT存数字枚举,比如订单状态:0待支付、1已支付/待制作、2制作中、3已完成、4已取消。有人喜欢用字符串,查询和展示没问题,但存储效率和索引性能都不如数字。后端用枚举类统一管理状态值,前端只接收数字然后映射文案。

2.2 MyBatis-Plus还是JPA?我的选择

ORM框架这块,SpringBoot生态里最常见的就是MyBatis-Plus和Spring Data JPA。点餐系统既有简单的CRUD,也有复杂统计SQL,我最终选了MyBatis-Plus。

原因很直接:MyBatis-Plus提供通用Mapper、分页插件、条件构造器(QueryWrapper),单表操作几乎不用写SQL;复杂统计查询可以手写XML映射文件,完全可控。JPA虽然封装程度更高,但碰到复杂查询时,JPQL或原生SQL的坑对新手不太友好,而且JPA的懒加载和N+1问题在联表查询多的时候需要额外用心处理。

核心依赖这样引入:

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.5</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency>

2.3 建表SQL示例

这里给出最关键的四张表建表语句,其余表结构类似。字段注释一定要写清楚,宁多勿少,后期维护全靠注释。

CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL COMMENT '订单编号', `table_id` bigint(20) DEFAULT NULL COMMENT '桌台ID', `user_id` bigint(20) NOT NULL COMMENT '下单用户ID', `total_amount` decimal(10,2) NOT NULL COMMENT '订单总金额', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2制作中 3已完成 4已取消', `pay_type` tinyint(4) DEFAULT NULL COMMENT '支付方式 1微信 2支付宝 3模拟', `pay_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '0未支付 1已支付', `remark` varchar(255) DEFAULT NULL COMMENT '订单备注', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_status` (`status`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表'; CREATE TABLE `order_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_id` bigint(20) NOT NULL COMMENT '订单ID', `dish_id` bigint(20) NOT NULL COMMENT '菜品ID', `dish_name` varchar(100) NOT NULL COMMENT '菜品名称快照', `dish_image` varchar(255) DEFAULT NULL COMMENT '菜品图片快照', `price` decimal(10,2) NOT NULL COMMENT '单价快照', `quantity` int(11) NOT NULL COMMENT '数量', `subtotal` decimal(10,2) NOT NULL COMMENT '小计', PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表';

这里有个细节:order_no一定要建唯一索引。我一开始没做唯一约束,并发测试时出现过重复订单号,排查了很久才发现是多线程环境下时间戳加随机数撞了,后来改成“日期 + 随机数 + 用户ID后缀”并加唯一索引才彻底解决。

3. 后端SpringBoot核心实现:从登录鉴权到下单扣库存

3.1 项目初始化与分层结构

用IDEA新建SpringBoot项目时,我建议直接选Spring Initializr,Java版本选8或11都行,SpringBoot版本别追求最新,2.7.x就可以,稳定且资料多。选依赖时勾上Web、MySQL驱动、MyBatis(后续换成MyBatis-Plus)、Validation、Lombok。

如果你手里是旧的Gradle项目想迁移到Maven,也不用慌,核心是把build.gradle里的依赖翻译成pom.xml,SpringBoot的starter命名是统一的,换过去基本无痛。网上关于“早期gradle构建项目迁移maven”的教程不少,核心就是注意版本号对齐。

分层这块我按标准的三层架构:Controller -> Service -> Mapper,实体类放entity包,DTO和VO也分开放。很多人觉得包分太多麻烦,但项目一旦过千行,包结构混乱就是灾难。我的推荐结构:

com.example.restaurant ├── controller # 接口层,只做参数接收和结果返回 ├── service # 业务逻辑层 ├── mapper # MyBatis-Plus数据访问层 ├── entity # 数据库实体 ├── dto # 请求参数对象 ├── vo # 响应视图对象 ├── config # 配置类(跨域、WebSocket、Security等) ├── common # 通用工具、统一返回结果、异常处理 └── utils # JWT、二维码生成等工具类

3.2 JWT登录鉴权设计

前后端分离项目的鉴权方案,最常用的是JWT(JSON Web Token)。为什么不用Session?因为前端H5和后台管理是不同端口甚至不同域名部署,Session的Cookie跨域处理很别扭,而JWT是无状态的,后端不存登录状态,前端每次请求在Header里带Authorization: Bearer token,后端解析验证即可。

JWT工具类核心代码,我用的是jjwt库:

@Component public class JwtUtils { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire}") private Long expire; // 过期时间,单位秒 public String generateToken(Long userId, String role) { Date now = new Date(); Date expireDate = new Date(now.getTime() + expire * 1000); return Jwts.builder() .setHeaderParam("typ", "JWT") .setSubject(userId.toString()) .claim("role", role) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secret.getBytes(StandardCharsets.UTF_8)) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody(); } }

这里有几个实操要点:

第一,secret不能硬编码在代码里,要放到application.yml配置文件中,不同环境用不同值。第二,HS256签名时如果secret太短会报错,建议至少32位以上。第三,JWT过期时间我设置为24小时,但在管理端场景,玩家会希望更安全,所以后端还需要一个拦截器校验token并刷新过期策略,或者引导前端重新登录。

拦截器配置里需要排除登录接口、菜品展示接口等匿名访问路径,否则顾客还没登录就什么都看不到了。

3.3 下单流程:购物车到订单的完整链路

下单是这个系统最核心的业务流,流程大概是:

  1. 顾客在前端把购物车数据提交到后端,格式是[{dishId: 1, quantity: 2}, ...]。
  2. 后端校验菜品是否存在、是否上架、库存是否足够。
  3. 计算总金额,注意金额计算用BigDecimal,绝对不能用double或float,否则会出现0.1+0.2不等于0.3的经典问题。
  4. 创建订单主表记录,状态为待支付。
  5. 创建订单明细记录。
  6. 扣减库存(这里用乐观锁防止超卖)。
  7. 返回订单ID和订单编号给前端,前端引导用户支付。

扣库存这一步,很多人直接写成UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id},这样在并发场景下会超卖。我用的方案是在库存表加一个乐观锁版本号字段(或者直接在更新SQL里限定stock >= quantity):

UPDATE dish SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}

影响行数为0说明库存不足,直接抛异常回滚事务。这种写法比先查再更新更安全,也是SQL层面最简单的防超卖手段。

下单接口我在Service层加了@Transactional注解,保证订单主表、明细表、库存扣减这三个操作要么全部成功,要么全部回滚。这里要特别提醒:事务只对RuntimeException回滚,如果你自己try-catch吞掉了异常,事务是不会回滚的。我刚开始做的时候就犯过这个错,库存扣了但订单没生成,排查半天才发现异常被吞了。

3.4 WebSocket实时推送:后厨大屏如何第一时间知道新订单

点餐系统有个特别提升体验的功能:后厨大屏实时显示新订单。一开始我用前端定时轮询订单状态接口,5秒查一次,虽然能用但是体验差——订单来了最多延迟5秒才知道,而且频繁请求浪费资源。后来改成WebSocket,后端一有新订单就主动推送给连接的后厨端,秒级响应。

SpringBoot整合WebSocket不算复杂,核心三步:引入依赖、配置端点、写消息处理器。

@Component @ServerEndpoint("/ws/kitchen") @Slf4j public class KitchenWebSocket { private static CopyOnWriteArraySet<Session> sessions = new CopyOnWriteArraySet<>(); @OnOpen public void onOpen(Session session) { sessions.add(session); log.info("后厨端连接,当前连接数:{}", sessions.size()); } @OnClose public void onClose(Session session) { sessions.remove(session); } @OnError public void onError(Session session, Throwable error) { log.error("WebSocket错误", error); } public static void sendNewOrderMessage(String message) { for (Session session : sessions) { try { session.getBasicRemote().sendText(message); } catch (IOException e) { log.error("推送消息失败", e); } } } }

下单成功后,在Service层调用KitchenWebSocket.sendNewOrderMessage(...),把订单编号和桌台号推送出去。前端用Vue的new WebSocket('ws://localhost:8080/ws/kitchen')接收消息,监听到新订单后刷新列表并播放提示音。

这里有个部署坑:如果后端用了Nginx反向代理,WebSocket需要单独配置升级头,否则会报“握手失败”。Nginx配置如下:

location /ws/ { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; }

3.5 统一返回与全局异常处理

前后端分离开发中,最烦人的是接口返回格式不统一。有人返回{code: 200, data: ...},有人直接返回裸JSON,前端接数据时各种判断,极易出bug。我在这个项目里做了统一响应体。

@Data public class Result<T> { private Integer code; // 200成功,其他失败 private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("操作成功"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }

全局异常处理用@RestControllerAdvice配合@ExceptionHandler,把参数校验异常、业务异常、未知异常统一包装成Result格式返回,前端就不用关心各种异常栈信息了。这个环节看起来很基础,但实际开发中能省掉大量联调时间。

4. 前端Vue实现:顾客H5与管理后台的双前端架构

4.1 工程结构:一个仓库还是两个项目

这个系统前端我实际拆成了两个工程:restaurant-h5(顾客扫码点餐端)和restaurant-admin(商家管理端)。虽然两者共用后端API,但界面风格、交互逻辑、路由结构完全不同,硬塞在一个Vue工程里会让代码越来越乱。

管理员端用Vue3 + Element Plus + Vite开发;顾客H5考虑到微信内置浏览器兼容性,我用的是Vue3 + Vant组件库。为什么用Vant?因为Vant本身就是移动端UI库,有现成的底部导航、商品卡片、弹出层、购物车栏,做点餐页面几乎就是拼积木。

Vue环境配置是很多新手卡住的点,我提一句:先用Node 18+版本(太低太高都可能有兼容性问题),然后npm create vue@latest创建工程,装依赖npm install,开发npm run dev,构建npm run build。Vite冷启动速度比Webpack快太多了,强烈建议不要再用老掉牙的Vue CLI。

4.2 Axios封装与请求拦截

前后端分离必须封装Axios,不能每个组件里this.$http.get一把梭。我在src/utils/request.js里做了一个统一配置:

import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', // 开发环境通过Vite代理,生产环境通过Nginx转发 timeout: 10000 }) // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, error => Promise.reject(error)) // 响应拦截器:统一处理错误 request.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.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) }) export default request

这个封装的关键点在于:后端返回的body统一是{code, message, data}结构,拦截器里先判断code,成功直接返回data给页面,失败统一弹出错误提示。这样Vue组件里写接口调用就是一个干净的三行代码:

const res = await request.get('/dish/list') this.dishList = res

4.3 点餐页面核心逻辑:购物车与桌台参数

顾客端的核心页面是菜单列表 + 商品详情 + 购物车弹层。Vant组件里van-card放菜品卡片,van-stepper做数量加减,购物车用van-popup弹出,一套组合拳下来页面就成型了。

购物车状态我在项目里用的是Pinia(Vue 3官方推荐状态库,比Vuex更轻量)。因为购物车数据要跨组件共享:菜单页加购、购物车弹层改数量、结算页读数据,没有全局状态管理根本没法做。

// stores/cart.js import { defineStore } from 'pinia' export const useCartStore = defineStore('cart', { state: () => ({ items: [] // [{ dishId, dishName, price, image, quantity }] }), getters: { totalCount: state => state.items.reduce((sum, item) => sum + item.quantity, 0), totalPrice: state => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0) }, actions: { addItem(dish) { const existing = this.items.find(item => item.dishId === dish.id) if (existing) { existing.quantity++ } else { this.items.push({ dishId: dish.id, dishName: dish.name, price: dish.price, image: dish.image, quantity: 1 }) } }, removeItem(dishId) { this.items = this.items.filter(item => item.dishId !== dishId) }, clear() { this.items = [] } } })

桌台参数传参有几个方案:URL参数、二维码携带、路由query。我实际用的是二维码方式:每个桌台的二维码内容是一个URL,比如https://xxx.com/h5?tableId=12。顾客扫码进入后,Vue路由守卫里读取route.query.tableId,存到Pinia或SessionStorage,下单时把tableId传给后端。

这种设计有一个必须考虑的边界:顾客扫码后如果刷新页面,query参数会丢失。所以我在进入页面时先把tableId存到SessionStorage,后续刷新也能读到。别用localStorage,因为顾客换桌或者重扫后旧桌号会被错误带过去,Session在当前标签页生命周期内保留更合理。

4.4 路由权限与动态路由

管理后台的路由权限可以做得简单也可以做得很复杂。我这个项目里的方案是静态路由 + 路由守卫校验登录状态 + 菜单按角色动态渲染,没有做后端返回动态路由表的高级玩法,因为餐厅角色就三种,权限差异很小,做动态路由属于过度设计。

路由守卫示例:

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path === '/login') { next() return } if (!token) { next('/login') return } if (to.meta.roles && !to.meta.roles.includes(store.user.role)) { next('/403') return } next() })

这里要注意,Vue路由有两种模式:hash和history。开发时用默认的hash模式没有太多坑,但生产环境如果要用history模式,Nginx必须做try_files配置,否则刷新页面就会404。我管理后台用的history模式,Nginx配置如下:

location / { try_files $uri $uri/ /index.html; }

顾客H5因为在微信内打开,hash模式反而更省心,不容易出现奇怪的刷新问题,我就保留了hash模式。

4.5 菜品管理与图片上传

管理后台的菜品管理页面是典型CRUD:表格展示、新增/编辑弹窗、删除确认、分页搜索。图片上传这里我踩过一个较大的坑:一开始直接把图片Base64编码存数据库,一张几MB的图片就能把数据库撑爆,接口响应慢到崩溃。

后来改成文件上传方案:前端用Element Plus的el-upload组件把图片传到后端,后端保存到本地磁盘或云存储,数据库只存访问路径。刚开始我用本地磁盘存储,部署后图片路径写死成本机地址,换服务器就全部失效。后来我把图片存储抽出来,用MinIO做对象存储,Docker一行命令就能启动,和SpringBoot整合也很顺畅。

@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { if (file.isEmpty()) { return Result.error("上传文件不能为空"); } String fileName = UUID.randomUUID().toString() + "." + StringUtils.getFilenameExtension(file.getOriginalFilename()); // 存储文件 try { file.transferTo(new File(uploadPath + fileName)); return Result.success(fileName); } catch (IOException e) { log.error("文件上传失败", e); return Result.error("上传失败"); } }

图片存储这一块选择空间很大:本地磁盘、MinIO、阿里云OSS、腾讯云COS都可以。毕设和课程设计就用本地磁盘或MinIO最简单,商用项目建议直接上云OSS,省去自己处理备份和CDN的麻烦。

5. 环境配置与部署上线:从本地跑通到服务器落地

5.1 开发环境配置的核心要点

先把开发环境踩坑清单列一下:

数据库连接:application.yml里spring.datasource.url必须加useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai,否则中文乱码、时间差8小时的问题全来了。

spring: datasource: url: jdbc:mysql://localhost:3306/restaurant?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai redis: host: localhost port: 6379

端口冲突:SpringBoot默认8080,如果本地已经被占用,可以在配置里写server.port: 8081,或者用随机端口server.port: 0(但调试时不建议,每次启动端口都在变)。

热部署:这属于开发体验改善项,spring-boot-devtools可以实现修改代码后自动重启,但注意它和某些实体类缓存的兼容问题。如果你想省事,直接用IDEA的JRebel或者干脆手动重启也完全没问题。

5.2 前端联调:解决跨域问题

开发环境跨域是前后端分离项目第一个拦路虎。前端跑在5173端口,后端跑在8080端口,直接Ajax请求必然报跨域。

方案一:后端配置CORS。SpringBoot里写一个配置类,允许指定前端地址跨域访问。这个方案能用,但生产环境每加一个域名就得改一次,不够优雅。

方案二(推荐开发环境用):Vite代理。在vite.config.js里配置:

export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } } })

前端代码里所有接口都写成/api/...开头,Vite开发服务器帮你去请求后端,浏览器端不存在跨域。生产环境把baseURL换成Nginx反向代理的路径就行,后端接口不用改,前端代码也不用改。

5.3 生产部署:Jar包 + Nginx + Docker

后端打包成可执行Jar包:

mvn clean package -DskipTests java -jar restaurant-server.jar --spring.profiles.active=prod

生产环境我推荐用Docker部署,省去安装JDK、MySQL、Redis的麻烦。写一个简单的Dockerfile:

FROM openjdk:8-jre-alpine COPY restaurant-server.jar /app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "/app.jar"]

构建镜像命令:

docker build -t restaurant-server . docker run -d -p 8080:8080 --name restaurant-server restaurant-server

前端构建:

npm run build

把生成的dist目录丢到Nginx的html目录,加上反向代理配置,一个完整可访问的系统就上线了。这里包含一个常常被忽略的细节:后端接口路径如果带/api前缀,Nginx代理时要一起转发,不能把前缀截断,否则后端路由找不到对应接口。

6. 常见问题与排查技巧实录

6.1 LocalDateTime序列化问题

后端实体类用LocalDateTime而不是Date,返回给前端时默认序列化格式是一长串数字时间戳,或者变成"2025-01-01T10:00:00"带T的ISO格式,前端显示非常丑。

解决方案有两种:一种在实体字段上加@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss"),另一种全局配置Jackson:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai

注意全局配置对LocalDateTime不生效,必须单独注册Jackson的自定义序列化器(使用JavaTimeModule时DateFormat不覆盖LocalDateTime)。我在项目里写了一个配置类注册LocalDateTimeSerializer:

@Configuration public class JacksonConfig { @Bean public Jackson2ObjectMapperBuilderCustomizer customizer() { return builder -> { builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); builder.deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }; } }

6.2 接口报400/401/404的排查思路

联调阶段最烦的就是接口各种状态码:

  • 400 Bad Request:多半是参数类型不匹配,比如后端要Long,前端传了字符串。优先看控制台日志里具体的类型转换异常。
  • 401 Unauthorized:Token没带或者过期。检查请求拦截器是否正确加上Authorization头,检查JWT密钥是否一致(开发环境和生产环境密钥不同也会突然全部401)。
  • 404 Not Found:后端路径没对上。SpringBoot Controller的@RequestMapping路径与前端请求路径必须完全一致,大小写、斜杠都不能错。

6.3 并发测试:多用户同时下单怎么办

单机开发时很难暴露并发问题,但餐厅场景中午高峰期肯定有多桌同时下单。我用JMeter做过简单的并发测试,发现两个高频问题:

第一,扣库存超卖,这个前面已经用乐观锁SQL解决了。

第二,数据库连接池耗尽。默认HikariCP连接池最大连接数只有10,并发高时容易报connection is not available。在配置里适当调大:

spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10

第三,订单号重复问题,在order_no上建唯一索引后,手动捕获DuplicateKeyException并重新生成订单号即可。我实际的做法是订单号用“yyyyMMddHHmmss + 3位随机数 + userId后4位”,撞单概率几乎为零,但以防万一还是加了唯一索引。

6.4 前端移动端适配与微信内置浏览器问题

顾客H5要考虑多种手机屏幕,我用了Vant之后基本能自适应。但有几个坑是微信内置浏览器特有的:

  • 微信内置浏览器不支持像普通浏览器那样直接唤起微信支付,必须走微信支付的JS-SDK流程,需要公众号AppID和商户号。毕设阶段我直接用“模拟支付”按钮跳转过去,真实商用再接入微信支付。
  • 如果后续要在H5里做视频展示(比如后厨直播或者菜品制作过程),微信内置浏览器的视频播放有x5-playsinline属性要求,M3U8格式的直播流在 iOS 上支持较好,Android 端经常需要自定义播放器。这不是点餐系统核心,我暂时没深入研究,提一句供有需求的人参考。
  • 如果涉及配送定位(以后扩展外卖场景),可以用腾讯地图JS SDK在H5里做定位和路径展示,但注意需要在腾讯地图开放平台申请Key,并且域名要提前配置白名单。

6.5 数据库时区问题

这真的是个经典老坑。数据库连接串里没有设置serverTimezone时,插入的时间比实际时间少8个小时。我排查时第一反应是Java代码写错了,后来才发现是MySQL连接串少了时区参数。

解决方式就是在JDBC URL里加上serverTimezone=Asia/Shanghai,并且在实体类中统一使用LocalDateTime类型,不要混用java.util.Date和java.sql.Timestamp,混用会导致时间在很多框架里被重复转换,结果错上加错。

6.6 后端启动失败与版本兼容性问题

SpringBoot版本和JDK版本不匹配是新手常见问题。比如SpringBoot 3.x要求JDK 17以上,你用JDK 8跑就必然报错。要是你手头JDK版本太低,就老老实实用SpringBoot 2.7.x,不要盲目追求新版本。

还有一类问题是依赖版本冲突,典型的是MyBatis-Plus和SpringBoot版本不兼容导致启动时Invalid bound statement。我建议使用MyBatis-Plus的spring-boot-starter时,选择与SpringBoot版本匹配的MP版本,比如SpringBoot 2.7对应MyBatis-Plus 3.5.x,SpringBoot 3.x对应MyBatis-Plus 3.5.5+。

7. 扩展思路:像真实项目一样继续演进

如果你做完核心功能还想加分,这里有几个方向可以选,按投入产出比排序:

  • 接入真实支付:微信支付 Native 扫码支付,顾客在H5下单后调起微信支付收银台。难度中等,需要注册商户号,文档齐全,做出来简历含金量高。
  • 打印机对接:后厨58mm热敏小票打印机,通过云打印服务(如飞鹅云)实现下单自动打印小票。成本低体验好,很多小餐馆极其需要。
  • 数据统计图表化:管理后台用ECharts做营业额折线图、菜品销量排行饼图、客流高峰时段分析。代码量不大,但视觉冲击力强,答辩时也更好讲。
  • 会员积分系统:消费累计积分,积分抵扣。需要加积分流水表,业务逻辑难度不大,但对进销存的理解能提升一个档次。

8. 最后的经验分享

我前前后后做过三套餐厅点餐系统,第一套从数据库建模开始就是乱的,订单状态想到哪加到哪,最后统计营业额时不是多算就是漏算。第二套学乖了,先把状态机画清楚,把“待支付到已支付、已支付到制作中、制作中到已完成”的流转边界固定下来,后面所有代码都跟着状态机走,业务就稳了大半。

还有一点,做这类全栈项目别想着一步到位。我的习惯是先跑通“扫码 -> 看菜单 -> 加购 -> 下单 -> 后厨看到订单”这个最核心的闭环,再回头补管理系统、统计报表、权限这些辅助功能。核心链路通了,信心就有了,后面就是锦上添花。

另外,如果你拿到了一个老项目源码,想改成自己的毕设或者商用项目,可以直接把Jar包用工具反编译成工程文件(市面上有 JD-GUI、Luyten,以及IDEA自带的反编译插件),但反编译出来代码结构可能丢注释,实体关系得自己慢慢理。前提是你对这个项目有合理的使用授权,不要拿别人的代码直接提交说成原创,该懂得避雷还是得懂。

最后还是那句话:做项目遇到问题不可怕,多打日志、多翻官方文档、多在调试器里断点走一遍,百分之八十的问题都是自己写的时候没想清楚。这套点餐系统的技术栈不算新,但把每一个模块都做扎实了,你收获的就是一套真实可用的企业项目经验,远超背一百道面试题。

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

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

立即咨询