☰
扫码点餐系统全栈拆解:SpringBoot+OAuth2+uniapp前后端分离实战
2026/10/1 10:38:45 网站建设 项目流程

简介:这是一套面向高校毕业设计或课程大作业的「意向点餐(扫码点餐)系统」前后端分离项目。项目采用 SpringBoot 与 Spring Security OAuth2 作为后端认证及接口框架,前端使用 uniapp(Vue3)开发,可编译到 H5 与微信小程序,并覆盖外卖与自取点餐、多门店管理等常见业务模块,能直接作为 Java 小程序方向的设计参考或二次开发底稿。压缩包内共 2000 个文件,以 Java 源码(1323 个)和 Vue 前端文件(257 个)为主体,辅以 JS、XML、HTML、CSS、SQL 等配置文件与脚本,整体体积 15.09MB,目录划分清晰,便于按模块定位代码与配置。目前已有 403 人学习或下载,适合需要从零搭建点餐系统、梳理 OAuth2 认证流程或完成小程序端与后台联调的开发者。通过阅读源码与 SQL 初始化脚本,可快速复现扫码点餐、订单流转、门店切换等核心链路;再结合前端页面,能直观理解 uniapp 多端打包与 SpringBoot 接口对接的完整过程。

1. 扫码点餐真的不只是“点个菜”:这套前后端分离系统到底装了什么

一个“扫码点餐系统.zip”解压之后,往往不是点开就能跑的前台 demo,而是一整套前后端分离工程:后端是 SpringBoot + Spring Security OAuth2,前端是 uniapp(vue3),同一套代码能编译成微信小程序,也能跑 H5。对要做毕业设计或者想完整走一遍全栈流程的人来说,它的价值不在于“点餐”这个业务有多新奇,而在于它把微信登录、多门店隔离、外卖/自取订单流转这几个高频模块串在了一起,每一个都是简历上能写、面试时能聊的实打实的技术点。

拿到这种压缩包,第一件事不是急着配数据库,而是先把工程结构看清:哪个是后端、哪个是前端、配置文件里藏着哪些环境依赖。这个系统里的核心链路是:用户扫码进店 → 微信一键登录 → 浏览门店菜品 → 加购物车 → 提交外卖或自取订单 → 商家接单出餐。下面我按后端、小程序端、订单流程、避坑、联调验证的顺序,把整套资源拆开讲。

2. SpringBoot + OAuth2 后端底座:令牌怎么发、门店数据怎么写

2.1 为什么是这个组合:无状态令牌与微信生态的配合

扫码点餐这类业务,前端是微信小程序/H5,后端是独立 API 服务,天然适合前后端分离。小程序端每次请求 API 时,不可能像传统 Web 应用那样靠 Session 维持登录态——小程序没有 Cookie 概念,H5 跨域场景下 Cookie 也经常被浏览器拦掉。Spring Security OAuth2 在这里解决的就是“无状态登录”:用户登录成功后,后端签发一个 access_token,前端后续请求只要在 Header 里带上Authorization: Bearer <token>,后端就能通过令牌识别身份。

这套模式和小程序的微信登录能顺畅衔接:小程序端拿uni.login()得到的 code,向后端换 openid,后端用 openid 创建或绑定本地用户,再走自定义的 token 颁发流程。access_token 短期有效(比如 2 小时),refresh_token 长期有效(比如 7 天),小程序端发现 401 后主动用 refresh_token 换新,用户就无感知续期。

2.2 把认证服务搭起来:关键配置与代码

这个系统的后端认证核心是 Spring Security OAuth2。老版本项目常用@EnableAuthorizationServer直接声明授权服务器,新版则拆成了 Authorization Server 和 Resource Server 两套独立配置。无论哪种写法,核心逻辑都是三件事:定义 client 客户端、定义用户来源、定义令牌存储方式。

@Configuration @EnableAuthorizationServer public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.inMemory() .withClient("app-client") .secret("{noop}app-secret") .authorizedGrantTypes("password", "refresh_token") .scopes("all") .accessTokenValiditySeconds(7200) .refreshTokenValiditySeconds(604800); } @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) { endpoints .authenticationManager(authenticationManager) .userDetailsService(userDetailsService) .tokenStore(new InMemoryTokenStore()); } }

这段配置里有几个点要说明:password模式适合小程序端拿用户名密码换令牌,但扫码点餐场景更多是“手机号 + 验证码”或者“微信登录后换 token”,所以这里更常见的做法是自定义一个/oauth/token的扩展端点,在内部用 openid 直接生成令牌。refresh_token授权类型一定要保留,否则小程序端 token 过期后只能让用户重新登录。InMemoryTokenStore只适合开发和单机部署,生产环境建议换成 Redis 存储,否则重启后端所有在线用户都要重新登录。

用户来源这块,后端要提供一个UserDetailsService的实现,从数据库里查用户:

@Service public class UserService implements UserDetailsService { @Autowired private UserMapper userMapper; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userMapper.selectByPhoneOrOpenid(username); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return org.springframework.security.core.userdetails.User .withUsername(user.getPhone()) .password(user.getPassword()) .authorities("ROLE_USER") .build(); } }

这里有个实际坑:微信登录用户的密码字段可能是空的,password授权模式下UserDetailsService必然校验密码。所以这个系统里微信登录通常不走 password 模式,而是后端直接拿 openid 查用户,查到就发 token,查不到就先自动注册再发 token,绕开密码校验这一步。

2.3 多门店数据模型:门店 ID 要进每一张业务表

后端除了认证模块,最核心的就是多门店模型。这个系统支持多门店,意味着菜品、订单、购物车都不能做成全局一份。我见过不少人在这个点上翻车:订单表里没存门店 ID,导致 A 门店的订单流到了 B 门店的待处理列表里。数据模型上,门店表先独立存在,菜品表和门店是多对多或直接挂store_id,订单表必须冗余store_id字段——下单时从门店维度写入,查询时用store_id + 订单状态联合过滤,索引也要按这个组合建。

CREATE TABLE store ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, address VARCHAR(255), status TINYINT DEFAULT 1, business_hours VARCHAR(64), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, store_id BIGINT NOT NULL, name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, category_id BIGINT, image_url VARCHAR(255), stock INT DEFAULT 0, status TINYINT DEFAULT 1, KEY idx_store_category (store_id, category_id) ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, store_id BIGINT NOT NULL, user_id BIGINT NOT NULL, order_type TINYINT COMMENT '1-自取 2-外卖', status TINYINT NOT NULL, total_amount DECIMAL(10,2), address VARCHAR(255), contact_phone VARCHAR(20), remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_store_status (store_id, status), KEY idx_user (user_id) );

价格字段一定要用DECIMAL(10,2),不要用double。金额计算涉及精度,用浮点数会出现 0.1 + 0.2 = 0.30000000000000004 这种结果,订单金额对不上账就是从这里开始的。库存字段这里写的是stock INT,但实际扣库存必须配合乐观锁,下面讲订单流转时会再展开。

3. uniapp(vue3) 小程序端:从微信登录到购物车结算的一条链路

3.1 微信一键登录:从 uni.login 到 openid 的换取

小程序端不存密码,登录靠的是微信的 code 换 openid。前端第一步调用uni.login()拿到临时 code,然后把这个 code 发给后端;后端拿着 code 去微信接口换 openid,查库后签发自己的 token。这个链路里,后端要自己维护一个/api/auth/wx-login接口,前端只需要关注 code 的获取和 token 的保存。

async function wxLogin() { const { code } = await uni.login({ provider: 'weixin' }); const res = await request({ url: '/api/auth/wx-login', method: 'POST', data: { code } }); if (res.code === 0) { uni.setStorageSync('access_token', res.data.access_token); uni.setStorageSync('refresh_token', res.data.refresh_token); uni.setStorageSync('user_info', res.data.userInfo); } }

代码里的request是封装过的请求函数,不是uni.request裸调用。为什么要包一层?因为业务里除了登录接口,其他所有请求都要带 token、都要统一处理 401 和网络错误。如果每个页面都写一遍uni.request,光 Header 的拼接就能把人逼疯。

这里要提醒一个容易忽略的细节:uni.login拿到的 code 一次性有效,而且有效期只有几分钟。如果用户手机时间不准、或者后端拿 code 换 openid 时网络抖动,会报invalid code。常见做法是前端拿到 code 后立即请求后端,不要在中间夹其他异步操作。

3.2 请求封装与 token 静默续期:避免 401 死循环

封装请求层的核心目的有三个:统一带 token、统一错误提示、统一处理过期续期。小程序端的 token 续期逻辑不能写成“遇到 401 就跳登录页”,那样用户正点着菜突然被踢出去,体验极差。正确姿势是:请求返回 401 时,先检查本地有没有 refresh_token,有就静默调一次刷新接口,用新 token 重放刚才失败的请求。

function request(options) { return new Promise((resolve, reject) => { uni.request({ url: BASE_URL + options.url, method: options.method || 'GET', data: options.data || {}, header: { 'Authorization': 'Bearer ' + uni.getStorageSync('access_token'), 'Content-Type': 'application/json' }, success: async (res) => { if (res.data.code === 40101) { const refreshed = await refreshToken(); if (refreshed) { retryRequest(options).then(resolve).catch(reject); } else { uni.navigateTo({ url: '/pages/login/login' }); reject(res.data); } } else { resolve(res.data); } }, fail: (err) => { uni.showToast({ title: '网络异常', icon: 'none' }); reject(err); } }); }); }

这个封装里有两个关键参数:40101是后端约定的“token 过期或无效”的业务码,不能拿 HTTP 状态码 401 直接判断——因为如果网关或代理层先拦截了 401,前端根本拿不到后端业务响应。refreshToken()内部要防止并发:如果购物车有三个请求同时 401,三个请求都去刷新 token,会造成多次刷新、旧 token 还没失效就被顶掉。常见做法是加一个isRefreshing标志位,第一个请求刷新时把后面的请求先挂起,等新 token 回来后统一重放。

3.3 扫码进店与点餐页的数据绑定

扫码点餐的“码”本质上是一个带参数的 URL 或小程序码,里面最关键的就是storeId。用户扫码进入小程序后,前端从启动参数里解析出门店 ID,然后调门店详情接口拿到门店名、营业时间、公告,再调菜品列表接口。这个顺序不能反过来,没拿到门店 ID 之前就去拉菜品列表,后端会直接报“门店不能为空”。

购物车在小程序端适合放本地缓存,键名可以按门店维度隔离:

const CART_KEY = 'cart_' + storeId; function addToCart(product, quantity) { const cart = uni.getStorageSync(CART_KEY) || []; const idx = cart.findIndex(item => item.productId === product.id); if (idx > -1) { cart[idx].quantity += quantity; } else { cart.push({ productId: product.id, name: product.name, price: product.price, quantity, storeId }); } uni.setStorageSync(CART_KEY, cart); }

按门店维度存购物车是为了多门店场景:用户在 A 门店加了几道菜,又扫了 B 门店的码,两个购物车不能串。下单时再按当前进入的门店 ID 去取对应缓存。这里有个体验细节:结算页要同步展示“购物车清空时机”——下单成功即清空本地缓存,否则会出现“订单已提交但购物车还显示着”的诡异状态。

4. 多门店与订单流转:外卖/自取的差异不是换个字段那么简单

4.1 外卖与自取的业务差异清单

外卖和自取在数据库里可能只是一个order_type字段的差别,但业务流程上是两套逻辑。自取的履约路径是“用户到店 → 凭取餐码取餐”,外卖的履约路径是“骑手接单 → 送达到地址”。两者在订单字段、接单流程、配送费计算上都有明显差异:

业务维度自取外卖
用户地址不需要配送地址必须填收货地址
配送费0按距离或门店规则计算
预计时间用户自选到店时间系统估算配送时间
接单环节门店确认后出餐可能涉及骑手分配
订单取消出餐前可退骑手接单后限制取消

在实现上,自取订单的下单接口可以不接收地址字段,但外卖订单必须有完整的地址、联系人、电话校验。后端在做参数校验时就要按类型区分,不能一套校验走到底。很多半成品系统翻车就翻在这里:订单表给出了address字段,但外卖单没做非空校验,用户不填地址也能提交成功,门店端收到一个没法配送的单子。

4.2 订单状态机设计与状态流转

订单状态是这个系统的核心业务逻辑,设计上要覆盖从下单到完成/取消的全生命周期。这个系统里的状态流转大致是:待支付 → 待接单 → 制作中 → 待取餐(自取)/ 配送中(外卖)→ 已完成,另外还有已取消和退款中两个负向状态。

public enum OrderStatus { WAIT_PAY(0, "待支付"), WAIT_CONFIRM(1, "待接单"), MAKING(2, "制作中"), WAIT_PICKUP(3, "待取餐"), DELIVERING(4, "配送中"), COMPLETED(5, "已完成"), CANCELED(6, "已取消"), REFUNDING(7, "退款中"); private final int code; private final String desc; }

状态流转要放到后端统一封装,不能由前端随意跳转。前端只能按下单、取消、确认收货这类操作按钮,后端根据当前状态判断能不能执行。比如“已完成的订单不能取消”“待支付订单不能直接跳到配送中”。一个容易踩的坑是:订单支付成功后,通知门店的逻辑要幂等——用户支付成功的回调可能因为网络超时被微信重试多次,如果每次回调都执行一遍“改状态 + 通知门店”,门店会收到好几个重复的新订单提醒。常见做法是在回调逻辑入口处先查一次订单状态,只有待支付状态的订单才执行支付成功后的流程。

4.3 并发下单与库存防超卖:不只是在代码里减 1

点餐系统的库存和电商不完全一样,菜品取消率更高,但“限量菜品卖超了”这件事一样会发生。扣库存的代码如果写成先查库存、再判断、再更新,两个请求同时进来就可能都通过检查:

// 错误示范:查-判-改分三步,中间有间隙 Integer stock = productMapper.selectStock(productId); if (stock > 0) { productMapper.decreaseStock(productId); }

并发场景下这个写法必出超卖。正确做法是用带条件的更新语句把“判断”和“扣减”合成一步:

UPDATE product SET stock = stock - 1 WHERE id = #{productId} AND stock > 0;

这条 SQL 的影响行数如果为 1,说明扣减成功;为 0 说明库存不足。下单接口里拿到影响行数后再去插入订单记录,库存不足直接返回“菜品已售罄”。另外,如果菜品允许退款、取消,扣掉的库存还要在订单取消时回补,回补操作同样要用条件更新,避免“取消订单”和“再次下单”同时执行导致库存变成负数。

多门店场景下,所有菜品操作都必须带上store_id作为查询条件。库存更新语句里如果不带门店条件,一个门店的菜品更新可能影响同 ID 的其他门店菜品,这就属于数据隔离没做干净。

5. 避坑与常见问题:部署跑不起来时先查这五个地方

5.1 解压后文件不完整,项目无法启动

现象:压缩包解压到一半报错,或者解压成功了但目录里找不到 pom.xml、manifest.json 等关键文件,IDE 打开后直接不识别。

原因:网上流传的资源包经常带伪加密标志,或者上传者用压缩软件分卷后没有合并完整。winrar、360 压缩、mac 自带归档工具对伪加密的处理逻辑不一样,有的解压工具能跳过伪加密直接读,有的则拒绝解压。

解决:压缩包先看文件大小,和正常工程比明显小很多就要警惕。用 7-Zip 打开后先点“测试”按钮,测试通过再解压。解压路径不要带中文和空格,D:\project\meal-system这种路径最稳。如果提示损坏但测试通过,先把文件复制到本地再解压,不要直接在 U 盘或共享目录里解压。

5.2 token 过期后刷新死循环,小程序白屏

现象:小程序运行一段时间后,所有请求都失败,页面空白,控制台一大堆 401。刷新页面后短暂正常,过一会儿又不行。

原因:access_token 过期后,前端请求里带的是旧 token,后端返回 401。如果 refresh_token 接口本身也要求带 access_token,或者刷新接口返回的也是 401,整个链路就卡死了。另外一个常见原因是后端是集群部署但 token 存在内存里,前一次请求打到 A 实例,刷新请求打到 B 实例,B 实例不认识这个 refresh_token。

解决:刷新接口自身必须在 Security 配置里放行,不能纳入 token 校验范围。多实例部署时令牌存储切换为 Redis。前端做好并发刷新控制,避免多个请求同时触发 refresh。用isRefreshing标志位,刷新过程中新进来的请求先排队,拿到新 token 后统一重放。

5.3 微信支付回调验签失败,订单一直待支付

现象:小程序端调起支付后钱扣了,但订单状态一直是待支付,门店端看不到新订单。

原因:最常见的是回调接口接收参数时用了框架的 JSON 自动解析,导致验签用的原文和微信服务器发出来的原文不一致。微信支付回调签名是基于原始 body 字符串计算的,框架把 body 转成对象再转回字符串后,字段顺序或格式变了,验签必然失败。

解决:回调接口用HttpServletRequest直接读原始 body 字符串,完成验签后再手动解析业务内容。同一个订单号要按幂等处理,避免重复回调导致状态错乱。沙箱环境中回调地址必须是公网可访问的 HTTPS 地址,本地联调时可以用内网穿透工具把回调转发到本地调试。

5.4 金额计算用了浮点数,账单对不上

现象:用户下单时菜品 9.9 元,两份结算显示 19.8 元,但数据库里订单金额变成 19.799999 之类的小数。

原因:后端或者前端数据库字段用了float/double,金额经过多次加减乘除后浮点误差累积。点餐系统里还有满减、配送费、打包费叠加,误差会越来越明显。

解决:数据库金额字段统一用DECIMAL(10,2),Java 后端用BigDecimal计算,前端展示时保留两位小数。涉及到折扣计算时,每一步都调用setScale(2, RoundingMode.HALF_UP),不要等最后一步再统一四舍五入。

5.5 H5 模式联调跨域,接口全部 404

现象:在微信开发者工具里跑小程序正常,但切到 H5 模式后所有接口都请求失败,浏览器控制台报 CORS 错误或请求 URL 变成相对路径打不开。

原因:H5 页面跑在http://localhost:8081,后端接口跑在http://localhost:8080,跨域了。微信小程序没有跨域限制,所以小程序端一切正常,容易被忽略。

解决:开发环境下用 vite 的 proxy 代理,把/api代理到后端地址,前端请求统一走相对路径/api/xxx。生产环境把前端构建产物放到 Nginx,由 Nginx 统一转发/api到后端服务,这样浏览器端就没有跨域问题。

6. 本地联调与交付验证:一套能复现的“开门三板斧”

拿到这套系统后,别急着改业务代码,先按固定流程验证整套环境能不能跑通。我一般分三步走,每步都能独立验证,出问题时能快速定位是前端还是后端的问题。

第一步,启动后端,看认证接口是否通。后端启动后先不走数据库,直接请求一下/oauth/token或登录接口,确认 Spring Security 配置没写错。用 curl 发一次带 client 信息的请求最直接:

curl -X POST http://localhost:8080/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=password&username=test&password=123456&client_id=app-client&client_secret=app-secret"

返回的 JSON 里有access_token和refresh_token字段,说明认证链路已经通了。这个步骤能过滤掉一半以上的“为什么小程序起不来”的问题——很多情况下后端根本没启动成功,前端白屏只是表象。

第二步,起前端 H5,验证代理和登录跳转。在 uniapp 项目根目录执行npm install后再npm run dev:h5。浏览器打开页面,先看网络面板里请求是否走代理、是否带上了 token。如果登录接口通了,再扫码或者手动带storeId参数进门店页,拉一次菜品列表。这一步能验证前端到后端的数据链路是完整的。

第三步,模拟一单完整的“下单”流程。不进真实支付(本地没有商户号),直接调后端的模拟支付接口或者手动把订单状态改成待接单,然后确认门店端的订单列表能刷出来。这一步可以先直接把店里的菜品库存改成 1,然后用两个并发请求去抢购,验证防超卖逻辑是否生效。

curl -X POST http://localhost:8080/api/order/create \ -H "Authorization: Bearer <access_token>" \ -H "Content-Type: application/json" \ -d '{"storeId":1,"orderType":2,"items":[{"productId":1,"quantity":1}],"address":"某市某区某路 1 号","contact":"张先生","phone":"13800000000"}'

响应返回orderNo和totalAmount,去数据库里查对应的记录,核对金额、门店 ID、状态是否正确。

我带学生跑这个项目时,最怕的是他们拿到手就改配置、改代码,改到后面连哪步出的问题都说不清。从那以后,我每次拿到一个陌生系统,都强制先走一遍“后端认证 → H5 联调 → 模拟下单”这个固定流程,确认原始状态能跑通,再动手改业务。这套流程看着笨,但能省掉大量“我昨天还能跑今天就不行”的玄学排查时间。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询