
校园外卖这个方向这两年咨询量和实操量都明显上来了。很多计算机专业的学生、刚转行的Java开发都会把它当作第一个完整全栈项目来练手。市面上打着“校园外卖”旗号的源码很多但真正能跑通、能写进简历、能讲明白的并不多。这篇博文我就以一套基于 SpringBoot Vue 的校园外卖系统为例把从技术选型、数据库设计、前后端核心实现到部署联调、常见坑点和二次开发思路完整拆开来讲。这套系统我前后完整复现过也帮人排查过不少问题下面写的都是实际能落地的经验和细节。不管你是准备做毕业设计还是想积累一个靠谱的全栈项目这篇文章都值得你花十分钟看完。1. 项目整体定位与技术选型1.1 为什么是 SpringBoot Vue 的组合校园外卖系统本质上是一个典型的互联网应用用户端需要浏览商品、下单支付、跟踪订单商家端需要管理商品、接单出餐管理端需要审核商家、处理投诉、做数据统计。这种“多角色 多端 核心交易流程”的项目形态非常适合用前后端分离架构来做。后端选 SpringBoot理由非常直接它把 Spring 生态里大量的配置自动化了内置 Tomcat一个 main 方法就能启动服务配合 MyBatis-Plus 做数据访问开发效率比传统 SSM 高一个档次。而且 SpringBoot 在 Java 岗位的招聘要求里几乎是标配选它来做项目对求职和毕设答辩都有实际帮助。前端选 Vue同样基于两个考虑一是 Vue 的上手曲线比 React 平缓对于以 Java 为主要方向的同学来说用 Vue 写页面能更快出成果二是 Vue 配合 Element UI 或者 Element Plus做后台管理类页面非常顺手表格、表单、弹窗、分页这些组件都是现成的不需要从零造轮子。我见过不少人纠结要不要微服务、要不要上 Redis 集群、要不要搞消息队列。我的观点很明确校园外卖系统作为单体项目用 SpringBoot Vue 就够了。微服务不是不能学但别在主项目里硬上否则后期维护成本和面试时的解释成本都会成倍增加。1.2 系统角色与核心功能拆解这套校园外卖系统我按角色拆成三大端外加一个公共模块用户端小程序/H5/Web用户注册登录、浏览商品分类、加入购物车、提交订单、在线支付模拟或接入沙箱、订单状态跟踪、个人地址管理、评价订单。商家端Web商家入驻申请、商品上下架、库存管理、订单接单/拒单、出餐状态更新、营业数据看板。管理端Web商家审核、用户管理、订单全量查看、分类管理、公告发布、销量统计。平台侧的公共能力包括文件上传商品图片、短信验证码登录/注册、定时任务自动取消超时订单、权限拦截JWT 认证 角色鉴权。功能范围画清楚之后再去设计数据表、接口就不会出现东一榔头西一棒子的情况。这也是很多新手写项目时最容易忽略的一步拿到题目就建表建完表就写接口写到最后发现这缺一个字段、那少一张表返工成本极高。2. 数据库设计与核心表结构2.1 八张核心表的字段设计与关联关系数据库是外卖系统的地基。我把这套系统的核心表按业务域拆成三组每组配套讲字段设计的理由方便你理解“为什么这么建”。用户域user用户表主键 id、用户名、密码BCrypt 加密存储、手机号、头像、性别、角色标识 role1 用户 / 2 商家 / 3 管理员、创建时间。address地址表主键 id、用户 id、收货人姓名、联系电话、学校/校区、详细地址、经纬度、是否默认地址。校园外卖最关键的就是配送范围所以经纬度字段一定不能省后面做“当前定位能否下单”的判断要用。商家域seller商家表主键 id、店铺名称、店铺 Logo、联系电话、店铺公告、起送价、配送费、营业状态1 营业 / 0 休息、审核状态0 待审 / 1 通过 / 2 拒绝、经纬度。起送价和配送费一定要独立字段因为在订单表里要冗余一份快照防止商家改了之后历史订单数据对不上。category分类表主键 id、分类名称、排序、商家 id。这里要注意分类表需要跟商家关联不要搞成全局统一分类因为每个商家的商品分类差异很大。product商品表主键 id、商品名称、商品图片、价格、分类 id、商家 id、描述、销量、库存、是否上架状态。库存字段看起来很常规但在高并发秒杀场景下面临超卖问题后面我会讲我的处理方案。订单域orders订单表主键 id、订单编号、用户 id、商家 id、地址 id、总金额、配送费、实付金额、订单状态0 待支付 / 1 待接单 / 2 配送中 / 3 已完成 / 4 已取消、支付方式、支付时间、备注。订单状态是业务流转的核心建议用数字枚举而不是直接存中文状态字串便于扩展和统计。order_detail订单明细表主键 id、订单 id、商品 id、商品名称快照、商品图片快照、商品单价快照、数量、小计。把商品名称和单价冗余到明细表里是电商系统的经典做法——商品表里的信息随时可能改但订单里的历史信息不能变。cart购物车表主键 id、用户 id、商品 id、数量、选中状态、加购时间。这套表结构一共 8 张核心表外加公告表notice、轮播图表banner等辅助表能够覆盖校园外卖系统 90% 以上的业务场景。2.2 订单表设计中的一个关键细节金额精度与订单号钱的问题再怎么小心都不为过。MySQL 里金额字段我强烈建议使用DECIMAL(10,2)而不是FLOAT或DOUBLE。浮点数在计算机中本身就是近似存储算钱时会累积误差0.1 0.2 不等于 0.3 这个经典的二进制浮点问题在订单金额这种敏感场景是绝对不能出现的。订单编号建议做成“日期 随机数 自增序列”的形式例如yyyyMMddHHmmss 6位随机数。不要直接使用数据库自增主键作为订单编号暴露给用户一方面会泄露平台订单量另一方面也容易被恶意遍历。生成订单编号时要做的唯一检查是保证不重复随机碰撞的概率经过去重校验之后可以忽略。数据库连接层面默认配置utf8mb4字符集这个不只是为了存 emoji 表情——商品名称里可能出现特殊符号、用户备注里可能出现生僻字utf8mb4才能完整兼容。2.3 索引与事务设计的基本盘每张业务表的主键都要用BIGINT UNSIGNED AUTO_INCREMENT不要纠结要不要炫技用雪花 ID。说句实话校园外卖这个体量的项目自增主键完全够用而且对分页排序特别友好。索引是数据库性能的关键。我说几个最常用的orders表user_id建立普通索引用于个人订单列表查询seller_id建立普通索引用于商家接单列表查询status与create_time建立联合索引用于后台按状态和时间筛选订单。order_detail表order_id建立普通索引保证订单详情联查走索引。product表seller_id和category_id建立联合索引商品列表按商家和分类过滤时避免全表扫描。事务的控制核心放在两个方法上创建订单和订单支付。创建订单时要边扣库存边生成订单明细这两步操作必须放在同一个Transactional方法里一旦后续步骤抛异常前面插入的数据要全部回滚。订单支付后需要更新订单状态、记录支付流水、累加商家销量这三个写操作也应当是一个完整事务。提示Transactional并不是加在方法上就万事大吉。同类内部调用、异常被 try-catch 吞掉、抛出的是非 RuntimeException 类型这些情况下事务都可能导致不生效。排查这类问题最有效的手段是开启 MyBatis 的 SQL 日志观察是否有Rolling back日志输出。3. 后端核心模块的实现与避坑细节3.1 项目分层结构与统一返回体后端代码我采取经典的四层结构这种结构经过这么多年检验依然是中小型项目最稳妥的组织方式com.campus.order ├── controller // 接口层只做参数接收与结果返回 ├── service // 业务层承载核心业务逻辑 ├── mapper // 数据访问层MyBatis-Plus 的 BaseMapper 继承 ├── entity // 实体类 ├── common // 公共模块统一返回、异常处理、工具类 └── config // 配置类WebMvc、拦截器、跨域所有接口统一返回一个ResultT对象格式为{ code: 200, message: 操作成功, data: {} }这样做的价值在后端联调时体现得最充分前端不用为几十个接口分别处理不同的响应结构一个全局的 Axios 响应拦截器就能完成统一的错误提示。3.2 用户认证JWT 登录态与拦截器实现校园外卖系统里用户、商家、管理员三种角色都要求登录后才能访问特定接口。我没有选择 Servlet 原生 Session 方案而是选 JWTJSON Web Token来做无状态认证。JWT 的核心机制就是服务端登录成功后签发一个包含用户 id、角色标识、过期时间的加密令牌客户端保存起来之后每次请求在请求头Authorization里带上这个令牌服务端通过拦截器解析校验。我的拦截器逻辑这样写public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { return this.writeUnauthorized(response, 未登录); } // 解析并校验 token Claims claims JwtUtil.parseToken(token.replace(Bearer , )); if (claims null) { return this.writeUnauthorized(response, 登录已过期); } // 将用户信息存入 request 上下文方便后续业务代码获取 request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } }这里有几个细节都是我被实际报错教育过的JWT 生成时要设置过期时间我一般给 24 小时过期后前端拿 401 状态码自动跳转登录页。拦截器中解析 token 失败时不要抛异常让全局异常处理器兜底而是直接返回 401 并携带明确提示前端拦截器才能准确处理。业务代码里要获取当前登录用户不要每次都在 Controller 里手动从 token 解析而是在拦截器里存入request.getAttribute()Controller 里用一个RequestAttribute Long userId直接拿代码会干净很多。角色鉴权的逻辑我放在注解层面实现自定义RequireRole(admin)注解配合拦截器判断role字段是否匹配。这样可以避免在每个方法里写一堆 if-else 判断角色扩展新的操作权限时也更方便。3.3 下单流程库存扣减与金额计算的完整时序下单是外卖系统最核心的业务流程也是面试时最常被追问“如果并发很高你怎么处理”的地方。我先说一下常规情况下的流程前端提交购物车商品 id 列表、收货地址 id、备注信息。后端校验商品是否存在、是否上架、库存是否充足。遍历购物车明细以“商品当前数据库价格”重新计算总金额而不是信任前端传过来的价格。这个非常重要前端传价格可以被篡改。查询商家配送费与起送价判断订单金额是否达到起送价不满足则直接报错提示前端。生成订单主表记录状态置为“待支付”。批量生成订单明细记录扣减商品库存增加商品销量。清空购物车中已下单的商品。返回订单编号与应付金额前端拉起支付。第三步里“重新从数据库计算价格”的动作经常被忽略。如果前端可以传入商品 id 加数量后端用自己的逻辑去算那攻击者就无法通过篡改请求体中的price字段来薅羊毛。库存扣减我用的 SQL 是条件更新UPDATE product SET stock stock - #{count}, sales sales #{count} WHERE id #{productId} AND stock #{count}用stock count作为更新条件只有当库存足够时才更新成功数据库层面的行锁能防止并发扣减导致负库存。更新后如果影响行数为 0就说明库存不足立即抛出业务异常并回滚整个事务。这个方案没有用锁却能保证数据一致它的原理是靠数据库自身对行记录的锁机制来串行化更新操作。订单状态迁移我也用常量枚举严格管理0 待支付 → 支付成功模拟 → 1 待接单 1 待接单 → 商家接单 → 2 配送中 2 配送中 → 配送完成 → 3 已完成 待支付 → 超时未支付 / 用户取消 → 4 已取消状态迁移只在 Service 层维护前端传状态值直接改库的做法是绝对禁止的——一旦前端出现 bug 或者被恶意调用订单状态就乱了。3.4 SpringBoot 整合 MyBatis-Plus 的配置细节MyBatis-Plus是一个十分成熟的 MyBatis 增强框架内置了大量单表 CRUD 方法内置分页插件内置代码生成器能省很多重复劳动。pom 里引入坐标后application.yml这样配置spring: datasource: url: jdbc:mysql://localhost:3306/campus_order?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case这一项必须开启否则数据库字段create_time映射不到实体的createTime属性上。分页插件的配置如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置完之后分页查询直接调用Page类型的 Select 即可支持自动 count 查询和物理分页。提示日志开启StdOutImpl会打印每一条 SQL 和参数这个配置在写代码阶段非常有助力能直观看到 SQL 执行情况排查问题效率翻倍。上线前记得关闭或者改为Slf4jImpl以降低日志量。4. Vue 前端实现与组件化开发4.1 前端工程结构与项目初始化前端的 Vue 版本我根据这套系统的时间点推荐 Vue 2 Element UI如果你是新写代码更推荐 Vue 3 Element Plus Vite。两套方案对业务能力要求一致重点是理解组件化思想和数据流。Vue 端工程结构我这样组织src ├── api // 按模块拆分接口请求 ├── assets // 静态资源 ├── components // 公共组件 ├── router // 路由配置 ├── store // Vuex 状态管理 ├── views // 页面视图 │ ├── user // 用户端页面 │ ├── seller // 商家端页面 │ └── admin // 管理端页面 ├── utils // 工具函数request.js 等 ├── App.vue └── main.js接口层用 Axios 统一封装是一个关键良好实践。我通常的做法是在/utils/request.js中创建一个 axios 实例设置baseURL、超时时间并在请求拦截器中自动携带Authorization请求头。这样每个业务模块的接口文件里只需要写import request from /utils/request export function getOrderList(params) { return request({ url: /order/list, method: get, params }) }4.2 路由权限控制与登录态保持前端路由这一块经常有人踩坑直接在路由表里写好所有页面用户没登录也能打开商家管理页这是不对的。我的做法是在路由配置里给需要权限的路由增加meta信息例如{ path: /seller, component: Layout, meta: { role: [seller] }, children: [ { path: orders, component: SellerOrders, meta: { title: 订单管理, role: [seller] } } ] }然后在全局路由守卫beforeEach中做两件事判断本地存储中是否有 token没有则强制跳转登录页。有 token 则解析路由目标meta.role判断当前用户的角色是否在允许列表里不在则跳转 403 页面或者登录页。这样前端只能控制“显示与跳转”真正的数据安全依赖后端接口的 JWT 鉴权。前端路由守卫主要是提升用户体验防止用户看到无权访问的页面报一堆 401 错误。4.3 购物车与订单页的关键交互购物车页面的核心是选中状态和金额联动。用户勾选一个商品时底部栏的“已选商品数”和“合计金额”要即时变化。这个逻辑如果用事件一层层传代码会变得非常混乱。推荐办法把购物车列表放进 VuexVue3 则用 Pinia通过 getters 实时计算选中数量与总价。getters: { selectedProducts(state) { return state.cartList.filter(item item.checked) }, totalPrice(state) { return state.cartList .filter(item item.checked) .reduce((sum, item) sum item.price * item.count, 0) } }提交订单时调用后端接口传入cartIds addressId remark后端返回订单编号和应付金额前端跳转订单确认页。这里前端显示的金额只是展示最终金额以后端返回为准。配送进度展示我实现的方式是轮询订单详情页每隔 5 秒调用一次订单状态查询接口拿到最新状态后更新页面展示。更完善的方案可以选择用 WebSocket 主动推送但对于校园外卖这种场景短轮询已经能很好工作而且实现成本极低。5. 前后端联调、部署与常见问题排查5.1 跨域问题的完整解决方案前后端分离项目联调时第一关就是跨域。SpringBoot 后端默认启动在localhost:8080Vue Dev 服务器默认在localhost:5173Vite或8081Vue CLI两个端口不一致浏览器就会拦截跨域请求。处理方案有两种后端加全局跨域配置或者前端用 Vite/Vue CLI 的代理。二选一即可我更推荐后端加跨域配置这样部署到服务器后不会因为 Nginx 配置代理改动而直接影响前端联调。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }需要特别提醒如果你的后续项目改用了 Spring Security跨域配置必须在 Spring Security 的过滤链里单独放行仅仅加WebMvcConfigurer是不够的。否则所有带自定义请求头的请求都会被安全过滤器拦截掉。5.2 前端打包与 Nginx 部署前端打包之后产物是一个纯静态文件目录。我用 Nginx 托管前端并将/api路径反向代理到后端服务server { listen 80; server_name your.domain.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }try_files $uri $uri/ /index.html;必须写上否则 Vue Router 使用 history 模式时刷新某个子路由页面会返回 404。后端打包同样简单SpringBoot Maven 项目直接用命令mvn clean package -DskipTests java -jar campus-order-0.0.1-SNAPSHOT.jar --server.port8080如果你希望后端作为一个服务常驻运行需要配合 systemd 或者进程守护工具来管理启动和停止。5.3 排查实录三个高频问题的定位思路我在帮人调试这套系统时遇到的高频问题基本是这三类第一类前端请求能发出后端却收到不到任何日志。这个问题通常不是后端坏了而是请求根本没到达后端原因包括后端服务未启动、端口被占用、跨域配置失败、Nginx 代理路径写错。定位思路是先看浏览器 Network 面板里请求的状态码再用 curl 直连后端地址测试接口是否正常一步定位到问题所在层级。第二类数据库连接失败。报错信息里出现Communications link failure或者Access denied for user。前者大概率是数据库服务没启动、端口不对、服务器防火墙没放行 3306 端口后者大概率是数据库账号密码错误、或者使用的账号没有远程访问权限。注意 MySQL 8 以上版本默认驱动用的是com.mysql.cj.jdbc.Driver旧版驱动类名已经废弃配置不对也会启动报错。第三类数据中文乱码。这类问题的根源通常不是页面编码而是数据库和表字符集不是 utf8mb4。检查三项JDBC URL 里是否带characterEncodingutf8mb4数据库链接级别字符集表和字段的字符集。逐个对齐后乱码问题可以稳定消除。6. 这套项目的二次开发与扩展方向系统跑通之后不建议就此打住否则它和网上下载的“交作业版”区别不大。我建议围绕以下三个方向做二次开发既能提升项目含金量也能在面试时讲出你的思考路径第一接入微信支付沙箱或支付宝沙箱。现在的支付流程如果是模拟免支付直接改状态可以升级为真实沙箱支付流程前端拉起支付二维码后端接收支付回调通知并更新订单状态。这个改动会引入“回调幂等性”和“签名验证”两个技术点正是面试官爱问的内容。第二引入 Redis 做验证码存储和商品缓存。验证码存储在 Redis 中并设置过期时间商品列表热门数据缓存到 Redis 减轻数据库压力。如果想让技术亮点更靠近生产实际可以加上缓存击穿和缓存雪崩的应对说明。第三基于 WebSocket 做订单状态实时推送。用户下单后商家端页面实时弹出新订单声音提醒商家接单后用户端状态自动流转这些都能让系统体验变得更真实。我还会在项目里预留一个“配送员”角色的接口让学校内兼职配送员能够接单和更新配送位置。字段设计上订单表里已有配送员 id的扩展位新角色接入成本很低。7. 写在最后的一点实践体会这套校园外卖系统说难不难说简单也不简单。难的地方不在于某个技术点本身而在于把所有环节串起来不出错数据库字段的一致、事务边界的把控、前后端接口参数的匹配、部署环境的一次性跑通每个环节都可能成为新手过不去的坎。我个人在复现过程中印象最深的不是某一个算法或者框架的使用而是“细节决定成败”这件被反复验证的事。一个Decimal精度问题可能只在特定金额组合下暴露一个拦截器放行漏了 OPTIONS 请求导致整个开发期都在填跨域的坑一个前端路由少了try_files配置部署上去刷新页面就是一片白。这些细节上的消耗往往远大于把框架跑通本身。如果你准备以这个项目作为毕设或者求职项目我的建议是不要停留在“能跑”这个层面。把核心链路里的每一个判断都问一句“为什么”把每一个异常场景都亲手验证一遍这个项目才算真正长在你身上。后续如果遇到具体问题欢迎带着报错信息来交流我尽量把排查思路和解决方案都同步给你。