校园外卖系统开发实战:SpringBoot+Vue+MyBatis+MySQL全栈实现
1. 项目概述为什么我最终选了这套技术组合校园外卖这类系统本质上是一个典型的“用户-商家-平台”三角角色模型。用户在餐品列表中挑选、下单商家查看订单并发货平台负责数据沉淀和运营管理。把这套东西做成一个前后端分离项目技术栈落在 SpringBoot Vue MyBatis MySQL 上是我个人比较推荐的一条路线因为它兼顾了“业务复杂度适中”和“技术覆盖面广”两个特点。如果你正在准备毕业设计、课程设计或者想找一个能真正跑起来、能演示的完整项目做参考这套系统是很好的入手对象。它不是一个只有增删改查的玩具 Demo而是包含登录鉴权、订单状态流转、角色权限控制、文件上传、分页查询等真实业务场景的系统。你可以直接拿源码改也可以按文章里的思路从零复刻一遍整个过程能帮你把前后端开发流程完整串联起来。为什么最终没有选择更“省事”的单体模板方案而是坚持前后端分离我的理由有三个。第一前后端分离是目前企业项目的主流协作模式在面试或答辩时能讲清楚前后端如何配合、跨域如何处理是比较加分的点。第二Vue 加上 Element UI 这类组件库做管理端界面效率极高后端只需要专注提供 JSON 接口不用被页面套在服务端渲染的框架里。第三部署时前后端可以拆开静态资源挂 Nginx后端 jar 独立运行排查问题边界清晰。这套系统的角色我设计成三类学生用户、商家、平台管理员。用户端核心是浏览店铺菜品、加购物车、下单支付、跟踪订单、评价商家端核心是菜品上下架、分类维护、营业状态切换、处理订单管理员端则负责用户管理、商家审核和平台数据查看。整个业务流程的闭环就是“用户下单 → 商家接单 → 配送/自提 → 确认收货 → 评价”围绕这条主线去设计表结构和接口思路会非常清晰。1.1 系统核心功能模块拆解校园外卖系统看起来功能不少但拆开来看核心模块其实就五个。用户模块负责注册登录、个人信息维护。登录我统一用账号密码登录成功后后端返回一个 Token前端保存到本地存储后续所有需要身份的请求都在请求头带上 Token后端通过拦截器校验身份和角色。商户模块相对简单一个商家对应一个店铺维护店铺名称、公告、营业状态和起送价。菜品模块挂在商户下面每个餐厅有自己的菜品分类分类下再挂菜品菜品包含图片、价格、描述、月售量等基础数据。订单模块是整张表设计里最复杂的它牵扯到订单主表、订单明细表还涉及订单状态枚举的流转。前后端对这一块的约定必须统一状态码不统一很容易出现“前端不知道订单卡在哪一步”的情况。评价模块让用户在订单完成后对菜品打分、写评论这是外卖系统的必要条件也体现了流程闭环的完整性。平台管理模块则是管理员视角的审核与统计。1.2 技术选型背后的实战考虑SpringBoot 自带嵌入式 Tomcat这在本地开发阶段非常省事不用单独装服务器打一个 jar 就能跑。我选 SpringBoot 2.x 而不是 3.x是因为很多第三方依赖和教程资料对 2.x 的兼容性更好如果你是一边查资料一边开发遇到问题搜索成本会低很多。Vue 我用的是 2.x 配合 Element UI虽然现在 Vue 3 生态已经成熟但考虑到 Element UI 对表格、表单、弹窗这类后台管理场景的开箱即用程度以及大量老项目资料的参考价值Vue 2 依然是课程设计和毕设场景的高效选择。如果你愿意接受组合式 API 和新的组件库Vue 3 也可以但本文核心逻辑不变。MyBatis 和 JPA 之间我始终推荐 MyBatis。原因很直接外卖系统的订单查询、菜品联查都涉及多表关联SQL 自己写在 XML 里一眼就能看清查了什么、怎么关联、哪里该加索引。相比之下 JPA 的自动 SQL 在复杂场景下反而难调优。ORM 的作用是帮我们把结果集映射成对象而不是替我们隐藏 SQL。MySQL 选用 5.7 或 8.0 都可以开发环境我常用 5.7生产环境如果图新特性可以用 8.0。需要注意 8.0 的驱动类和时区设置与 5.7 有差异这个坑在后面部署章节会专门提。2. 数据库建模外卖系统的表结构怎么设计数据库设计是这类系统的地基也是最容易让新手翻车的地方。很多同学一上来就写实体类写到一半发现表结构缺字段、缺关联回头改代码改到崩溃。我的建议是先花半天时间把表设计清楚再动代码。2.1 核心数据表设计与建表语句我最终的项目里核心表一共 7 张user、merchant、category、dish、orders、order_item、review。这里的 user 表同时承载学生用户、商家账号和平台管理员账号三种角色通过 role 字段区分角色类型包括 USER、MERCHANT、ADMIN。商家额外的店铺信息单独放在 merchant 表里通过 user_id 关联。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), avatar VARCHAR(255), phone VARCHAR(20), role VARCHAR(20) NOT NULL DEFAULT USER, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );merchant 表则保存店铺维度的信息。CREATE TABLE merchant ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, name VARCHAR(100) NOT NULL, notice VARCHAR(255), address VARCHAR(255), delivery_price DECIMAL(10,2) DEFAULT 0, start_price DECIMAL(10,2) DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1营业 0打烊, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );dish 和 category 两张表负责菜品体系。category 表存分类名称和所属商家dish 表存具体菜品外键指向 category 和 merchant。为什么菜品要冗余一个 merchant_id因为查询商家菜品列表时如果只按分类去关联需要多一层 join 才能得到商家信息直接冗余商家外键会让常用查询少一次关联性能更好。订单表的设计是重点。我单独建了 order 主表和 order_item 明细表主表只保存订单维度的数据明细表保存每个菜品的信息。CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, merchant_id BIGINT NOT NULL, order_no VARCHAR(32) NOT NULL UNIQUE, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2配送中 3已完成 4已取消, receiver_name VARCHAR(50), receiver_phone VARCHAR(20), address VARCHAR(255), remark VARCHAR(255), create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, finish_time DATETIME ); CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(100), dish_img VARCHAR(255), price DECIMAL(10,2), quantity INT );这里有两个细节值得注意。第一order_item 冗余了 dish_name 和 dish_img这很关键。因为菜品信息可能被商家修改或删除订单属于快照数据必须保留下单那一刻的菜品名称和价格否则历史订单里的菜名会跟着菜品表变化这是设计上最常见的错误。第二订单号 order_no 建议由后端生成用时间戳加随机数避免数据库自增 ID 直接暴露给用户同时方便追踪。2.2 前后端接口契约与统一返回格式前后端分离项目里接口契约的重要性不亚于数据库设计。我习惯在项目里定义一个统一的 Result 类所有接口统一返回这个结构。基本的格式是 code、message、data。{ code: 200, message: 操作成功, data: {} }code 为 200 表示成功其他为业务错误码。这里要提一个经验不要把 HTTP 状态码和业务状态码混为一谈。用户登录密码错误HTTP 状态可以是 200但 code 返回某个非 200 的值前端拿到数据后根据 code 判断业务是否成功。这样做的原因是很多网络代理、浏览器拦截逻辑会对 HTTP 非 2xx 状态做出额外处理而业务错误不应该触发这种网络层面的逻辑。另外分页接口我统一约定参数为 page、size、keyword返回格式固定为 { total, list }。前端 Vue 的 el-table 配合分页组件时这种结构可以直接绑定不需要做多余的数据转换。养成这种统一约定的习惯前后端联调可以少浪费很多时间。3. 后端实现SpringBoot MyBatis 到底怎么写后端是这套系统的数据处理核心也是工作量最大的部分。我按实际开发顺序拆解先搭工程结构再写登录鉴权最后处理订单这块最核心的业务。3.1 后端工程结构与分层思想项目我按常见的四层结构组织。controller 层只管接收参数和返回结果service 层存放业务逻辑mapper 层负责数据库操作entity 层放实体类。com.example.campus ├── controller // 接口入口 ├── service // 业务逻辑 ├── mapper // MyBatis 数据访问接口 ├── entity // 实体类 ├── config // 配置类CORS、拦截器、WebMvc ├── common // 统一返回、异常处理、常量 ├── util // JWT、字符串等工具 └── CampusApplication.java分层的核心原则是单向依赖controller 调用 serviceservice 调用 mapper严禁 controller 直接操作 mapper。刚开始写代码时觉得多了一层很麻烦但项目一复杂好处立刻体现出来——事务注解加在 service 方法上就能保证订单创建和订单明细插入的一致性权限校验写在拦截器里controller 代码会干净非常多。package 命名这里提醒一句不要用 com.example 这种默认名虽然项目能跑但后续如果要发布或部署包名带来的问题会很难受。改成你自己域名倒序或项目名缩写比如 com.campus.mall更规范。3.2 登录鉴权的完整实现过程登录鉴权我选用 JWT而不是 Session。原因很简单前后端分离场景下Session 依赖 Cookie 和服务器存储状态跨域时处理麻烦而且后端水平扩展时 Session 同步也是个头疼问题。JWT 把用户信息加密放在 Token 里后端只需验证签名天然适合这种无状态接口设计。Token 生成逻辑并不复杂。我引入 jjwt 依赖生成时把用户 ID、角色和过期时间放进去。public class JwtUtil { private static final String SECRET your-secret-key-please-change; private static final long EXPIRE 24 * 60 * 60 * 1000L; public static String createToken(Long userId, String role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() EXPIRE)) .signWith(SignatureAlgorithm.HS256, SECRET.getBytes()) .compact(); } public static Claims parseToken(String token) { return Jwts.parser() .setSigningKey(SECRET.getBytes()) .parseClaimsJws(token) .getBody(); } }注意这个 SECRET 在实际项目中必须放到配置文件里并且不要出现任何真实秘密。再写一个拦截器在 preHandle 方法里从请求头读取 Authorization 字段解析出 userId存到 ThreadLocal 供后续业务使用。public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals(OPTIONS)) { return true; } String authHeader request.getHeader(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { throw new BusinessException(401, 未登录或登录已过期); } Claims claims JwtUtil.parseToken(authHeader.substring(7)); UserContext.set(claims.get(userId, Long.class), claims.get(role, String.class)); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }这里 OPTIONS 请求必须放行否则跨域预检请求会被拦截器挡掉前端怎么调都是 401。这个问题我见过太多次了很多同学前端配置了跨域后端也加了 CORS 配置但接口依然报错排查半天发现是拦截器把预检请求拦了。3.3 订单状态流转的代码实现订单是这个系统业务复杂度最高的部分。我先定义常量接口把订单状态统一收口。0 待支付、1 已支付、2 配送中、3 已完成、4 已取消。不要让魔法数字散落在 service 代码里否则后期改状态逻辑会非常痛苦。下单的 service 方法大致长这样。Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto) { Long userId UserContext.getUserId(); Merchant merchant merchantService.getById(dto.getMerchantId()); if (merchant null || merchant.getStatus() 0) { throw new BusinessException(店铺不存在或已打烊); } Orders order new Orders(); order.setUserId(userId); order.setMerchantId(dto.getMerchantId()); order.setOrderNo(generateOrderNo()); order.setStatus(0); order.setReceiverName(dto.getReceiverName()); order.setReceiverPhone(dto.getReceiverPhone()); order.setAddress(dto.getAddress()); order.setRemark(dto.getRemark()); ListOrderItemDTO items dto.getItems(); if (items null || items.isEmpty()) { throw new BusinessException(订单明细不能为空); } BigDecimal total BigDecimal.ZERO; for (OrderItemDTO item : items) { Dish dish dishService.getById(item.getDishId()); if (dish null) { throw new BusinessException(菜品不存在); } BigDecimal amount dish.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())); total total.add(amount); } order.setTotalAmount(total); orderService.insert(order); // 插入明细略 return order.getId(); }事务注解必须加上而且 rollbackFor 要指定 Exception.class。默认情况下Spring 的事务回滚只针对 RuntimeException如果你在 service 里抛出一个普通异常事务是不会回滚的订单主表和明细就会出现数据不一致。这个坑我踩过一次后来果断统一用自定义 BusinessException 继承 RuntimeException。状态机层面我建议把状态变更做成独立方法比如 confirmOrder、deliverOrder、finishOrder、cancelOrder每个方法内部先校验前置状态再更新到目标状态。public void confirmOrder(Long merchantId, Long orderId) { Orders order orderService.getById(orderId); if (order null || !order.getMerchantId().equals(merchantId)) { throw new BusinessException(无权操作该订单); } if (order.getStatus() ! 1) { throw new BusinessException(订单状态不允许接单); } order.setStatus(2); orderService.updateById(order); }这种“先校验、再更新”的模式能有效防止脏操作。商家只能操作属于自己的订单这个校验别省很多权限漏洞就是因为只做了登录校验、没做资源归属校验。3.4 MyBatis 多表联查与分页的实战细节MyBatis 的 XML 写法上我推荐把复杂查询全部放进 XML而不是用注解拼 SQL因为订单列表这类查询条件多、排序复杂注解方式的维护性太差了。分页我直接用 PageHelper在查询前调用 PageHelper.startPage后面紧跟的 mapper 方法就会自动分页。select idqueryOrderPage resultTypecom.example.campus.entity.Orders SELECT o.*, m.name AS merchant_name FROM orders o LEFT JOIN merchant m ON o.merchant_id m.id WHERE o.user_id #{userId} if teststatus ! null AND o.status #{status} /if ORDER BY o.create_time DESC /select这里有个性能细节要说明如果 SQL 里有 LEFT JOIN要确保 WHERE 条件能用到索引订单表建议给 user_id、merchant_id、status、create_time 分别加索引。校园项目量级可能看不出来但表数据过万之后全表扫描和走索引的差别是秒级这个习惯最好一开始就养成。还有一个容易忽略的问题动态 SQL 里的 if 判断参数如果是 Integer 类型判断 status ! null 是可以的但如果传的是字符串还需要注意空字符串的情况。MyBatis 的动态 SQL 很灵活但灵活也意味着容易出错写完后一定要跑一遍各种参数组合尤其是“不传条件”的情况。4. 前端实现Vue 页面和业务逻辑怎么组织前端项目我用 Vue CLI 创建配合 Vue Router 和 Vuex。前端代码不是简单的页面堆砌合理的项目结构和请求封装能让你少走很多弯路。4.1 前端工程结构与请求封装Vue 项目我习惯按 views、components、router、store、api、utils 这六个目录组织。views 放页面级组件components 放可复用组件api 目录下每个模块一个文件比如 user.js、menu.js、order.js统一管理接口地址。请求模块必须做统一封装。基础的 axios 实例要设置 baseURL、超时时间最重要的是请求拦截器自动带上 Token。import axios from axios import { Message } from element-ui import router from ../router const request axios.create({ baseURL: process.env.VUE_APP_BASE_URL || /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } Message.error(error.response?.data?.message || 网络错误) return Promise.reject(error) } )统一封装的价值在遇到 401 时最能体现。Token 过期后所有请求都会在响应拦截器里被拦下来统一跳转登录页而不是每个页面单独写一遍过期判断。这就是“基础封装”带来的长期收益。4.2 页面路由与权限控制路由层面我分成公开路由和需鉴权路由。登录页、注册页是公开的其余页面在路由守卫里做判断。Vue Router 的 beforeEach 是天然的路由钩子每次跳转前检查本地有没有 Token。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) return } if (to.path /login token) { next(/) return } next() })角色权限这块我除了在路由 meta 里标记角色要求还有一层更重要的约束——后端接口的拦截器必须根据角色校验接口访问权限。前端隐藏按钮和菜单只是体验问题后端校验才是安全底线。经常有人问“前端控制角色不就行了吗”答案是绝对不行因为接口可以被直接调用绕过前端页面。4.3 前后端联调与跨域问题开发阶段的跨域我用 Vue CLI 的 devServer.proxy 做代理解决。这样开发环境下前端请求的地址是相对路径 /api代理把请求转发到后端 8080 端口浏览器看到的响应是同源的自然没有跨域问题。// vue.config.js module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }生产环境则通过 Nginx 的 location /api 转发同样解决跨域。这里要特别注意 pathRewrite 的配置。后端接口如果是 /user/login前端请求是 /api/user/login代理转发时需要把 /api 前缀去掉否则后端接到的路径是 /api/user/login路由匹配不到。联调阶段我建议前后端先把接口字段和返回结构核对清楚再开始写代码。很多“联调半天对不上”的问题基本都是字段命名不统一导致的比如后端返回 createTime前端写 createdTime这种错误排查成本很高。5. 完整部署教程从开发机到服务器上线源码能在本地跑通只是第一步真正把系统部署到服务器上才算完成闭环。这一章我按完整流程走一遍从环境准备到最终自检每个步骤都写清楚。5.1 环境准备与数据库初始化服务器我以 Linux 系统为例CentOS 7 或 Ubuntu 20.04 都可以。需要提前装好 JDK 1.8 或 11、MySQL 5.7 或 8.0、Nginx。数据库初始化分两步。第一步是创建数据库并导入建表脚本第二步是修改后端配置里的数据库连接。如果你是第一次在服务器上操作 MySQL注意字符集要设置成 utf8mb4否则中文表情符号会存不进去。mysql -uroot -p CREATE DATABASE campus_mall DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE campus_mall; SOURCE /home/deploy/campus/init.sql;导入完成后检查一下表是否创建成功用 SHOW TABLES 看一眼。这一步别省略因为字符集不对的话后面导入的数据会乱码。5.2 后端打包与运行后端打包前先确认 application-prod.yml 里的数据库地址、账号、密码都改成了服务器配置。这里我会把配置拆分application.yml 放公共配置application-dev.yml 和 application-prod.yml 分别放不同环境的配置通过启动参数指定环境。spring: datasource: url: jdbc:mysql://localhost:3306/campus_mall?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: your_user password: your_password driver-class-name: com.mysql.cj.jdbc.DriverMySQL 8.0 的驱动类名是 com.mysql.cj.jdbc.Driver5.7 是 com.mysql.jdbc.Driver同时 8.0 连接串必须加 serverTimezone 参数否则会报时区错误。这个差异非常经典部署阶段最容易踩。打包运行mvn clean package -DskipTests java -jar target/campus-mall.jar --spring.profiles.activeprod我建议用 nohup 启动并输出日志到文件方便排查问题。nohup java -jar target/campus-mall.jar --spring.profiles.activeprod /home/deploy/campus/app.log 21 启动后用 curl 验证接口是否正常。curl http://localhost:8080/health5.3 前端构建与 Nginx 配置前端在本地开发机上执行构建。npm install npm run build构建完成后dist 目录下是纯静态文件整体上传到服务器的 /home/deploy/campus/dist。然后配置 Nginx。我的配置方式示例如下。server { listen 80; server_name localhost; root /home/deploy/campus/dist; index index.html; location / { 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_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里有两个必须注意的点。第一try_files 那行配置是 SPA 路由的核心它的作用是把所有不存在的路径都重写到 index.html否则前端刷新一个子页面时Nginx 会返回 404。第二location /api/ 的 proxy_pass 结尾如果带了斜杠表示把 /api 前缀去掉后再转发比如 /api/user/login 会被转发为 http://127.0.0.1:8080/user/login。这个斜杠的规则容易混乱建议自己动手试一次加深理解。5.4 上线后的自检清单部署完别急着收工我按顺序做一遍自检。先检查后端进程是否存活看日志有没有报错。然后测试登录接口确认数据库连接正常。再访问前端页面登录、下单、商家端接单完整走一遍业务流程。如果某个环节异常直接去看 app.log大部分问题都会在日志里有明确线索。图片上传是另一个高频问题点。本地开发时图片存在本机磁盘部署到服务器后如果图片上传成功但前端访问 404大概率是 Nginx 没有代理图片路径。处理方案是给图片单独配一个静态路径 location把上传目录挂到 Nginx 的静态资源下。location /upload/ { alias /home/deploy/campus/upload/; }记住上传的图片目录要提前建好最好给一个合适的读写权限。6. 踩坑记录常见问题与故障排查这一章我把自己实际开发中遇到的典型问题整理成速查表每一条都是踩过坑换来的经验。6.1 高频问题解决速查表问题现象根本原因解决思路前端调接口报跨域前后端域名或端口不一致开发用 proxy 代理生产用 Nginx 转发跨域是现象根因是浏览器同源策略接口一直返回 401OPTIONS 预检请求被拦截器拦截拦截器放行 OPTIONS 请求数据库连接失败报时区错误MySQL 8.0 连接串缺少 serverTimezone连接串加 serverTimezoneAsia/Shanghai下单接口返回 500数据不一致service 抛普通异常事务没回滚自定义异常继承 RuntimeException事务指定 rollbackFor订单状态错乱多个接口都能修改状态状态变更统一走独立方法先校验前置状态前端刷新页面 404Nginx 没有配置 SPA 路由重写try_files 重写到 index.html图片上传成功但访问 404Nginx 没暴露上传目录配置 upload 静态路径 alias还有一类问题容易被忽略就是时间字段的格式。后端返回的 LocalDateTime 默认序列化格式带 T比如 2024-05-10T12:30:00前端如果不处理直接显示会很不好看。我在统一配置里加了 JackSon 的日期格式化返回格式统一成 yyyy-MM-dd HH:mm:ss。6.2 本地能跑服务器跑不起来的差异本地正常但服务器异常这类问题的排查思路集中在环境差异上。最常见的是后端 jar 包用的是本地数据库地址部署到服务器忘了改 prod 配置。其次是前端构建时用的环境变量本地请求地址是 /api生产环境也是 /api但如果服务器上 Nginx 没有做代理转发请求会落在 Nginx 自己身上返回一堆 HTML 内容前端解析 JSON 时直接报错。内存方面SpringBoot 默认启动参数对 1G 内存的小服务器很不友好OOM 的风险很高。我建议显式指定 JVM 参数。java -Xms256m -Xmx512m -jar target/campus-mall.jar数据库连接数也是个隐患。默认连接池配置在并发稍高时会出现连接等待学生项目可能不明显但写成推荐配置更稳妥。在 SpringBoot 中配置 HikariCP 连接池参数控制最大连接数和超时时间。开发环境顺手就好生产环境则要尽量压低上限避免一个故障把服务器资源耗尽。6.3 排查思路从现象到根因遇到问题我习惯先问三个问题现象是什么、最近改了什么、日志说了什么。前端报错优先看浏览器 Network 面板里接口的响应体和状态码后端报错看 app.log 的堆栈信息。不要凭经验猜直接看日志定位这是效率最高的方式。日志里常见一个现象就是 “nested exception is ...”后面的类名通常会直接告诉你是 SQL 语法错误、空指针还是事务超时。看到 SQLGrammarException 就去检查 XML 里的 SQL看到 NullPointerException 就检查是不是数据库查出来空对象往下游传了。7. 写在最后这套系统的优化与扩展方向如果把这套系统当作课程设计或者毕设交付当前的功能已经够用。但如果你希望它更有竞争力想在答辩或作品集里多展示一些亮点可以从几个方向做扩展。第一个方向是引入 Redis 缓存和高并发处理。菜品列表是典型的读多写少场景用 Redis 做一层缓存把热门菜品的查询结果缓存起来能显著降低数据库压力。另外秒杀、优惠券这类营销玩法常常要应对集中流量消息队列可以做流量削峰但复杂度较高建议有余力再折腾。第二个方向是支付模块的完善。当前项目里的支付是模拟状态流转如果对接真实支付渠道需要关注签名、回调、对账三个环节。回调接口要设计成幂等的因为支付平台的回调可能重复通知。这是很好的深入话题后续如果大家需要我可以专门写一篇支付模块的实战文章。第三个方向是数据可视化和统计分析。管理员端目前只有订单列表如果引入图表像 ECharts 展示每日订单量趋势、菜品销量排行、商家流水情况会让整个系统的完成度提升一个档次。我在实际开发这套系统的过程中最大的体会是一个项目是否能落地不在于用了多新的技术而在于业务流程是否闭环、数据是否准确、部署是否顺利。很多比这“炫酷”的项目卡在部署最后一公里反而成不了事。最后再分享一个我自己的习惯。每次写完一个模块我会顺手做一轮接口自测把参数、权限、异常路径都过一遍。这套系统前前后后改过不下十轮每一次重构都得益于当时的自测记录和日志。希望你也能养成这个习惯它会让你省下大量排查问题的时间。