刚整理完一套校园外卖服务系统的完整源码,跑通了用户点餐、商家接单、骑手配送、管理员管控四个角色的全流程。技术栈是当前Java后端和前端工程化里非常主流的组合:SpringBoot + Vue3 + MyBatis + MySQL,前后端完全分离。这个项目不是那种只写几个CRUD接口的玩具Demo,而是把校园场景里从用户下单到订单送达宿舍楼下的完整业务链路都落了地。如果你正在找一套能二次开发、业务逻辑完整的Java全栈项目,或者准备用它来做课程设计、毕业设计的底子,这篇文章会把整体架构、数据库建模、后端关键接口、Vue3前端实现思路,以及联调部署时踩过的坑,一次性讲透。
1. 项目整体设计与技术选型思路
1.1 为什么是SpringBoot + Vue3 + MyBatis + MySQL
先说结论:这套组合不是最新潮的,但一定是校园类业务系统里最稳、最容易招人接手、也最容易在答辩时讲清楚的方案。
后端选SpringBoot,核心原因有三个。第一是生态成熟,SpringBoot 2.x对JWT、Redis、MyBatis、文件上传、参数校验这些常规操作的集成基本是零配置起步,开箱即用。第二是内置Tomcat,一个java -jar就能把服务跑起来,部署成本低,对于校园外卖这种单体应用规模的系统完全够用。第三是社区资料极多,学生开发者在网上能搜到几乎所有问题的现成答案,后期维护不会卡壳。
前端选Vue3,是因为Composition API在组件复用和业务逻辑抽离上的表现比Vue2的Options API好太多。外卖业务里有大量“商品列表选规格加入购物车”“订单状态倒计时”这类需要共享状态和响应式更新的场景,用Vue3的Setup语法配合Pinia,代码组织起来非常清晰。加上Vite做开发服务器,热更新速度快,调试体验比Webpack时代的Vue工程上一个台阶。
MyBatis这个选择很多人问我为什么不用MyBatis-Plus。说实话,在这套系统里我刻意保留了原生MyBatis。校园外卖的订单查询、报表统计、商家销量排行这类SQL本身就不复杂,写成XML里的自定义SQL反而更直白,方便学习SQL优化。MyBatis-Plus对单表CRUD的提升很大,但真到多表联查和复杂聚合时你还是得写XML,不如一开始就习惯这种半ORM的写法。数据库用MySQL,免费、通用、校园服务器环境几乎都能装,主键用自增ID,行锁和事务能力应付外卖场景绰绰有余。
1.2 前后端分离的架构优势
这套源码采用了标准的Restful前后端分离结构。后端只暴露/api/**形式的JSON接口,前端通过Axios统一请求。这样做最大的好处是开发职责解耦:两个人同步开发时,后端只需要把接口文档定义清楚,前端拿着Mock数据就开工,不用等对方环境跑通。
部署上也是真分离,前端Vue项目打包成静态文件后由Nginx托管,后端SpringBoot跑在独立端口。这样的好处是静态资源和动态接口互不干扰,前端打新版本时不需要重启后端,后端发版时前端页面也不用重新构建。对于校园外卖这种会频繁调整前端UI的需求,这个优势非常实际。
1.3 业务流程与领域模型划分
我设计这套系统时先画的不是表结构,而是业务流程图。核心链路非常清晰:
用户在Web端浏览入驻商家,把菜品加入购物车,确认订单并选择配送地址(通常是宿舍楼栋和楼层),完成“下单”后商家端收到新订单提醒,商家接单后开始出餐,餐品做好后商家点击“通知配送”,骑手完成取餐并配送到宿舍楼下,用户最终确认收货。订单状态沿着“待支付 → 待接单 → 待取餐 → 配送中 → 已完成”这条线推进,中间还有“已取消”“退款中”两个异常分支。
围绕这条主链路,系统拆分出四个领域角色:用户端负责浏览、下单、支付、评价;商家端负责商品管理、订单接单、出餐状态更新;骑手端负责接配送单、更新配送状态;管理员后台负责审核商家入驻、查看经营数据、处理用户反馈。这种按领域拆模块的方式,比按功能拆模块要好维护得多。比如“订单”这个概念,用户端看到的是“我的订单”,商家端看到的是“门店订单”,骑手端看到的是“配送任务”,同一个订单在不同的业务上下文里有不同的字段展示和操作权限,如果一开始不按领域划分清楚,代码就容易写成一团乱麻。
2. 核心功能模块拆解与数据建模
2.1 用户端、商家端与后台的菜单式功能清单
整套系统的功能按角色拆,每个角色对应一套独立的前端页面和后端接口前缀。
用户端包含账号注册登录、商家列表浏览、按商品名或分类搜索、菜品详情查看、购物车新增删除修改、购物车结算、提交订单、模拟支付、个人订单列表、订单详情、取消订单、确认收货、发表评价,以及个人资料编辑。这部分是外卖系统的核心体验,代码量占比最大。
商家端包含工作台概览、菜品新增编辑上下架、菜品分类管理、订单列表实时刷新、订单接单/拒单、出餐完成通知、配送状态跟踪、当日营业额统计、门店信息编辑。商家端对实时性要求比用户端高,因为订单推送到商家端需要及时提醒。
管理后台包含商家入驻审核、用户管理、商家管理、骑手管理、订单全览与异常处理、评论管理、数据统计报表、系统公告管理。这个后台不需要太花哨,数据表格为主,重点是权限隔离和操作留痕。
2.2 数据库表结构设计
数据库设计是这套系统里最见功夫的部分。一共设计了14张表,核心表包括用户表、商家表、菜品表、分类表、购物车表、订单表、订单明细表、配送地址表、评论表、骑手表、骑手配送单表、公告表、操作日志表、管理员表。
其中订单和订单明细是绝对核心。订单表的主键我用了自增ID,配合order_no作为业务唯一流水号。order_no是前端展示给用户看的订单编号,它不等于主键。原因是主键自增ID容易被人通过遍历猜位数,而order_no生成规则是yyyyMMddHHmmss + 随机4位 + 用户ID后4位,既能保证业务上可读,又能避免接口层暴露具体数据规模。
订单表的关键字段包括:user_id、merchant_id、order_no、total_amount、status、payment_status、delivery_type、remark、created_at、paid_at、completed_at。这里特别说一下delivery_type字段,我设计成可选项,支持“配送到楼下”和“到店自取”两种模式,这样系统适用范围更广,不用改表结构就能支持堂食外带场景。
订单明细表是订单表的子表,一条订单对应多条明细,核心字段包括:order_id、dish_id、dish_name(冗余)、dish_image(冗余)、price(快照)、quantity、subtotal。菜品名称、图片和价格这三个字段一定要做冗余存储。原因很简单,菜品表数据是可能被商家修改的,如果订单明细只存菜品ID,用户历史订单里显示的是修改后的商品名和价格,这会直接引发投诉。存量订单快照,这是外卖系统设计的常识,实践里踩过坑才记得住。
2.3 关键索引与事务设计思考
数据库索引我是这样安排的:订单表上建了(user_id, created_at)联合索引,因为用户查询“我的订单”基本都按时间倒序翻页;建了(merchant_id, status)联合索引,因为商家端要按状态过滤订单;订单明细表在order_id上建普通索引。购物车表用(user_id, dish_id)作为业务唯一约束,防止同一用户同一菜品在购物车里出现两条重复记录。
事务处理上,下单这个动作被设计为一个大事务:扣减购物车商品、生成订单主记录、批量生成订单明细、扣减商家菜品库存、记录订单状态流转日志,这五步要么全部成功要么全部回滚。Spring中用@Transactional注解实现,注意要指定rollbackFor = Exception.class,否则运行期异常才回滚,业务异常和自定义异常可能导致事务不生效,这个细节很多初学者会漏。
3. 后端服务实现:关键接口与业务逻辑
3.1 统一响应体与全局异常处理
后端接口返回结构我统一用了一个Result<T>类,包含code、message、data三个字段。成功时code为200,业务失败时code为400或401,系统异常时code为500。前端Axios拦截器里首先判断code,再做后续处理,而不是每次请求都去判断HTTP状态码。
全局异常处理用@RestControllerAdvice实现。我自定义了BusinessException、UnauthorizedException、ForbiddenException三个异常,分别处理参数或业务校验失败、登录态失效、权限不足。全局异常处理器捕到后统一转成Result对象返回。这么做的意义在于Controller里的代码可以非常干净,不需要写一堆try-catch块,业务校验失败直接throw new BusinessException("库存不足")就行。
3.2 JWT登录认证与权限控制
登录认证用的是JWT方案,不是Server Session。原因一是前后端分离后Session处理跨域复杂,二是JWT无状态,后端接口天然支持分布式横向扩展,后面就算拆微服务也不用改认证机制。
JWT加密用HMAC-SHA256算法,密钥在application.yml中通过环境变量注入。Token里只放了userId、role、expiresAt三个关键信息,不放手机号和密码。有效时间设置为7天,用户点击“记住我”时延长到30天。
权限控制环节,我定义了一个AuthInterceptor拦截器,在SpringMVC注册时指定拦截/api/**。拦截器里做三件事:校验Token签名是否有效、校验Token是否过期、将解析出的用户信息放入ThreadLocal供后续业务代码获取当前登录用户。角色权限方面,后端接口URL设计为/api/user/**、/api/merchant/**、/api/rider/**、/api/admin/**,在拦截器里检查当前用户角色是否匹配URL前缀,不匹配直接返回403。这个方案虽然粗暴但非常直观,校园级别系统完全够用。
3.3 下单接口与订单状态机实现
下单接口是核心中的核心。先看完整流程:
@PostMapping("/api/order") public Result<Long> createOrder(@RequestBody @Valid CreateOrderDTO dto) { Long userId = UserContext.getUserId(); return Result.success(orderService.createOrder(userId, dto)); }OrderServiceImpl里的核心逻辑分五步:
第一步校验配送地址是否属于当前用户,且地址状态有效。
第二步查询购物车数据,按商家维度分组。这个很关键,一个订单只能属于一个商家,如果用户同时把A商家和B商家的菜品加入购物车,下单时必须提示用户分开下订单。我把下单请求设计为merchantId参数,前端在下单前会先按商家分组,用户选择某个商家后只提交该商家的商品。
第三步校验每个菜品是否在售、库存是否充足。这一步要用SELECT ... FOR UPDATE对菜品库存行加锁,防止两个下单请求同时扣减库存。
第四步生成订单主记录和明细记录,同时扣减库存、清空购物车中对应商家的商品。
第五步记录订单状态流转日志,状态从PENDING_PAYMENT开始。
@Transactional(rollbackFor = Exception.class) public Long createOrder(Long userId, CreateOrderDTO dto) { // 1. 校验地址 // 2. 购物车按商家分组校验 // 3. 菜品库存行锁校验 // 4. 插入订单主记录 + 明细,扣库存,清购物车 // 5. 写入订单状态日志 }订单状态机我建议用常量或枚举类定义,不要用魔法值。系统中所有订单操作,接单、出餐、配送、确认收货、取消、退款,都必须调用OrderStateMachine类里的方法流转状态。状态机里校验当前状态是否允许目标转换,例如“已完成”的订单不能被取消,“已取消”的订单不能被接单,这一步用状态机统一管理比到处散落if-else判断要安全得多。
3.4 购物车、库存与模拟支付
购物车接口实现不复杂,核心是防止重复添加。插入前先查(user_id, dish_id)是否存在,存在则更新数量,不存在则新增。
模拟支付是我这套系统特意保留的一个模块。因为真实接入支付宝或微信支付需要商户资质和繁琐的签约流程,课程设计和毕设场景没必要卡在这一步。我实现了一个MockPaymentController,接收orderId后生成支付成功回执,更新订单状态为PAID,同时向商家端推送WebSocket消息提醒新订单。这里的核心逻辑是支付后要触发后续流程,而不只是改个状态字段。
4. 前端Vue3实现:页面组织与核心交互
4.1 Vue3工程结构与路由设计
前端工程的结构如下,按角色分包:
src/ api/ # 按模块封装的请求接口层 assets/ # 静态资源 components/ # 通用组件 layouts/ # 布局组件,区分用户端和商家端 router/ # 路由配置 stores/ # Pinia状态管理 views/ user/ # 用户端页面 merchant/ # 商家端页面 admin/ # 管理后台页面 common/ # 登录注册等公共页面 utils/ # 工具类路由守卫是整个前端权限控制的关键。在router.beforeEach里做了三件事:从Pinia读取用户状态,没有则从localStorage恢复;检查目标路由的meta.requiresAuth和meta.role字段;不满足条件则跳转到登录页或401页面。这里有一个开发者容易漏掉的细节:后端JWT过期后,前端请求会收到401响应。处理方式是在Axios响应拦截器里统一清空用户状态,并跳转登录页,而不是在某个页面组件里一个个处理错误状态。
4.2 API封装与Pinia状态管理
Axios实例在创建时统一配置了baseURL为/api(开发环境走Vite代理),请求拦截器里把localStorage中的Token塞入Authorization请求头,响应拦截器里统一处理code非200的业务错误和401登录失效。
状态管理用Pinia而不是Vuex,理由刚才说过,Composition API配合Pinia的Composition风格写法逻辑更直观。用户信息userStore存当前登录用户、Token前缀、角色;cartStore存购物车列表和增加删除操作,购物车状态是所有用户端页面共享的,所以放在Pinia而不是组件内部。底部导航栏、菜品详情页、购物车页面都会同时读取购物车数量,如果不用全局状态,会非常难维护。
4.3 核心页面交互实现
用户端首页是一个商家列表,进入商家详情页后左侧是菜品分类,右侧是菜品列表。菜品卡片上有“加入购物车”按钮,点击后请求后端接口,成功后更新购物车。商家详情页顶部固定展示购物车总价和结算按钮。这里的交互细节是,Vue3中菜品列表和购物车之间的数据同步,通过cartStore里的action统一更新,返回最新数量后驱动UI响应,而不是在菜品组件里直接修改购物车数据结构。
下单确认页用了Element Plus的Steps组件展示订单状态进度条,配送地址选择了附近宿舍楼栋的静态数据模拟,实际落地可以接入高德地图API或腾讯位置服务。倒计时组件在订单支付页实现,前端倒计时到0后向后端请求一次订单状态,如果仍未支付则显示“订单已取消”并禁用支付按钮。
5. 联调部署与常见问题排查实录
5.1 前后端联调中的跨域与Mock问题
开发阶段最常出现的坑是跨域。解决方式不在后端配CORS,而是在Vite的server.proxy里配置代理:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, } } }这样前端请求/api/user/login时,Vite会代理到http://localhost:8080/api/user/login。之所以推荐代理而不是后端放开CORS,是因为生产环境前端静态文件由Nginx托管,同样配置一层location /api的proxy_pass就能解决生产跨域,代码上不用做任何切换。写过前后端分离项目的人都知道,把跨域处理逻辑放在架构层,业务代码才够干净。
5.2 生产环境打包部署流程
前端打包命令是npm run build,生成dist目录,把目录下所有文件拷贝到服务器的/usr/share/nginx/html下。Nginx配置关键段:
location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }后端打包用mvn clean package -DskipTests,生成jar包后用nohup java -jar启动。注意SpringBoot的application-prod.yml要区分数据库连接、Redis地址和JWT密钥,不能把开发环境配置带上去。数据库方面,首次启动时执行初始化SQL脚本,脚本中建库建表并写入管理员账号和示例商家数据。
5.3 高频问题与排查方法速查
这里整理我实际开发中遇到频率最高的问题和解决思路。
第一个是下单并发导致库存超卖。排查思路是在菜品表加上stock字段后,更新SQL语句必须带上库存条件判断:UPDATE dish SET stock = stock - 1 WHERE id = ? AND stock > 0,如果影响行数为0说明库存不足,直接抛异常回滚。单纯靠Java代码先查后改,并发下一定会出问题。
第二个是订单状态错乱。比如用户提交了取消申请,同时商家点了接单。这个问题出在状态更新没有加条件。正确的更新方式是在UPDATE order SET status = ? WHERE id = ? AND status = ?,用数据库状态作为乐观锁,更新行数为0再抛异常,避免出现状态被覆盖。
第三个是WebSocket连接频繁断开。我的方案是前端加了心跳检测,每30秒发送一次ping字符串,后端收到后返回pong,连续三次收不到心跳则服务端判定连接失效并清理连接。网关这种场景如果不做心跳,Nginx的默认空闲超时60秒会直接断开连接。
第四个是Vue3组件重复请求导致页面卡顿。商家端订单列表因为要实时刷新,最初我做的是每3秒轮询一次接口,后来改成WebSocket推送新订单,只有收到新订单消息时才触发重新拉取列表,服务端压力和前端渲染压力都明显下降。
第五个是数据库连接池溢出。MySQL默认最大连接数是151,SpringBoot的HikariCP默认池大小是10。如果并发测试时看到Connection is not available的报错,基本就是池被耗尽。排查时看HikariPool的active连接数和线程栈,找到哪个接口持有连接不放。常见原因是事务方法内部调用的方法捕获了异常但没回滚,导致连接被事务占住,解决方式是确保事务方法内所有数据库操作都在同一个try-catch逻辑内,不要私自吞掉异常。
5.4 这套源码后续可以扩展的方向
如果拿到这套源码想继续往下做,我个人的建议会接这三个方向:一是接入高德地图API,把配送地址从“手动选择楼栋”升级为地图选点,骑手端也能看到配送路线;二是增加营销模块,加入优惠券、满减活动、新客立减,这类功能对表结构是加两张表和几个校验接口的事;三是把订单模块里的消息推送换成更正式的推送服务,比如接入第三方短信服务,下单成功、商家接单、骑手出发时给用户发站内信或模板消息。做好了这三个方向,整个系统的完整度基本接近商业项目。
我在实际工具使用中有个体会:这套系统源码的数据库建表脚本是整套资产里最值钱的部分。很多开发者拿到源码喜欢先跑前端、先研究接口,我会反过来让他们先打开SQL脚本,把每张表的字段、注释、索引、外键关系读懂,再去读后端代码。表结构就代表业务模型,业务模型看透了,接口设计和前端页面就都能顺理成章地理解。这也是我做这套系统复盘时最深的体会。项目所有的表设计、状态流转、权限控制都是围绕“订单生命周期”展开的,只要抓住这条主线,后面增加任何功能都不会跑偏。