Spring Boot外卖点餐系统:订单事务、库存扣减与数据库设计实践
2026/9/16 18:48:15 网站建设 项目流程

简介:这是一份面向Java毕业设计的外卖点餐系统完整源码与数据库打包资源,以Spring Boot为后端框架,适合计算机专业学生用于课程设计、毕业设计答辩或项目二次开发。资源包共281个文件,包含60个Java后端类、51个Vue前端页面、92张PNG截图或设计图,以及SQL数据库脚本、YML配置文件、Maven启动脚本和少量XML映射、Markdown说明等,整体仅14.16MB,目录结构清晰,方便按模块检索与部署。项目在导师指导下完成并通过评审,属于高分毕设参考模板,源码中涵盖订单、店铺等核心业务模块的Service实现,配合前端页面可快速理解Spring Boot+Vue前后端分离的开发思路。目前已有509人学习/浏览,经过多人验证,对需要快速搭建外卖点餐系统或参考毕设代码的读者都有实际帮助。

1. Spring Boot外卖点餐系统:订单与店铺两条业务主线如何落进可运行源码

外卖点餐系统在Spring Boot毕业设计里属于中等偏上体量的选题,因为它不只是CRUD:订单要管状态流转,库存要防并发超卖,商家端和用户端共享同一套数据却呈现不同视角。这个源码包最值得先看的不是src目录下的Controller,而是顶层那几个文件——mvnw.cmd和maven-wrapper.jar意味着没有本地Maven也能直接构建,index.html和favicon.ico说明前端静态资源被打包进Spring Boot统一发布。核心代码集中在OrderServiceImpl和ShopServiceImpl,配合SQL脚本里的用户、店铺、商品、订单、订单明细五张表,覆盖外卖平台最主干的两条业务链路。对课程设计、毕业设计或者Spring Boot面试前的系统复习,这套代码能让你在技术和表达上都有话可说。

2. 订单域拆解:OrderServiceImpl里的状态字段、库存扣减与事务边界

2.1 订单状态字段的设计与推进方式

订单表里第一个要门儿清的字段就是status。外卖业务的订单状态一般用整数表达:0待支付、1已支付、2商家接单、3配送中、4已完成、5已取消。这个值会同时出现在用户端订单列表、商家端待处理列表、数据库索引和统计SQL里,所以代码里要用枚举管理,不要写魔法数字。以下是一个按业务倒推拆出来的状态枚举,也是订单主表status字段的取值依据:

public enum OrderStatusEnum { WAIT_PAY(0, "待支付"), PAID(1, "已支付"), ACCEPTED(2, "商家已接单"), DELIVERING(3, "配送中"), FINISHED(4, "已完成"), CANCELED(5, "已取消"); private final int code; private final String desc; OrderStatusEnum(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }

在OrderServiceImpl里推进状态时,要做的是通过枚举code更新数据库,而不是直接在方法里写updateStatus(orderId, 2)这种裸数字。状态枚举一旦集中管理,后面加退款状态、加超时自动取消状态,改动范围都会收敛在一个类里。前端要展示"已支付"还是"待支付",只需要根据code查desc,不用后端再拼一遍。

有一个常见误区:用varchar存"PAID"这样的字符串状态,看着直观,但订单列表查询时大小写写错就查不到数据,而且varchar比tinyint占用空间更大,状态字段参与联合索引时的区分度也不如整数枚举。整数状态配合枚举类,是Spring Boot项目里最稳妥的写法。

2.2 下单事务的骨架与库存条件更新

订单创建是外卖系统里事务边界最清晰的链路:校验店铺营业状态、校验商品在售与库存、插入订单主表、插入订单明细、扣减商品库存。任何一个环节失败,之前的所有写操作都应该回滚。以下是对OrderServiceImpl核心方法做的骨架级还原:

@Override @Transactional(rollbackFor = Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 店铺是否正常营业 Shop shop = shopMapper.selectById(dto.getShopId()); if (shop == null || shop.getStatus() != 1) { throw new BizException("店铺不存在或已打烊"); } // 2. 遍历购物项,校验商品并累加总价 BigDecimal totalPrice = BigDecimal.ZERO; List<OrderItemDetail> details = new ArrayList<>(); for (OrderItemDTO item : dto.getItems()) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStatus() != 1) { throw new BizException("商品已下架: " + item.getProductId()); } // 条件更新,返回0说明库存不足 int affected = productMapper.deductStock(item.getProductId(), item.getQuantity()); if (affected == 0) { throw new BizException("商品库存不足: " + product.getName()); } totalPrice = totalPrice.add(product.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); details.add(buildDetail(product, item.getQuantity())); } // 3. 插入订单主表 Order order = buildOrder(dto, totalPrice); orderMapper.insert(order); // 4. 批量插入订单明细 for (OrderItemDetail detail : details) { detail.setOrderId(order.getId()); orderDetailMapper.insert(detail); } return OrderVO.from(order); }

其中productMapper.deductStock对应的SQL必须写成条件更新:

update product set stock = stock - #{quantity} where id = #{productId} and status = 1 and stock >= #{quantity}

这里有两个关键点。第一,where里带stock >= #{quantity},让数据库在更新时自行判断库存是否充足;两个请求同时买最后一件商品时,第一个update先拿到行锁并完成扣减,第二个update在锁释放后重算条件发现stock=0不满足,影响行数为0。这样是从原理上杜绝超卖,而不是靠Java代码先select再判断。第二,事务的隔离级别默认是MySQL的REPEATABLE READ,行锁的等待由InnoDB引擎自行管理,代码里只需要关心接口返回,不用手动加锁。唯一要注意的是,业务上抛出的BizException必须是RuntimeException的子类,否则默认不回滚。

关于锁粒度,还有一个细节值得注意:这里锁的是product表的行,不是order表。如果先insert订单再update库存,两个并发请求会各自插入订单后进入库存更新阶段排队,虽然结果一致,但订单主表会短暂出现两条待支付记录,对一致性审视不友好。先扣库存再插订单,能让事务内最早拿到锁的位置更前置,后续步骤都在持锁状态下完成,逻辑上更接近"锁定资源再落单"。

2.3 @Transactional失效的三个高频场景

外卖系统里最容易出现"事务看起来生效了其实没有"的地方有三个:

场景根本原因处理方式
private方法上标注@TransactionalSpring AOP代理只能拦截public方法改成public,或者把事务逻辑拆到独立Service
同类内部this调用带事务的方法this引用绕过代理对象注入自身,或拆分Service
事务方法内catch了异常没重新抛出事务管理器感知不到异常捕获后重新抛出,或者用TransactionTemplate手动回滚

第3个场景在下单逻辑里很典型。有些写法是try-catch包住orderMapper.insert,fallback返回"下单失败",但订单明细已经插入成功,事务没有回滚。答辩时把这三个失效场景和自己的代码结合起来讲,比背十遍"事务的ACID"要有效得多。

3. 商家侧管理:ShopServiceImpl的营业状态缓存与商家端查询约束

3.1 店铺与订单为什么必须拆成独立Service

从代码组织上看,这个外卖系统的ShopServiceImpl负责店铺基本信息、营业状态切换、店铺搜索和店铺下的商品列表,OrderServiceImpl负责下单、支付回调、订单状态推进、订单查询。把店铺拆成独立服务并不只是为了"高内聚低耦合"这句口号。真正的业务原因是,店铺状态会被用户端列表、用户端下单、商家端接单三个场景同时读取,而订单状态只有下单和商家端操作两条链路会写入。两者拆开后,事务边界变小,改营业状态的update语句只锁shop表行,不会波及order表;反过来,大促时高频的订单插入也不会因为店铺信息的更新产生无谓的行锁竞争。

从调用关系上看,OrderServiceImpl里要校验shop的状态,但它不应该直接操作shop表,而是调ShopService暴露的checkShopOpen方法。这样做避免了两个Service操作同一张表导致缓存和数据库不一致的问题,也方便后续引入Redis时把缓存逻辑全部收拢在ShopServiceImpl内部。

3.2 营业状态切换与缓存Fallback策略

店铺营业状态是外卖系统的"总开关"。源码里ShopServiceImpl的switchStatus方法通常会先更新shop表,再处理缓存。如果项目里引入了Redis,Cache Aside模式是常见做法:

@Service public class ShopServiceImpl implements ShopService { @Autowired private ShopMapper shopMapper; @Autowired private RedisTemplate<String, Object> redisTemplate; private static final String SHOP_CACHE_PREFIX = "shop:info:"; @Override public boolean switchStatus(Long shopId, Integer status) { // 1. 更新数据库 Shop update = new Shop(); update.setId(shopId); update.setStatus(status); int affected = shopMapper.updateById(update); // 2. 删除缓存,让下次读请求回源数据库 if (affected > 0) { redisTemplate.delete(SHOP_CACHE_PREFIX + shopId); } return affected > 0; } @Override public Shop getShopById(Long shopId) { // 1. 先查缓存 Shop shop = (Shop) redisTemplate.opsForValue().get(SHOP_CACHE_PREFIX + shopId); if (shop != null) { return shop; } // 2. 缓存未命中,回源数据库 shop = shopMapper.selectById(shopId); if (shop != null) { // 3. 回填缓存,设置30分钟过期 redisTemplate.opsForValue().set(SHOP_CACHE_PREFIX + shopId, shop, 30, TimeUnit.MINUTES); } return shop; } }

这里有几个参数和坑值得说明。删除缓存而不是更新缓存,是为了避免并发更新缓存时把旧值写回去。过期时间设30分钟而不是永久,是为了商品价格、起送价这类信息在极端情况下最多漂移半小时。如果源码里没有Redis,用本地ConcurrentHashMap做缓存也可以,但要注意:本地缓存每个实例各存一份,多实例部署时会读到旧状态;重启进程直接清空;切换营业状态后必须手动remove掉对应key,否则用户端会一直看到打烊前的门店状态。

提示:切换营业状态后一定要主动删除缓存,不能依赖自然过期。用户端看到的店铺状态最多误差30秒,而不是30分钟。

3.3 商家端订单列表索引设计与状态批量更新

商家端的核心操作是"查看待处理订单"和"批量接单"。订单表如果只按user_id建索引,商家每次查询都会走全表扫,数据到几万条后体验立刻劣化。常见做法是在order表上建联合索引:

alter table `order` add index idx_shop_status_time (shop_id, order_status, create_time);

这个联合索引能同时支持两个查询模式:where shop_id = ? and order_status = 0用来查待处理订单,where shop_id = ? and create_time >= ?用来查一段时间内的订单列表。注意这里把create_time放在联合索引第三位,是为了让order_status的筛选和订单时间的范围查询互不干扰——如果order_status=0的记录很少,范围查询走索引后过滤出的行数会很有限。

批量接单时,在Service里循环调用orderMapper.updateStatus即可,单条update走主键,性能可接受。分页查询建议在SQL里使用order by create_time desc limit #{offset}, #{pageSize},offset过大会出现深分页问题,毕业设计阶段能把联合索引和分页参数说明白就够了。

4. 数据库脚本里的表设计:从ER关系到订单号、索引与字符集陷阱

4.1 五张核心表的字段拆分与关联关系

这个外卖系统的SQL脚本里,最核心的表是user、shop、product、order、order_detail,关系是:用户与订单是一对多,店铺与商品是一对多,订单与商品通过order_detail做多对多关联。它们在脚本里落到表结构时的一些关键选型如下:

核心字段设计要点
userid, nickname, phone, address, create_timephone要建唯一索引,登录和统计都靠它
shopid, name, status, business_hours, create_timestatus用tinyint,1营业0打烊
productid, shop_id, name, price, stock, status(shop_id, status)联合索引,price用decimal(10,2)
orderid, order_no, user_id, shop_id, total_price, status, create_timeorder_no建唯一索引,total_price也必须是decimal
order_detailid, order_id, product_id, product_name, price, quantityorder_id建普通索引,product_name做冗余字段

其中price字段必须用decimal而不是float或double,这是个高频失分点。外卖结算涉及金额累加,float在二进制里本身就不能精确表示0.1,累计几笔后会出现类似于38.8499999999的金额。decimal(10,2)精度可控,总价字段保留两位小数,配合BigDecimal计算商品总价,答辩时主动提这个点,通常能换一个正向反馈。

另一个容易被忽视的细节是不建物理外键。阿里开发规范里明确说禁止使用外键,产品表、订单表之间的关联完全由应用层保证。物理外键在插入时会触发额外的校验和锁,高并发写入场景下会拖慢性能;逻辑外键靠索引和代码约束,删除数据时也没那么多"Cannot delete or update a parent row"的限制。毕业设计里建物理外键虽然显得"严谨",但被追问性能影响时反而不好回答。

4.2 订单号从哪来:时间戳加随机数还是自增ID

order_no字段要建唯一索引,因为它是用户、骑手、商家之间沟通订单时的凭证。如果直接用数据库自增id当订单号,订单量会被任何人看穿,而且不同表的自增孤立,后续做分库分表时合并数据会撞主键。常见做法是时间戳加随机数:

private String generateOrderNo() { SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss"); String timePart = sdf.format(new Date()); int randomPart = ThreadLocalRandom.current().nextInt(1000, 9999); return timePart + randomPart; }

生成的order_no形如20250510143015231,时间精确到秒,随机部分规避同一秒内的冲突。理论上同一秒内生成9000笔订单才会撞号,但为了稳妥,order_no上的唯一索引作为兜底,一旦插入时报DuplicateKeyException就重新生成一次。比UUID短,比雪花算法容易解释,这是毕业设计场景下性价比最高的方案。

4.3 建表SQL的order关键字与命名习惯

MySQL里order是关键字,直接执行create table order (...)会直接报语法错误。源码脚本里一定处理过这个问题,要么给order加反引号写成order,要么表名起成orders或tb_order。建议表名用order加反引号,Java代码里对应实体类Order,语义最直观;如果导师不喜欢反引号,就用orders。至于status这种字段,建议命名成order_status、pay_status,因为"status"单独出现时,项目跑到后面你根本分不清是哪张表的status。

4.4 导入数据库脚本时要注意的字符集与排序规则

数据库脚本如果用Navicat直接导入,默认字符集可能是latin1,导入后中文全变乱码。执行脚本前统一确认三处:表字符集是utf8mb4,排序规则是utf8mb4_general_ci或utf8mb4_0900_ai_ci,连接串里加上characterEncoding=utf8。utf8mb4比utf8多支持emoji和一些生僻字,外卖订单备注里如果用户写了特殊符号,utf8会报"Incorrect string value"异常,utf8mb4不会。

提示:如果导入后发现中文乱码,先检查连接字符串里是否有characterEncoding=utf8参数,再确认表定义里的DEFAULT CHARSET,两步能解决绝大多数乱码问题。

5. 答辩现场可演示的构建验证与两个加分扩展点

5.1 用mvnw.cmd验证可复现构建

这个源码包顶层有mvnw.cmd和maven-wrapper.jar,说明项目用了Maven Wrapper。答辩时不必打开IDE,在项目根目录执行:

mvnw.cmd clean package -DskipTests java -jar target/xxx.jar

wrapper会读取.mvn/wrapper/maven-wrapper.properties里锁定的Maven版本,本机没装Maven也能构建。引申一句:wrapper是让团队共用版本,规避"我本地能跑你本地跑不了"的问题。

5.2 加分扩展:订单超时未支付自动取消

订单表加pay_deadline字段,下单时设为当前时间加15分钟,启动类上加上@EnableScheduling,用定时任务兜底扫描:

@Scheduled(fixedRate = 30000) public void cancelExpiredOrders() { List<Order> expired = orderMapper.selectExpiredUnpaid(); for (Order order : expired) { orderMapper.updateStatus(order.getId(), OrderStatusEnum.CANCELED.getCode()); productMapper.restoreStockByOrderId(order.getId()); } }

selectExpiredUnpaid的SQL核心是create_time < now() - interval 15 minute and order_status = 0。注意定时任务只是兜底,实时取消要依赖RabbitMQ延迟消息,答辩时把这句话说出口,评价会有区分度。

5.3 加分扩展:JWT登录与拦截器透明取用户

下单接口要拿到当前登录人的userId,常见做法是登录成功签发JWT,拦截器解析后放入ThreadLocal:

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token = request.getHeader("Authorization"); if (!JwtUtils.verify(token)) { response.setStatus(401); return false; } UserContext.setUserId(JwtUtils.getUserId(token)); return true; }

注意,ThreadLocal必须在请求结束后由afterCompletion方法调用remove清理,不然线程池复用会串数据,这是比JWT本身更值得讲的细节。

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

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

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

立即咨询