ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Spring Boot雪具销售系统:从SKU设计到订单状态机的完整落地

Spring Boot雪具销售系统:从SKU设计到订单状态机的完整落地 简介基于Spring Boot的雪具销售系统完整源码定位为Java学习者、计算机或电子信息工程专业学生完成课程设计、期末大作业或毕业设计的参考项目。系统采用B/S架构与MVC分层后端以Spring Boot为基础整合MyBatis与MySQL前端涉及Vue、Ajax等可作为前后端交互与增删改查业务的综合练习。资源包共636个文件压缩后约16.78MB其中Java源码实现业务逻辑Vue组件与SVG、CSS、JS等构成管理端界面XML与yml文件承担配置管理jpg/png提供页面素材bat脚本用于快速构建与启动整体目录结构较为清晰。已有93人浏览学习代码经过严格测试解压后可在IDEA/Eclipse等环境中配合JDK1.8、Maven 3.6、MySQL 5.7运行适合参考其模块拆分、接口编写和页面联调方式也可作为答辩演示程序的基础。1. 基于Spring Boot的雪具销售系统代码到底该怎么落笔这个标题看着像毕设仓库但它把真正值钱的点藏起来了。做基于Spring Boot的雪具销售系统能不能上线拼的根本不是商品列表写得有多花而是三件事库存怎么扣不超、订单状态怎么转不脏、支付回调怎么收不重。雪具这类商品又有自己的怪脾气——同一块雪板分长度同一双雪鞋分脚长和宽度库存维度比普通服装多一层普通电商的“多规格”思路直接套会翻车。这篇文章按我给自己项目搭底子的顺序写先定表再写下单链路然后用一个轻量状态机把订单生命周期管住最后用集成测试把代码钉死。后端只出JSON API前端随便接Vue或小程序都能跑这也是Spring Boot 3.x REST API最主流的落地形态。2. 先定表再写代码雪具SKU、库存与订单实体的落地设计2.1 雪具SKU为什么不能走普通电商的“多规格”路子普通T恤的SKU是颜色加尺码最多两个维度。雪具不一样。拿滑雪板举例同一款ATOMIC Q9有158cm、165cm、170cm三种长度每种长度可能对应不同的转弯半径和硬度雪鞋更麻烦鞋码用的是Mondo Point26.0、26.5还要叠加鞋楦宽度last width比如98mm和102mm穿进去完全是两个体验固定器又要看脚码范围还要看制动器宽度匹不匹配板腰。如果按“商品一张表规格塞JSON”的做法库存会乱到没法对账。常见做法是直接用SKU表把每个可卖单元拍平一个SKU对应一个具体板长、一个具体Mondo尺码、一个具体制动器宽度库存数挂在SKU这一层。查询时按型号筛SKU列表规格差异用JSON字段展示但库存绝不放JSON里。2.2 订单表结构把“状态流转记录”当一等公民订单领域至少要拆四张核心表商品表、SKU表、订单主表、订单项表。额外加一张订单状态履历表这很多人会漏。状态履历表存的是“什么时间、从什么状态、因为什么事件、变成什么状态”没有它售后排查只能靠翻日志开发环境还行上线后会被运营追着骂。订单主表不存商品快照商品快照放订单项表字段包括下单时的商品名、SKU规格JSON、单价。这样SKU后续改价改描述历史订单不受影响。状态字段用字符串枚举不建议用数字因为1到底是“待支付”还是“已支付”三个月后没人记得字符串PENDING_PAYMENT一目了然。2.3 用MySQL DDL把库存和订单的关系锁死下面这个建表脚本是MySQL 8.0的常用写法生产环境不要依赖任何自动建表机制脚本要进版本库由Flyway或Liquibase管理。CREATE TABLE snow_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL COMMENT 商品名如ATOMIC Q9, brand VARCHAR(64) NOT NULL COMMENT 品牌, category VARCHAR(32) NOT NULL COMMENT 分类SKI/SNOWBOARD/BOOT/BINDING, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE InnoDB COMMENT 雪具商品主表; CREATE TABLE snow_sku ( id BIGINT PRIMARY KEY AUTO_INCREMENT, product_id BIGINT NOT NULL, sku_code VARCHAR(64) NOT NULL COMMENT SKU编码如 Q9-165-9800, spec_json JSON NOT NULL COMMENT 规格维度length/cm, mondo, last_width等, price_cents INT NOT NULL COMMENT 价格单位分避免浮点误差, available_qty INT NOT NULL COMMENT 可售库存, frozen_qty INT NOT NULL DEFAULT 0 COMMENT 锁定库存支付成功后扣减, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_sku_code (sku_code), KEY idx_product (product_id) ) ENGINE InnoDB COMMENT 雪具SKU与库存表; CREATE TABLE snow_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL COMMENT 业务订单号唯一, user_id BIGINT NOT NULL, status VARCHAR(32) NOT NULL COMMENT 订单状态枚举值, total_cents INT NOT NULL, paid_at DATETIME NULL, shipped_at DATETIME NULL, finished_at DATETIME NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) ENGINE InnoDB COMMENT 订单主表; CREATE TABLE snow_order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, sku_id BIGINT NOT NULL, sku_code VARCHAR(64) NOT NULL, product_name VARCHAR(128) NOT NULL COMMENT 商品快照, spec_json JSON NOT NULL COMMENT SKU规格快照, price_cents INT NOT NULL, qty INT NOT NULL, KEY idx_order (order_id) ) ENGINE InnoDB COMMENT 订单项表; CREATE TABLE snow_order_status_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL, from_status VARCHAR(32) NULL, to_status VARCHAR(32) NOT NULL, event VARCHAR(40) NOT NULL COMMENT 触发流转的事件, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_no (order_no) ) ENGINE InnoDB COMMENT 订单状态履历表;几个字段要特别解释一下。price_cents用整数存储雪具单价动辄一千到三千元和支付渠道对接时分转元容易出精度账。available_qty和frozen_qty拆开下单锁库存时减available_qty加frozen_qty支付成功后再把frozen_qty扣掉超时未支付则回滚。这样做的好处是库存扣减是“两步走”而不是一次性减死后面接退款和超时关单都会顺手很多。uk_order_no唯一索引不只是为了查询它天然帮你在数据库层拦住重复订单号这比应用层判断可靠一个数量级。3. Spring Boot项目里最核心的下单代码扣库存与生成订单3.1 项目结构与依赖只用Spring Boot JPA MySQL我习惯用标准的Controller-Service-Repository三层不需要引入复杂的CQRS或者微服务拆分一个单体应用足够撑到日订单几千单。依赖只需要spring-boot-starter-web、spring-boot-starter-data-jpa、mysql-connector-j再加一个spring-boot-starter-validation做参数校验。Spring Boot 3.x要求Java 17起步如果你在JDK 8环境下老老实实用Spring Boot 2.7别硬升。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency3.2 下单服务层代码事务锁、排序、幂等、状态落地下单是销售系统最敏感的一段代码并发高的时候容易出超卖。下面是核心的placeOrder方法我注释写得比较细可以直接照抄改字段名。Service RequiredArgsConstructor public class OrderService { private final SnowSkuRepository skuRepository; private final SnowOrderRepository orderRepository; private final SnowOrderItemRepository orderItemRepository; private final SnowOrderStatusLogRepository statusLogRepository; private final IdGenerator idGenerator; Transactional public PlaceOrderResult placeOrder(PlaceOrderCommand cmd) { // 1. 按 skuId 升序排列多个SKU时保证加锁顺序一致避免交叉死锁 ListOrderSkuItem items cmd.getItems().stream() .sorted(Comparator.comparing(OrderSkuItem::getSkuId)) .toList(); // 2. 悲观锁锁定库存行这里会执行 SELECT ... FOR UPDATE ListSnowSku lockedSkus items.stream() .map(item - skuRepository.findBySkuIdForUpdate(item.getSkuId()) .orElseThrow(() - new BizException(SKU不存在: item.getSkuId()))) .toList(); // 3. 预检库存并扣减可售库存同时累加锁定库存 long totalCents 0L; for (int i 0; i items.size(); i) { OrderSkuItem item items.get(i); SnowSku sku lockedSkus.get(i); if (sku.getAvailableQty() item.getQty()) { throw new BizException(库存不足: sku.getSkuCode() 剩余 sku.getAvailableQty()); } sku.setAvailableQty(sku.getAvailableQty() - item.getQty()); sku.setFrozenQty(sku.getFrozenQty() item.getQty()); totalCents (long) sku.getPriceCents() * item.getQty(); } skuRepository.saveAll(lockedSkus); // 4. 创建订单主表状态固定为待支付 SnowOrder order new SnowOrder(); order.setOrderNo(idGenerator.nextBizNo(SO)); order.setUserId(cmd.getUserId()); order.setStatus(OrderState.PENDING_PAYMENT.name()); order.setTotalCents((int) totalCents); orderRepository.save(order); // 5. 保存订单项快照 ListSnowOrderItem orderItems new ArrayList(); for (int i 0; i items.size(); i) { SnowSku sku lockedSkus.get(i); SnowOrderItem item new SnowOrderItem(); item.setOrderId(order.getId()); item.setSkuId(sku.getId()); item.setSkuCode(sku.getSkuCode()); item.setProductName(sku.getProductName()); item.setSpecJson(sku.getSpecJson()); item.setPriceCents(sku.getPriceCents()); item.setQty(items.get(i).getQty()); orderItems.add(item); } orderItemRepository.saveAll(orderItems); // 6. 写状态履历 statusLogRepository.save(OrderStatusLog.from(order.getOrderNo(), null, OrderState.PENDING_PAYMENT.name(), OrderEvent.CREATED.name())); return PlaceOrderResult.of(order.getOrderNo(), totalCents); } }这段代码有三个地方值得细看。第一锁的顺序。两个并发请求如果分别先锁SKU A再锁SKU B、先锁B再锁A就会互相等对方释放形成数据库死锁。我按skuId升序排好再锁保证所有请求拿锁顺序一致这是处理多行库存锁的基本功。第二扣库存和建订单在同一个事务里任何一步抛异常都会整体回滚库存不会出现“减了库存没生成订单”或者“生成订单没减库存”的中间态。第三库存扣减走的是“可售变锁定”不是直接扣死这为后面支付超时回滚留好了口子。Repository 里的锁查询要这么写public interface SnowSkuRepository extends JpaRepositorySnowSku, Long { Lock(LockModeType.PESSIMISTIC_WRITE) Query(select s from SnowSku s where s.id :skuId) OptionalSnowSku findBySkuIdForUpdate(Param(skuId) Long skuId); }Lock(LockModeType.PESSIMISTIC_WRITE)在MySQL InnoDB下生成的就是SELECT ... FOR UPDATE事务提交前其他事务对这些行的更新会被阻塞。锁的范围要看清楚锁的是snow_sku表里id对应的那一行不是整张表所以不同SKU的并发下单互不影响。这个查询必须在事务方法内调用脱离事务的FOR UPDATE不会生效因为锁随事务提交立即释放。如果系统后续做了读写分离还要确保这个查询强制走主库用Transactional(readOnly false)保证它不落入只读路由。3.3 Controller层怎么写DTO入参、枚举校验、错误映射Controller 不做业务判断只做参数翻译和错误响应封装。DTO 接收前端JSON用Valid触发校验业务异常统一交给RestControllerAdvice转成结构化错误码。这样前端看到的是一个稳定的{ code, message, data }结构而不是Spring默认那一大坨堆栈。RestController RequestMapping(/api/orders) RequiredArgsConstructor public class OrderController { private final OrderService orderService; PostMapping public ApiResultPlaceOrderResult create(Valid RequestBody OrderCreateRequest request) { PlaceOrderCommand command PlaceOrderCommand.builder() .userId(request.getUserId()) .items(request.getItems()) .build(); return ApiResult.success(orderService.placeOrder(command)); } }OrderCreateRequest里有几个校验注解值得抄items集合用NotEmptyqty用Min(1)防止传0或负数。雪具订单通常量小价高很少有人一次买十块板但接口不能替用户做这个决定服务层可以加一个最大购买数量校验比如单个SKU最多5件这是风控策略不是业务规则别写死在实体里。3.4 库存扣减的几种实现和取舍FOR UPDATE、乐观锁、Lua脚本上面用的是悲观锁它简单可靠适合销售系统这种写多读少的场景。但它有个代价事务持有行锁期间所有对该SKU的查询和下单都会阻塞。如果某个接口在事务里调了外部支付渠道耗时拉到几秒钟库存行会被锁很久。支付调用无论如何不能放在这个事务里正确姿势是先落库标记“待支付”再异步发起支付支付结果通过回调更新。乐观锁的思路是在snow_sku表加version字段扣减时执行类似update snow_sku set available_qty available_qty - 1, version version 1 where id ? and version ?的语句影响行数为0就说明并发冲突重试或报错。它适合冲突概率低的场景比如雪具这种单价高但热门SKU就那几个的场景冲突反而集中在爆款上重试率会很高。再往上走就是Redis预扣库存。先用DECR预占下单成功后异步把真实库存同步到MySQL。这种方案吞吐量最高但引入了缓存一致性、补偿任务这些额外复杂度日订单没过万之前不值得。我给一个明确的选型建议单体应用、日订单几千直接用FOR UPDATE把事务控制在50毫秒内数据库根本不会成为瓶颈。4. 订单状态机不要上Workflow框架用枚举矩阵管理状态4.1 状态机为什么适合订单系统很多人在订单状态多起来之后第一反应是引入Flowable或Activiti这类工作流引擎我的建议是拒绝。订单状态流转是高度确定的待支付只能到已支付或已取消已支付只能到已发货或退款中不存在人工审批多级路由这些自由形态。工作流引擎带来的流程定义、部署、版本管理这些概念在这里只是徒增复杂度。状态机的本质就一张表当前状态 触发事件 下一个状态。它把散落在if-else里的状态判断集中起来让非法流转在入口处就被拦截。比如运营在后台误点了“已支付订单直接完成”状态机会直接抛异常而不是等代码走到半路才发现状态不匹配。4.2 轻量状态机的核心结构与代码先定义订单状态枚举public enum OrderState { PENDING_PAYMENT, // 待支付 PAID, // 已支付 SHIPPED, // 已发货 COMPLETED, // 已完成 CANCELLED, // 已取消 REFUNDING // 退款中 }再定义事件枚举public enum OrderEvent { CREATED, // 订单创建 PAY_SUCCESS, // 支付成功回调 CANCEL, // 用户取消或超时取消 SHIP, // 发货 CONFIRM_RECEIPT, // 确认收货 APPLY_REFUND, // 申请退款 REFUND_SUCCESS // 退款成功 }状态机核心类用Map做二维矩阵实现只有二十几行Component public class OrderStateMachine { private final MapOrderState, MapOrderEvent, OrderState transitions new HashMap(); public OrderStateMachine() { bind(OrderState.PENDING_PAYMENT, OrderEvent.PAY_SUCCESS, OrderState.PAID); bind(OrderState.PENDING_PAYMENT, OrderEvent.CANCEL, OrderState.CANCELLED); bind(OrderState.PAID, OrderEvent.SHIP, OrderState.SHIPPED); bind(OrderState.PAID, OrderEvent.APPLY_REFUND, OrderState.REFUNDING); bind(OrderState.SHIPPED, OrderEvent.CONFIRM_RECEIPT, OrderState.COMPLETED); bind(OrderState.REFUNDING, OrderEvent.REFUND_SUCCESS, OrderState.CANCELLED); } private void bind(OrderState from, OrderEvent event, OrderState to) { transitions.computeIfAbsent(from, k - new HashMap()).put(event, to); } public OrderState transit(OrderState from, OrderEvent event) { OrderState to transitions.getOrDefault(from, Map.of()).get(event); if (to null) { throw new IllegalStateException(非法状态流转: from - event); } return to; } }这段代码没有魔法transit方法查不到转换关系就直接抛异常。状态机的价值在约束力新同学加代码时不敢随便setStatus因为所有流转都要走统一入口状态履历表也能自动记录。4.3 状态流转矩阵表把上述配置翻译成矩阵表业务方评审时看这个表比看代码效率高当前状态触发事件目标状态附加动作PENDING_PAYMENTPAY_SUCCESSPAID记录paid_at减少frozen_qtyPENDING_PAYMENTCANCELCANCELLED回滚available_qty和frozen_qtyPAIDSHIPSHIPPED记录shipped_at对接物流单号PAIDAPPLY_REFUNDREFUNDING发起退款申请SHIPPEDCONFIRM_RECEIPTCOMPLETED记录finished_atREFUNDINGREFUND_SUCCESSCANCELLED回滚库存通知财务注意最后一行退款成功的订单状态落到CANCELLED而不是COMPLETED这是很多初版设计会搞错的地方。退款意味着交易没有完成订单对账时它不能算作销售额。4.4 状态变更后的事件发布状态流转不能只改一个字段它必然伴随着一系列副作用发短信、还库存、记账、推送WMS。这些副作用如果都写在transit调用方里Service会膨胀到没人敢动。常见做法是发布Spring事件让监听器各自处理。order.setStatus(nextState.name()); orderRepository.save(order); statusLogRepository.save(OrderStatusLog.from(order.getOrderNo(), currentState.name(), nextState.name(), event.name())); eventPublisher.publishEvent(new OrderStateChangedEvent(order.getOrderNo(), currentState, nextState, event));这里有一个真正的坑如果监听器里抛异常因为事件发布和状态更新在同一个事务里整个事务包括状态变更都会回滚。所以监听器要遵循一个原则——只做能快速完成的动作比如发MQ消息、写审计表。真正耗时的外部调用比如短信服务、物流订阅应该丢到MQ里慢慢处理用独立的事务保证数据不丢。5. 用集成测试和配置细节验证“代码真的能跑”5.1 用H2 Spring Boot Test跑一套真正的下单测试销售系统最怕回归测试只测Controller返回200好在下单链路完全可以用SpringBootTest配合内存数据库直接跑。生产用MySQL测试切到H2加上spring.jpa.hibernate.ddl-autocreate-drop让它自动建表注意这个配置只允许出现在测试环境生产环境一律用Flyway管表结构。SpringBootTest AutoConfigureMockMvc ActiveProfiles(test) class OrderApiTest { Autowired private MockMvc mockMvc; Autowired private SnowSkuRepository skuRepository; Test void 下单成功_扣减可售库存并置为待支付() throws Exception { mockMvc.perform(post(/api/orders) .contentType(MediaType.APPLICATION_JSON) .content({\userId\:1,\items\:[{\skuId\:1001,\qty\:1}]})) .andExpect(status().isOk()) .andExpect(jsonPath($.data.orderNo).exists()); SnowSku sku skuRepository.findById(1001L).orElseThrow(); assertThat(sku.getAvailableQty()).isEqualTo(9); assertThat(sku.getFrozenQty()).isEqualTo(1); } }这个测试跑通后下单接口的“扣库存生成订单”核心链路就有了保障。再加两个用例库存不足时接口返回指定错误码且库存不变重复提交相同订单号时数据库唯一索引拦截。这三个用例是销售系统的守门员比任何代码评审都管用。5.2 几个值得写进部署文档的配置细节Spring Boot 3.x的配置名有一些变化从老项目升级过来会碰到几个报错。比如spring.redis.*换成了spring.data.redis.*这些版本差异在面试里也经常被问到。另外两个配置建议直接抄spring: jpa: open-in-view: false jackson: default-property-inclusion: non_nullopen-in-view: false强制你在Service层把数据组装好Controller里访问懒加载属性会直接报错防止线上出现连接池被慢查询拖垮的经典故障。non_null让响应JSON不再输出一堆specJson: null字段前端不用做空值判断响应体积也能小一点。还有数据库连接池用HikariCP的话maximum-pool-size建议从10起步别照抄网上的50、100。连接池不是越大越快单机数据库同时处理的活跃连接就那几个配大了反而增加上下文切换开销。等压测发现连接不够再往上调这个参数是调出来的不是配出来的。5.3 上线前最该删掉的几个东西第一删掉spring-boot-devtools依赖它会在类路径变化时自动重启生产环境纯属浪费资源还有可能引发诡异的类加载问题。第二关掉Spring Boot的Banner打印日志文件里每次启动多出几十行ASCII画除了占空间没有任何价值。第三检查actuator暴露的端点/actuator/heapdump在未授权的情况下可能被用来下载JVM堆快照里面可能有内存中的用户名、Token这些敏感信息这是实打实的安全漏洞不是危言耸听。生产环境要么不开actuator要么只暴露health和metrics并且加上Spring Security认证。还有支付回调接口的幂等这个容易被忽略。微信或支付宝的回调在没有收到成功响应时会重复推送所以回调处理逻辑必须天然幂等先查订单状态已经是PAID就直接返回成功不再重复处理。数据库层面再用uk_order_no做兜底双保险拦住重复通知。这套代码写完从建表到测试配置全部能跑通剩下的商品管理、购物车、后台管理页面都是在这条主链路上做加法了。本文还有配套的精品资源点击获取
返回列表