尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot网上商城源码解析:架构设计、订单状态机与并发扣库存

Spring Boot网上商城源码解析:架构设计、订单状态机与并发扣库存 简介这是一套基于 Java 与 Spring Boot 框架开发的网上商城购物系统源码适合正在学习 Spring Boot 实战、需要完成电商课程设计或毕业设计的开发者。系统覆盖商品管理、购物车、订单、用户地址与在线客服等常见电商业务能帮助读者快速理解前后端分离商城项目的基本结构。资源包共 831 个文件约 19.21MB以 Java 后端源码、Vue 前端组件、JavaScript 脚本、HTML 页面与 CSS 样式为主同时包含 SQL 数据库脚本和 Maven 构建配置目录结构较完整便于按模块查阅。目前已有 36 人在 CSDN 学习下载可作为项目起步时的参考。项目在购物车、在线客服、地址管理三个模块中实现了增删改查、分页查询、条件筛选与排序并提供按指定列和类型统计记录的提醒接口同时附带 1-install.bat、2-run.bat、3-build.bat 等部署脚本方便本地快速运行调试。源码中包含后台管理界面与常见工具配置适合用来对照练习后端接口开发、Vue 页面联调及数据库设计。1. 用Java和Spring Boot搭网上商城系统源码里真正值钱的是模块拆分思路拿到这个标题下的源码zip很多人第一件事是解压、配个MySQL、跑起来看首页轮播图。但说实话Spring Boot写的网上商城仓库里一搜就是几十个版本页面长得都差不多真正拉开差距的是商品、购物车、订单这三条主线的边界划分以及并发下单时怎么保库存不超卖。这套源码覆盖的人群很明确刚啃完Java基础语法、想用Spring Boot做第一个完整项目的开发者需要交课程设计或毕业设计的在校生还有接小私活时想快速搭一套商城后端的人。跑通页面只是第一步读懂模块拆法才能自己改需求。这篇就按我拿到这种项目时会做的事来讲先立架构再建表然后走通下单链路最后落到本地启动和排错。2. 网上商城的Spring Boot四层架构Controller、Service、Mapper与领域模型2.1 Controller层只做参数接收别把业务逻辑写进来我见过不少Spring Boot项目Controller里直接new ServiceImpl、直接写SQL查询甚至有人把扣库存的UPDATE直接写在Controller方法里。这种写法在商城这种业务里撑不过第二个需求变更。常见做法是严格走四层Controller接收HTTP请求、Service处理业务、Mapper访问数据库、领域对象承载数据。Controller只做三件事解析参数、调用Service、包装返回值。看一段最小示例RestController RequestMapping(/api/goods) public class GoodsController { private final GoodsService goodsService; public GoodsController(GoodsService goodsService) { this.goodsService goodsService; } GetMapping(/page) public ResultPageResultGoodsVO page(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { return Result.ok(goodsService.pageQuery(pageNum, pageSize)); } }这段代码里没有出现任何SQL没有出现if判断库存的语句。如果哪一天要把分页从PageHelper换成MyBatis-PlusController一行都不用改。判断一个商城源码的Controller层写得好不好就看方法里有没有超过三行以上的逻辑。参数校验可以交给JSR-303的Valid注解返回值统一用Result 包装这样前端处理起来也一致。接口路径设计上/api/goods/page这种按资源拆分的风格比/queryGoodsList更容易扩展。2.2 Service层的事务边界Transactional放在哪个方法上四层架构里Service层是业务规则的容器也是事务的边界。商城项目里事务最密集的地方在订单创建生成订单主表、生成订单明细、扣减库存、如果用了优惠券还要标记已使用这些操作必须在一个事务里任何一个失败都要回滚。很多新手犯的错是把Transactional加在Controller方法上或者加在Mapper接口上前者事务范围太大后者事务粒度太碎。正确位置是Service的实现类或接口方法上Service public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final GoodsMapper goodsMapper; public OrderServiceImpl(OrderMapper orderMapper, OrderItemMapper orderItemMapper, GoodsMapper goodsMapper) { this.orderMapper orderMapper; this.orderItemMapper orderItemMapper; this.goodsMapper goodsMapper; } Transactional(rollbackFor Exception.class) public Order createOrder(Long userId, ListOrderItemParam items) { // 1. 校验商品状态与库存 // 2. 计算订单总金额 // 3. 插入订单主表 // 4. 批量插入订单明细 // 5. 扣减库存 return order; } }注意rollbackFor Exception.class这个参数。Spring的Transactional默认只在抛出RuntimeException时回滚检查异常不会触发回滚。商城下单时如果抛出一个checked exception表示库存不足事务不滚订单主表已经插进去了后面对账时会多出一堆孤儿订单。显式声明rollbackFor是最稳妥的写法。另外事务方法不能通过this调用否则代理不生效这个问题在Spring Boot里很隐蔽排查时优先看调用链上有没有经过Spring容器代理。2.3 MyBatis与Spring Boot的整合Mapper接口和XML的对应关系商城系统的查询条件通常很多商品名称模糊查、价格区间、分类筛选、上下架状态写死在Mapper接口的注解里会让SQL难以维护。源码里常见的做法是XML与接口分离。接口只声明方法XML里写SQL两者通过命名空间和方法名绑定。JPA系的Query写不了复杂动态SQLMyBatis的XML方案在商城场景下更顺手。看一个分页查询的声明Mapper public interface GoodsMapper { ListGoods selectPage(Param(offset) int offset, Param(limit) int limit, Param(status) Integer status); }对应XMLselect idselectPage resultTypecom.example.mall.entity.Goods SELECT id, goods_name, price, stock, status FROM goods where if teststatus ! null AND status #{status} /if /where ORDER BY sort_order DESC LIMIT #{offset}, #{limit} /select这里有几个参数值得细看。Param注解指定了SQL里引用的参数名XML里用#{offset}和#{limit}做预编译占位能防止SQL注入。resultType直接映射到实体类Goods字段名和列名通过map-underscore-to-camel-case配置自动转驼峰。LIMIT #{offset}, #{limit}是MySQL的分页写法offset表示跳过多少条limit表示取多少条。如果前端传pageNum2、pageSize10Service层要自己算offset(pageNum-1)*pageSize。这种分页方式在数据量小于百万时没问题数据量大了要改成基于游标的分页这是另一个话题。四层架构落实到商城项目还有一个容易被忽略的角色VO。Goods是数据库实体GoodsVO是给前端看的视图对象。价格字段在数据库里是DECIMALJava对应BigDecimalVO里可以再加工成字符串“¥99.00”实体类不应该掺和展示逻辑。3. 商品与购物车核心数据表设计和接口实现3.1 商品表、SKU表、购物车表的设计要点商城的数据表设计第一张表是商品表goods第二张是库存表sku第三张是购物车表cart。小项目里商品和SKU会合并成一张表字段里加一个规格描述。但严格来说一个商品有多个颜色、多个尺码每个规格对应不同库存和价格时必须拆出SKU表。看一套最简的三表结构CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, sort_order INT NOT NULL DEFAULT 0, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_status_sort (status, sort_order) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, goods_id BIGINT NOT NULL, sku_name VARCHAR(64) NOT NULL, sku_price DECIMAL(10,2) NOT NULL, sku_stock INT NOT NULL DEFAULT 0, UNIQUE KEY uk_goods_sku (goods_id, sku_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE cart ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, selected TINYINT NOT NULL DEFAULT 1 COMMENT 1选中 0未选中, UNIQUE KEY uk_user_sku (user_id, sku_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张购物车表的设计关键在uk_user_sku这个唯一键。它保证同一个用户加购同一个SKU时只有一条记录不会出现重复行。DECIMAL(10,2)用来存价格float和double绝不用于金额计算这个不需要讨论。sku表里UNIQUE KEY uk_goods_sku(goods_id, sku_name)防止同一商品下出现同名规格。索引上goods表的idx_status_sort(status, sort_order)是给商城首页的列表查询用的WHERE status 1 ORDER BY sort_order DESC正好命中这个组合索引。3.2 商品列表的分页查询PageHelper还是手写LIMIT商城首页的商品列表、搜索页的结果展示都需要分页。Spring Boot生态里两个常见方案PageHelper和MyBatis-Plus的分页插件。PageHelper用起来最省事调用PageHelper.startPage(pageNum, pageSize)后紧接着那条查询SQL自动带上LIMIT而且会执行一条COUNT查询拿到总数。public PageResultGoodsVO pageQuery(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListGoods goodsList goodsMapper.selectPage(pageNum, pageSize, 1); PageInfoGoods pageInfo new PageInfo(goodsList); // 组装分页返回结构 }手写LIMIT的方案更可控SQL直观不依赖中间件。两者冲突时我一般这样做简单查询用PageHelper效率高涉及多表JOIN或者查询很重的接口用手写SQL配合二级缓存。PageHelper有个坑startPage只会对紧接着的第一个查询生效中间如果插入了别的Mapper查询分页会作用到错误的SQL上。所以代码里startPage和查询语句之间不能隔业务逻辑。排查这类问题时看SQL日志PageHelper会在日志里打印出带LIMIT的完整SQL对照一下就知道有没有串页。3.3 购物车加购的幂等处理唯一索引与原子更新加购接口是商城的高频操作用户点一次加购前端可能会因为网络超时重试两次。如果代码写成先查购物车有没有这条记录没有就insert有就update把数量加一并发场景下两个请求同时查到null会插入两条相同的记录。防止这个问题的第一道防线是表上的uk_user_sku唯一索引第二道防线是业务代码里用INSERT或UPDATE的原子写法public void addToCart(Long userId, Long skuId, int quantity) { int updated cartMapper.increaseQuantity(userId, skuId, quantity); if (updated 0) { // 说明购物车里还没有这条SKU记录 Cart cart new Cart(); cart.setUserId(userId); cart.setSkuId(skuId); cart.setQuantity(quantity); cart.setSelected(1); cartMapper.insert(cart); } }对应Mapper里的更新语句UPDATE cart SET quantity quantity #{quantity} WHERE user_id #{userId} AND sku_id #{skuId}increaseQuantity返回的int是受影响行数。如果等于0说明where条件没命中购物车里没有这条记录才走insert分支。这个写法把“先查再写”的竞态窗口压缩到最小。UPDATE语句本身是行级原子操作两个并发请求同时执行同一个UPDATEMySQL的行锁会让它们串行执行数量不会丢。insert那边如果并发撞了唯一索引会抛DuplicateKeyException业务层要把这个异常catch住重新执行一次increaseQuantity或者直接返回“已在购物车中”的提示。用ON DUPLICATE KEY UPDATE可以一条SQL解决这件事但可读性稍差我一般更倾向上面这种两步逻辑。购物车还有一个常见的业务需求勾选、取消勾选、批量删除、清空。这些操作全部围绕cart表的主键或user_id进行不需要触碰goods和sku表。要注意的是查询购物车列表时需要把goods_name、sku_name、商品图片、价格这些冗余信息JOIN出来这个查询要控制在一次性查出所有字段不要在循环里逐条查SKU否则就是经典的N1问题。4. 订单流程与支付回调状态机、库存扣减和幂等设计4.1 订单状态机的流转设计从待支付到已取消订单是商城系统的核心它连接了购物车、库存、支付和物流。状态机设计得好不好直接决定后面写退款、售后、对账时痛不痛苦。网上商城的订单状态一般有五种待支付、已支付、已发货、已完成、已取消。用枚举定义public enum OrderStatus { PENDING_PAYMENT(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELLED(4, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public static boolean canChange(int from, int to) { // 只允许合法的状态迁移 return (from PENDING_PAYMENT.code to PAID.code) || (from PENDING_PAYMENT.code to CANCELLED.code) || (from PAID.code to SHIPPED.code) || (from SHIPPED.code to COMPLETED.code); } }状态不能随意跳转。待支付可以到已支付也可以到已取消但不能直接到已发货已支付不能回到待支付。所有更新订单状态的SQL必须带状态条件用乐观锁的思路防止状态错乱UPDATE orders SET status #{newStatus}, pay_time NOW() WHERE id #{orderId} AND status #{oldStatus}这条UPDATE是关键。如果返回0说明订单状态已经变了比如支付回调并发触发了两次第二次执行时status已经不是待支付了就不会重复更新。网上商城源码里最容易出问题的地方就在这里直接UPDATE orders SET status #{newStatus} WHERE id #{orderId}不带旧状态条件结果就是两个请求都成功订单被覆盖成未知状态。4.2 下单时扣库存用UPDATE ... WHERE stock quantity下单扣库存是并发问题的高发区。最简单的错误写法是先SELECT stock查出来在Java里判断库存够不够再UPDATE stock stock - quantity。这个流程在并发下有明显的竞态窗口两个请求同时查到stock1都判断库存充足都执行扣减库存变成-1。正确做法是把判断和扣减放到一条UPDATE里UPDATE goods SET stock stock - #{quantity} WHERE id #{goodsId} AND stock #{quantity}这条SQL的执行逻辑MySQL对goods表的这一行加行锁读取当前stock值判断stock quantity是否成立。成立则减不成立则不加返回受影响行数0。Java侧的判断就简单了int updated goodsMapper.deductStock(skuId, quantity); if (updated 0) { throw new InsufficientStockException(库存不足); }返回1表示扣减成功返回0表示库存不足。这个方式比在Java里先查再判更可靠因为判断和扣减在同一个数据库事务原子操作里完成。还有一个细节goods表和sku表如果拆开了扣减要针对sku_stock字段。另外定期对账时要把订单明细里的sku_id和数量累加跟实际扣减记录对比防止程序bug导致库存漂移。4.3 支付回调的验签与幂等同一个通知必须只处理一次支付回调是商城系统里最需要谨慎对待的接口。支付平台会多次发送异步通知直到商户返回成功标识。回调接口的设计必须满足两个条件验签和幂等。验签防止伪造回调幂等保证同一个订单被通知多次时只处理一次。看一个回调接口的骨架PostMapping(/pay/notify) public String payNotify(RequestBody String notifyData) { // 1. 验签用平台公钥或密钥对notifyData做签名验证 if (!payService.verifySign(notifyData)) { return failure; } // 2. 解析出订单号、支付金额、支付流水号 String orderNo parseOrderNo(notifyData); String payStatus parsePayStatus(notifyData); // 3. 幂等处理 Order order orderMapper.selectByOrderNo(orderNo); if (order null || order.getStatus() ! OrderStatus.PENDING_PAYMENT.getCode()) { return success; } // 4. 校验金额是否一致 if (order.getTotalAmount().compareTo(parseAmount(notifyData)) ! 0) { return failure; } // 5. 更新订单状态为已支付 orderMapper.updateStatus(order.getId(), OrderStatus.PAID.getCode()); return success; }第三步的if判断就是幂等关键。第一次回调进来状态是待支付更新成功。第二次、第三次回调进来订单状态已经是已支付直接返回success不重复更新。这里不能反过来先更新状态再返回万一更新成功后返回failure平台会一直重试每次重试都会执行一次幂等判断虽然不会出错但会有无谓的数据库压力。金额校验也别省。平台回调里的支付金额要和本地订单金额严格比对用BigDecimal的compareTo不用equals因为2.0和2.00在BigDecimal的equals比较下是false。回调处理完后可以考虑把完整的回调原始报文存一张日志表方便后续排查对账问题。5. Spring Boot商城源码的本地运行与排查技巧5.1 用Actuator快速确认模块健康状态拿到源码后第一件事是跑起来跑起来之后确认各模块正常。Spring Boot Actuator是排查运行时问题最直接的工具。在pom.xml里加一个依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency然后配置暴露端点management: endpoints: web: exposure: include: health,info,metrics启动后访问/actuator/health返回{status:UP}就说明应用本身活着。如果数据源配置有问题health端点会显示DOWN并附加数据库连接失败的明细。有一点要注意生产环境不要把所有端点都暴露出去include里只写需要的或者直接用management.endpoints.web.exposure.includehealth,info避免把beans、env这些内部信息暴露到公网。5.2 多环境配置dev与prod的切换商城项目至少要区分开发环境和生产环境。常见做法是用spring.profiles.active配合多个配置文件# application.yml spring: profiles: active: devapplication-dev.yml里放本地数据库连接数据库地址是localhost密码是本地密码日志级别用DEBUG。application-prod.yml里放线上数据库连接连接池参数调大日志级别用INFO。打包发布时用命令行指定环境mvn clean package -DskipTests java -jar mall.jar --spring.profiles.activeprod这样切换环境不用改代码启动参数指定即可。配置里还要注意MySQL连接串的时区参数serverTimezoneAsia/Shanghai这个参数缺了会导致日期字段差8小时商城订单时间如果从凌晨零点附近错到前一天对账时核对半天。5.3 端口占用、数据源连不上、静态资源404的排查顺序本地启动商城源码最常见的三个报错端口被占用、数据源连不上、静态资源404。端口占用看启动日志里的Web server failed to start. Port 8080 was already in use解决方式是指定新端口java -jar mall.jar --server.port8081数据源连不上时看日志有没有Access denied for user或者Communications link failure。前者是用户名密码不对后者是MySQL服务没启动或者端口不对。先用命令行测一下mysql -u root -p能连上再排查Java侧配置。静态资源404要区分是Spring Boot没找到页面还是接口路径不对。Spring Boot默认的静态资源根目录是classpath:/static/前端页面、css、js都放这个目录。如果页面能打开但接口返回404多半是RestController和Controller混用导致返回了视图名而不是JSON。最后一招本地复现问题时把spring.datasource.hikari.connection-timeout调大一点再把logging.level.com.example.mall.mapper调成DEBUG就能看到每条SQL的完整参数。商城这种业务日志里能看到SQL问题就解决一半。本文还有配套的精品资源点击获取
返回列表