ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot外卖点餐系统:订单事务、库存扣减与数据库设计实践

Spring Boot外卖点餐系统:订单事务、库存扣减与数据库设计实践 简介这是一份面向Java毕业设计的外卖点餐系统完整源码与数据库打包资源以Spring Boot为后端框架适合计算机专业学生用于课程设计、毕业设计答辩或项目二次开发。资源包共281个文件包含60个Java后端类、51个Vue前端页面、92张PNG截图或设计图以及SQL数据库脚本、YML配置文件、Maven启动脚本和少量XML映射、Markdown说明等整体仅14.16MB目录结构清晰方便按模块检索与部署。项目在导师指导下完成并通过评审属于高分毕设参考模板源码中涵盖订单、店铺等核心业务模块的Service实现配合前端页面可快速理解Spring BootVue前后端分离的开发思路。目前已有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; ListOrderItemDetail 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在锁释放后重算条件发现stock0不满足影响行数为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.insertfallback返回下单失败但订单明细已经插入成功事务没有回滚。答辩时把这三个失效场景和自己的代码结合起来讲比背十遍事务的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表再处理缓存。如果项目里引入了RedisCache Aside模式是常见做法Service public class ShopServiceImpl implements ShopService { Autowired private ShopMapper shopMapper; Autowired private RedisTemplateString, 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_status0的记录很少范围查询走索引后过滤出的行数会很有限。批量接单时在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用tinyint1营业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也必须是decimalorder_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 订单号从哪来时间戳加随机数还是自增IDorder_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连接串里加上characterEncodingutf8。utf8mb4比utf8多支持emoji和一些生僻字外卖订单备注里如果用户写了特殊符号utf8会报Incorrect string value异常utf8mb4不会。提示如果导入后发现中文乱码先检查连接字符串里是否有characterEncodingutf8参数再确认表定义里的DEFAULT CHARSET两步能解决绝大多数乱码问题。5. 答辩现场可演示的构建验证与两个加分扩展点5.1 用mvnw.cmd验证可复现构建这个源码包顶层有mvnw.cmd和maven-wrapper.jar说明项目用了Maven Wrapper。答辩时不必打开IDE在项目根目录执行mvnw.cmd clean package -DskipTests java -jar target/xxx.jarwrapper会读取.mvn/wrapper/maven-wrapper.properties里锁定的Maven版本本机没装Maven也能构建。引申一句wrapper是让团队共用版本规避我本地能跑你本地跑不了的问题。5.2 加分扩展订单超时未支付自动取消订单表加pay_deadline字段下单时设为当前时间加15分钟启动类上加上EnableScheduling用定时任务兜底扫描Scheduled(fixedRate 30000) public void cancelExpiredOrders() { ListOrder 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拦截器解析后放入ThreadLocalpublic 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本身更值得讲的细节。本文还有配套的精品资源点击获取
返回列表