简介:基于SpringBoot的校园外卖服务系统设计与实现项目包,面向Java后端学习者和毕业设计学生,帮助读者掌握外卖类业务系统的完整落地流程。项目围绕用户、商家、订单、配送四大模块展开,覆盖系统架构设计、数据库表关系、业务接口实现、SpringSecurity安全认证以及Docker部署测试等内容,能够从需求分析到项目上线提供可参考的工程实践。后端采用SpringBoot整合SpringDataJPA与MySQL完成数据持久化,配合SpringSecurity实现认证授权,整体结构分层清晰、易于扩展。
压缩包共793个文件,大小28.84MB,以Java源码、Vue组件、JavaScript脚本、CSS样式等前后端文件为主体,同时包含SQL数据库脚本、HTML页面、配置文件及bat快捷部署脚本,资源结构清晰,便于直接导入开发工具运行和二次开发。
已有41人学习使用。资源内含完整源码、数据库初始化脚本和说明文档,对照工程目录可理解外卖场景下用户、商家、订单和配送模块的实现细节。从数据库建表脚本到前端页面、后端服务接口,整套代码按模块划分,便于按需研读,是学习SpringBoot整合实践、开展课程设计或毕业设计的实用参考资料。
1. SpringBoot校园外卖服务系统:这份资源能帮你补齐哪块短板
做校园外卖这类业务系统,难点从来不在增删改查,而在「多端协同」和「订单状态流转」。一份合格的SpringBoot校园外卖系统源码,通常包含用户端、商家端、管理端三个后台,外加一套供小程序或App调用的REST API,核心要解决的是:认证鉴权怎么做、购物车怎么算钱、订单超时怎么处理、支付回调怎么验签。如果你正在做毕业设计、课设,或者想快速搭一套可演示的微服务单体项目,这份资源的参考价值是实打实的。我自己拿到第一件事不是跑起来,而是先拆它的模块边界和表结构,因为这两处决定后续所有功能能否稳定扩展。下面按我的拆解顺序,把这份资源的重点、参数和坑一次性讲清楚。
2. 系统整体架构与模块划分:先看清代码边界再动手改
2.1 技术选型与模块边界
拆这种项目我习惯先把依赖和模块结构列出来,不然改到一半容易晕。这套系统按典型单体分层设计,技术栈集中在Spring Boot生态内,并辅以中间件解决订单类业务的高频读写问题。
代码分层通常是:
- 用户端模块:登录注册、浏览菜品、购物车、下单、订单查询、地址管理。对应controller包名通常是
user,接口路径前缀是/api/user。 - 商家端模块:菜品上下架、库存修改、订单接单/拒单、营业统计。路径前缀
/api/merchant。 - 管理端模块:用户管理、商家审核、分类管理、全局订单查询。路径前缀
/api/admin。 - 公共模块:统一返回体、异常处理、JWT工具、拦截器、Redis工具。
- 基础设施模块:MyBatis-Plus的Mapper层、Service层、配置类、定时任务。
选型逻辑一般是:认证用JWT + Redis做token续期;ORM用MyBatis-Plus减少单表CRUD代码;订单超时关闭用延迟队列,简单点就用定时任务扫表,复杂一点上RabbitMQ死信队列。这套系统里通常能同时看到这两种方案的代码,对理解订单系统演进很有帮助。
需要重点关注的是角色边界。如果用户端、商家端的管理员都能操作同一个订单实体的更新接口,但没有做角色校验,那就会出现商家把用户订单标记为已完成的问题。拆包时先看拦截器里是否区分了USER、MERCHANT、ADMIN三种角色。
2.2 目录结构与启动流程
拿到源码先不要急着改,先在IDE里看一遍包结构。比较合理的工程结构如下:
campus-delivery/ ├── src/main/java/com/example/delivery/ │ ├── config/ # 拦截器、Redis、WebMvc配置 │ ├── controller/ # 按角色拆包的controller │ ├── service/ # 业务逻辑层 │ ├── mapper/ # MyBatis-Plus的Mapper接口 │ ├── entity/ # 数据库实体 │ ├── dto/ # 前端传参对象 │ ├── vo/ # 返回给前端的视图对象 │ ├── common/ # 统一返回体、异常码、全局异常处理 │ ├── utils/ # JWT工具、日期工具 │ └── task/ # 定时任务(订单超时处理) ├── src/main/resources/ │ ├── mapper/ # XML文件,复杂SQL写这里 │ ├── application.yml # 主配置 │ ├── application-dev.yml │ └── db/ # 初始化SQL脚本 └── pom.xml这个目录结构有两个值得学习的点。第一是dto和vo分开,避免直接把数据库实体暴露给前端,即使需求里有时图省事会直接返回entity,但拆包做二次开发时建议坚持这个习惯,后面改字段名会感谢自己。第二是common包里放全局异常处理,而不是在每个controller写try/catch。统一异常处理通常用@RestControllerAdvice,它能让参数校验失败、业务异常、未知异常分别返回可读的信息。
启动流程上也有些需要注意的细节。这套系统因为依赖Redis和MySQL,启动前务必先保证本地这两个服务可用。数据库初始化常见的做法是把SQL脚本放在src/main/resources/db目录,Spring Boot配置了spring.sql.init.mode=always后首次启动会自动建表。如果脚本里包含DROP TABLE IF EXISTS,那么每次重启都会清空数据,如果你有手工插入的测试数据,记得在改配置时保留never模式。
2.3 数据库设计:九张核心表之间的关系
表结构是这份资源最值得看的部分。一张订单相关的表字段怎么设计、状态值怎么定义,直接反映作者对业务的理解程度。通常这套系统会有九张左右的表,核心表为:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| user | id、phone、password、role、status | 用户账号,角色区分用户/商家 |
| address | id、user_id、detail、phone、is_default | 用户收货地址 |
| category | id、merchant_id、name、sort | 菜品分类 |
| dish | id、merchant_id、category_id、price、status | 菜品信息 |
| shopping_cart | id、user_id、dish_id、number | 购物车条目 |
| orders | id、order_no、user_id、merchant_id、amount、status、address_id | 订单主表 |
| order_detail | id、order_id、dish_id、dish_name、price、number | 订单快照 |
| merchant | id、name、license、status | 商家信息 |
| admin | id、username、password | 管理端账号 |
订单表是整个系统的核心,几个关键字段设计要仔细推敲:
- 订单号
order_no:一般不用自增id做主键暴露给用户,用时间戳+随机数生成唯一订单号,长度通常20位。如果是并发量大的场景,可以用Redis自增拼接日期,但课设和演示项目里够用就行。 - 状态
status:典型状态有PENDING_PAYMENT、PAID、ACCEPTED、DELIVERING、COMPLETED、CANCELLED、REFUNDED。这套系统里如果用整数表示,常见的映射是0待支付、1已支付、2已接单、3配送中、4已完成、5已取消、6已退款。 - 金额字段:建议用
decimal类型,避免float或double的精度问题。前端传金额时接口层最好以分为单位接收整数,或者用字符串类型接收后在后端解析,否则容易出现前端浮点运算导致金额差一分钱的问题。
表设计上还有一个常见争议点:order_detail表里要不要冗余dish_name和price字段?大多数入门项目会直接关联dish表去查菜品名称和价格,但这样就丢失了历史快照。如果商家后来改了菜品价格,订单详情里的价格也会跟着变,这不符合财务审计要求。合格的系统会在order_detail里冗余一份菜品名称和下单时价格,这份资源如果做到了这点,那说明作者对订单系统的理解是到位的。
3. 核心流程落地:从登录鉴权到订单状态机
3.1 JWT登录与拦截器配置
校园外卖系统的所有接口几乎都要登录态才能访问,JWT是这类项目最常见的方案。登录接口拿到手机号和密码后,校验通过签发一个token返回给前端。前端后续请求在Header里带上Authorization: Bearer <token>,后端通过拦截器统一解析。
核心拦截器代码逻辑通常是这样的:
@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private StringRedisTemplate redisTemplate; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求和其他放行路径 if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String authHeader = request.getHeader("Authorization"); if (authHeader == null || !authHeader.startsWith("Bearer ")) { throw new BusinessException(401, "未登录或token缺失"); } String token = authHeader.substring(7); // 解析token,校验签名并获取用户id和角色 Claims claims = JwtUtil.parseToken(token); Integer userId = claims.get("userId", Integer.class); String role = claims.get("role", String.class); // 关键校验:token是否已在Redis中过期或被主动注销 String redisKey = "login:token:" + userId; String cachedToken = redisTemplate.opsForValue().get(redisKey); if (cachedToken == null || !cachedToken.equals(token)) { throw new BusinessException(401, "登录已过期,请重新登录"); } // 将用户信息放入请求上下文,供后续controller使用 request.setAttribute("userId", userId); request.setAttribute("role", role); return true; } }这段代码里最容易被忽略的是Redis校验这一步。如果没有这个校验,用户退出登录后旧token依然有效,因为JWT本身是无状态的,只有配合Redis做服务端状态记录,才能真正做到主动失效。这就是为什么Stpter这类依赖JWT的项目基本都配了Redis,不是因为它快,而是为了管理token的生命周期。
拦截器配置还有一个坑:白名单路径。登录接口、注册接口、图片访问路径不需要拦截,直接在WebMvcConfig里放行。常见做法是:
registry.addInterceptor(jwtInterceptor) .addPathPatterns("/**") .excludePathPatterns( "/api/user/login", "/api/user/register", "/api/user/captcha", "/images/**", "/error" );注意/error路径也放行了,否则过滤器抛出异常时,Spring Boot跳到错误页面的请求又会被拦截,导致错误详情看不清。
3.2 点餐与购物车实现
购物车的典型实现是把数据存到数据库表里,而不是存在客户端。这样用户在A设备加购、B设备下单时可以同步,也方便后续做“购物车失效菜品提示”。
加购接口的几个关键逻辑点:
- 菜品是否存在且为在售状态。
- 购物车里是否已有相同菜品,有则数量累加。
- 单一菜品库存是否足够,不够则拒绝加购。
Service层常见的写法是:
@Override public void addToCart(CartAddDTO dto) { // 参数校验:菜品id不能为空、数量必须大于0 if (dto.getDishId() == null || dto.getNumber() == null || dto.getNumber() <= 0) { throw new BusinessException("参数不合法"); } // 菜品状态校验 Dish dish = dishMapper.selectById(dto.getDishId()); if (dish == null || dish.getStatus() != 1) { throw new BusinessException("菜品已下架"); } // 菜品库存校验,注意判断条件是当前库存与购物车数量之和 int currentStock = dish.getStock(); if (currentStock < dto.getNumber()) { throw new BusinessException("库存不足,当前剩余" + currentStock + "份"); } // 查询购物车是否已有该菜品 QueryWrapper<Cart> wrapper = new QueryWrapper<>(); wrapper.eq("user_id", dto.getUserId()); wrapper.eq("dish_id", dto.getDishId()); Cart cartItem = cartMapper.selectOne(wrapper); if (cartItem == null) { // 没有则新增一条记录 Cart cart = new Cart(); cart.setUserId(dto.getUserId()); cart.setDishId(dto.getDishId()); cart.setNumber(dto.getNumber()); cartMapper.insert(cart); } else { // 已有则数量累加 cartItem.setNumber(cartItem.getNumber() + dto.getNumber()); cartMapper.updateById(cartItem); } }这段代码的库存判断容易忽略一个点:如果当前库存是5份,购物车里已经有4份,再加购2份,直接拿currentStock < dto.getNumber()判断会误判为允许加购,实际超过总库存。正确做法是拿“购物车已有数量 + 本次加购数量”和库存比较,即cartItem.getNumber() + dto.getNumber() > currentStock时才拒绝。很多电商系统的超卖问题就出在这个判断条件上。
此外,点餐接口还涉及到“菜品列表按分类展示”。通常通过category_id关联查询,返回VO里包含categoryName和一个dishList。批量查询时注意避免for循环查库,应该用selectBatchIds或者一次联表查出所有菜品后再按分类分组。如果这份资源里是循环查,你接手后建议改成一次性查询,数据量大一点差距就非常明显。
3.3 订单状态机与支付回调模拟
订单接口是这个项目最复杂的部分,核心涉及两步:创建订单和状态流转。
创建订单的流程:从购物车中取出本次勾选的菜品,校验库存和价格,计算订单总金额,生成订单主记录和订单详情记录,然后清空购物车已选的条目。这里面有个环节容易翻车:前端传给后端的是菜品id数组还是购物车条目id数组?如果是购物车条目id数组,后端可以直接查出每条购物车对应的菜品,然后依次扣库存。如果是直接传菜品id和数量,后端还要防重,即同一菜品在数组里出现多次,数量应该合并而不是重复插入订单详情。
订单金额的计算以后端为准。即使前端传了一个总价,后端也要根据数据库里的菜品价格重新算一遍,再对比前端传的值,不一致就提示刷新页面而不是直接入库。否则前端改一下传参就能低价下单。
订单状态机的流转逻辑,建议单独写在Service层,避免controller里到处都是状态判断:
public void updateOrderStatus(Long orderId, Integer targetStatus, String role) { // 加锁防止并发状态更新 Order order = orderMapper.selectByIdForUpdate(orderId); if (order == null) { throw new BusinessException("订单不存在"); } int current = order.getStatus(); // 状态机校验:只有合法迁移才允许变更 Map<Integer, List<Integer>> allowedTransitions = new HashMap<>(); allowedTransitions.put(0, Arrays.asList(1, 5)); // 待支付 -> 已支付/取消 allowedTransitions.put(1, Arrays.asList(2, 5)); // 已支付 -> 接单/取消 allowedTransitions.put(2, Arrays.asList(3)); // 接单 -> 配送中 allowedTransitions.put(3, Arrays.asList(4)); // 配送中 -> 已完成 List<Integer> allowed = allowedTransitions.get(current); if (allowed == null || !allowed.contains(targetStatus)) { throw new BusinessException("当前订单状态不允许变更为目标状态"); } order.setStatus(targetStatus); orderMapper.updateById(order); }这里有两个值得关注的点。第一是selectByIdForUpdate,即SELECT ... FOR UPDATE行级锁,防止两个请求同时把订单从0状态改成不同状态。MyBatis-Plus里自定义这个方法要在Mapper接口上写SQL注解或XML。第二是状态机不允许跳跃式变更,比如从待支付直接改为已完成,这在多端协同的多角色场景下尤为重要。
支付回调模拟方面,现在的项目为了方便演示,通常提供两种方式:一种是前端点击“模拟支付”按钮直接调用后端/api/order/payMock接口,后端直接把订单置为已支付状态;另一种是比较完整的支付回调接口,内部用RSA2或MD5签名校验,要求回调请求带签名参数,后端先验签再更新订单。做毕业设计的话,第一种足够;做企业外包演示或者需要展示项目深度,建议保留第二种。验签的关键在于不能信任回调请求的参数本身,而应该用接收到的原始参数重新计算签名,拿到本地私钥或公钥做对比。哪怕这套系统只是模拟,这个流程也建议保留。
订单超时未支付自动关闭,一般用两种实现:定时任务扫描待支付订单并更新状态,或者用延迟队列。定时任务写法简单但会有分钟级延迟,且要处理好分页避免一次扫太多数据。如果源码里用的是Spring Scheduling的@Scheduled(cron = "0 */1 * * * ?")大概每分钟扫一次,扫描条件是status = 0 AND create_time < NOW() - INTERVAL 15 MINUTE。要注意一旦扫到订单就批量更新状态,脚本要控制一次扫描的数量(比如LIMIT 100),防止首次运行时把历史积累的大量超时订单一次性锁住导致数据库压力集中。
4. 部署与配置:多环境Profile与打包发布
4.1 application.yml三套环境配置
拿到这个SpringBoot校园外卖系统源码后,第一件事往往是黑匣子阶段——项目起不来,也不知道哪里起不来。这时先从配置文件开始排查就对了。常规情况下,src/main/resources下会有application.yml、application-dev.yml、application-test.yml、application-prod.yml。这是Spring Boot多环境Profile的标准拆分法。
dev环境的关键配置可以这样参考:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/campus_delivery?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: "123456" driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 30000 redis: host: localhost port: 6379 database: 0 timeout: 5000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 data: redis: repositories: enabled: false mybatis-plus: mapper-locations: classpath:/mapper/*.xml type-aliases-package: com.example.delivery.entity configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: "your-secret-key-at-least-32-characters-long" expire-hours: 24这里每个参数都要解释清楚,不然改错就是坑:
serverTimezone=Asia/Shanghai:不设置的话,自定义插入时间和数据库时间的时区差值会导致时间显示异常。useSSL=false:本地连接MySQL时避免SSL握手报错。maximum-pool-size=10:这个参数控制数据库连接池最大连接数。本地开发10就够,如果部署到服务器上并发高一点,建议调整到20或30。太小会报连接池满,太大对本地MySQL来说是浪费。lettuce.pool:Redis客户端lettuce的连接池参数,max-active=8意味着同一时间最多8个Redis连接。整体连接数大会出现Unable to connect to Redis之前,经常是max-active太小加上连接泄漏导致。logic-delete-field: deleted:MyBatis-Plus的逻辑删除全局配置。所有实体表如果有deleted字段,所有查询自动加WHERE deleted = 0,所有删除自动改成UPDATE deleted = 1。接手的项目如果代码里出现deleted字段而配置文件里没有开逻辑删除,需要自己注意。
启动时指定环境只需在IDE里设置虚拟机参数-Dspring.profiles.active=dev,或者打包后用java -jar campus-delivery.jar --spring.profiles.active=dev。命令行指定优先级是最高的。
4.2 打包跳过测试与数据初始化
mvn clean package -DskipTests -Pdev-DskipTests跳过测试用例执行,-Pdev激活Maven Profile对应的构建配置。很多项目在pom.xml里配置了<profiles>对应用户说不同环境的过滤规则。这里注意-Pdev和spring.profiles.active=dev是两回事,前者是Maven构建时的Profile,后者是Spring Boot运行时的Profile。如果pom.xml里没配置<profiles>,-Pdev不会起作用,但要确保Spring Boot项目的application.yml里没有把spring.profiles.active写死成prod,否则本地打包上线后会发现连接的是生产数据库,这个错误极难排查。
数据初始化通常有两种方式:
schema.sql和data.sql:放到resources目录下,配合spring.sql.init.mode=always使用。特点在于表结构重建快,适合开发和演示。数据被清空是常态,别把重要的手工数据放在这里,更不能和Flyway混用在同一个环境,否则可能出现初始化脚本重复执行的问题。db/init.sql:手动执行mysql -uroot -p < init.sql。这种方式稳定可控,适合上线部署,避免每次启动都重置数据。
对于课设和内部演示,推荐第二种方式:手动导入一次。原因在于spring.sql.init.mode=always的机制会在每次启动都执行脚本,如果脚本里含INSERT而表里数据已经存在,就会报主键冲突。这也是拆包后第一波常见的报错来源。
4.3 Docker Compose一键起依赖
如果本地没有安装MySQL和Redis,或者维护环境麻烦,Compose是演示环境的短期解决方案。
version: "3.8" services: mysql: image: mysql:8.0 container_name: campus-mysql environment: MYSQL_ROOT_PASSWORD: "123456" MYSQL_DATABASE: campus_delivery ports: - "3306:3306" volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql command: --default-authentication-plugin=mysql_native_password redis: image: redis:7.0 container_name: campus-redis ports: - "6379:6379" volumes: - ./redis-data:/data./init.sql挂载到/docker-entrypoint-initdb.d目录下,MySQL容器首次启动时会自动执行该脚本完成建库建表和数据初始化。command参数中--default-authentication-plugin=mysql_native_password是为了兼容MySQL 8.0默认的认证插件,因为有些JDBC驱动版本比较旧时使用caching_sha2_password会连接不上。如果你是新生代驱动(如mysql-connector-j8.0.21+的部分更新版本),可以去掉这个参数直接用默认认证插件,密码在初始化环境中设置更安全。
Redis容器没有配置appendonly或save,数据在容器重建后会丢。如果只是开发调试无所谓,但演示完发现Redis里存的验证码和token全没了,不要慌,就是这个原因。
5. 常见问题排查:五个最容易翻车的点
5.1 前端传参为JSON但后端对象接收为空
现象:接口调用成功,但Controller里的DTO对象有一半字段为null,尤其在用axios或postman调用时。
原因:多半是前端只设置了Content-Type为application/json,但实际发送的是一个奇怪的字符串,或者后端controller入参缺少@RequestBody注解。还有一种情况是Spring Boot 3.x默认使用Jackson,前端传的字段名是orderNo,后端DTO字段是order_no。Jackson默认不会自动做驼峰和下划线的互转,字段名对不上就全部null。
解决:先在application.yml里配置全局命名策略:spring.jackson.property-naming-strategy=SNAKE_CASE(如果约定前端全部用下划线),或者保持所有字段用驼峰并统一前端。更直接的做法是:利用IDE调试,在Controller第一行打断点看@RequestBody OrderDTO解析后的值,如果字段全是null但对象不是null,多半就是字段名映射问题。处理方式是给DTO字段加@JsonProperty("order_no")逐个对齐,或者统一约定命名风格。
5.2 明明登录成功,但请求业务接口报401
现象:用户调登录接口返回了token,复制进Authorization请求头后,业务接口依然报401 未登录或token已过期。
原因:最常见的是token字符串前多了Bearer前缀。前端可能把token直接放进Header,而后端拦截器要求的是Authorization: Bearer <token>格式,代码里用authHeader.startsWith("Bearer ")判断,少了这个前缀直接被判定为无效。另外,如果Redis中token的key设计成login:token:userId而这里userId取的类型不对也会匹配不上,比如从claims里取到的是Integer或String不一致。
解决:后端在拦截器日志里增加一分钟的调试观察,把authHeader打印出来看一眼实际值。前端在请求拦截器里统一加上:config.headers['Authorization'] = 'Bearer ' + getToken()。注意Bearer和token之间有且仅有一个空格。顺便检查Redis里是否存了token,如果没存,说明登录接口里写Redis那一步没执行,去查登录代码有没有走到redisTemplate.opsForValue().set。
5.3 下单成功后商户端看不到新订单
现象:用户端已成功提交订单,套餐金额也扣了,但商户端订单列表始终为空。
原因:典型原因是订单表有merchant_id字段,但创建订单的Service层没有从菜品信息里反查出当前订单属于哪个商家,默认存了一个null或0。商户端查询订单时where merchant_id = ?查不到。也有可能是数据库里的关联字段名映射问题,比如merchantId和merchant_id没对应上。
解决:创建订单时根据购物车里的第一个菜品或者当前所选菜品的商家信息,把merchant_id正确写入订单主表。如果是一个订单包含多个商家的菜品,那设计上应为每个商家拆分开单,即一个购物车提交后按merchant_id分组生成多张订单。这一点你拿到这套源码后要重点看:它到底是支持一单多商家,还是只支持单商家下单。如果是前者,订单明细表里要有order_id关联,但一个订单不能属于多个商户,必须拆单。
排查步骤建议:先查数据库确认订单记录的merchant_id是否为null,再用商户账号的id手动拼一条SQL查询,看列表能否查出数据。能查出,问题在查询条件;查不出,问题在落库逻辑。
5.4 支付回调后订单未变成已支付
现象:前端使用模拟支付功能,页面上弹窗提示支付成功,但订单状态仍是待支付。
原因:可能是模拟支付接口更新了Redis或缓存中的订单状态,但数据库里的订单状态没有同步更新。还有一种情况是支付回调接口路径与controller映射不一致,导致请求被拦截器拦截或404,但前端以为成功。
解决:第一步,打开控制台Network看模拟支付请求是否返回200;第二步,如果返回200,直接看数据库里orders表的status字段是否真的被更新。如果数据库为0,说明后端更新代码没执行,多半是状态机校验没过——因为模拟支付入口调用时,当前订单状态可能是5(已取消),状态机不允许从5再跳到1。模拟支付接口在更新状态前,通常会先调用一遍查询接口判断订单是否存在且处于待支付状态。这里最容易被忽略的是@Transactional事务没有生效,比如方法被内部类调用而不是通过Spring代理调用。自己实现时务必通过OrderService.pay(...)这样从外部传入bean方式调用,不要用this.pay(),否则事务注解无效,异常时状态不会回滚,下次还会遇到脏数据。
5.5 菜品图片上传后访问404
现象:管理端上传菜品图片成功后,前端访问图片URL地址一直是404,数据库里面保存的是一串本地文件路径。
原因:这就是本地存储和静态资源映射没配好。图片被存到了服务器磁盘某个绝对路径,而Spring Boot的默认静态资源目录是classpath:/static/,磁盘路径不在这个范围内,Tomcat不会直接暴露给你。
解决:在application.yml里配置自定义静态资源映射:
spring: web: resources: static-locations: file:${upload.dir}/然后在配置类里把/images/**映射到磁盘目录:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${upload.dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/images/**") .addResourceLocations("file:" + uploadDir + "/"); } }同时确认上传的代码是保存到${upload.dir}目录下,而且目录要提前手动创建好,否则文件保存时会报找不到路径。开发环境下建议把upload.dir配置成./upload,相对工作目录比较好理解。项目打包后如果不指定绝对路径,它是相对于jar包所在目录的位置,这一点容易在服务部署后被迷惑。
6. 进阶验证:用自动化脚本跑通完整订单链路
有些时候写完代码觉得没问题,但上线前心里没底。这时候可以用一个简单的Shell脚本,把登录、加购、下单、支付、接单、配送、完成的完整链路自动跑一遍。方法是把后端的HTTP接口串起来,脚本里逐步调用,校验每个步骤的返回状态和订单状态。
#!/bin/bash BASE_URL="http://localhost:8080/api" echo "=== 1. 用户登录 ===" LOGIN_RESP=$(curl -s -X POST "$BASE_URL/user/login" \ -H "Content-Type: application/json" \ -d '{"phone":"13800000000","password":"123456"}') TOKEN=$(echo "$LOGIN_RESP" | jq -r '.data.token') echo "用户token获取结果: $TOKEN" echo "=== 2. 查询菜品列表 ===" DISH_RESP=$(curl -s -X GET "$BASE_URL/user/dish/list?categoryId=1" \ -H "Authorization: Bearer $TOKEN") DISH_ID=$(echo "$DISH_RESP" | jq -r '.data.list[0].id') echo "第一个菜品ID: $DISH_ID" echo "=== 3. 加入购物车 ===" curl -s -X POST "$BASE_URL/user/cart/add" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"dishId\":$DISH_ID,\"number\":1}" echo "加购完成" echo "=== 4. 提交订单 ===" ORDER_RESP=$(curl -s -X POST "$BASE_URL/user/order/submit" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"addressId":1,"remark":"快点送"}') ORDER_ID=$(echo "$ORDER_RESP" | jq -r '.data.orderId') echo "新订单ID: $ORDER_ID" echo "=== 5. 模拟支付 ===" curl -s -X POST "$BASE_URL/user/order/payMock" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d "{\"orderId\":$ORDER_ID}" echo "支付完成" echo "=== 6. 查询订单状态 ===" ORDER_STATUS=$(curl -s -X GET "$BASE_URL/user/order/detail?orderId=$ORDER_ID" \ -H "Authorization: Bearer $TOKEN") echo "$ORDER_STATUS" | jq '.data.status'这个脚本用了jq解析JSON,建议在Linux或macOS环境下执行。如果没有jq,可以用Python的json.tool替换,或者直接在脚本里用grep加正则提取字段。脚本的价值在于:每次改完订单状态机后,跑一遍能快速发现链路哪里断了。比如支付后状态如果是0而不是1,那就是状态更新处有bug,脚本十秒之内就能暴露问题。
完成这个验证后,再配合日志埋点检查慢SQL和异常。日志框架里把com.example.delivery.mapper的日志级别调到DEBUG,就能看到MyBatis-Plus自动生成的所有SQL语句和参数,排查懒查询和N+1问题特别有用:
logging: level: com.example.delivery.mapper: debug把订单相关的接口完整走一遍,然后再去改逻辑时就有底气得多。有一回我在这套模拟项目里调整完超时订单定时任务的逻辑,心里拿不准会不会出现重复关单的问题,就是用这个脚本连续跑三遍,同时监控数据库里的状态流转记录,确认没有一条订单被重复更新到取消状态,才敢把代码合并上去。从那以后我每次用这套项目做演示前,都强制自己先把这个链路脚本完整跑一遍,发现订单状态异常就先用日志定位,不急着动代码。希望它能帮你省掉一部分到处打听“项目怎么跑”的时间,也建议你把这种验证思路移植到别的工作里去。
本文还有配套的精品资源,点击获取