Spring Boot服装销售平台:库存锁+订单幂等+真实联调

发布时间:2026/10/30 14:24:10
Spring Boot服装销售平台:库存锁+订单幂等+真实联调

简介:这是一套基于Spring Boot与Vue技术栈开发的服装销售平台完整源码,面向Java Web初学者、毕业设计学生及中小型电商系统学习者,解决从需求分析到前后端联调的全流程实践问题。资源包共862个文件,涵盖146个Java后端逻辑文件、153个JavaScript交互脚本、52个Vue组件、66个JPG商品图及79个GIF动效素材,辅以HTML页面、CSS样式、MyBatisPlus配置及MySQL建表SQL等,完整支撑B/S架构下的用户管理、图片与视频素材上传展示等核心功能。压缩包大小为17.98MB,结构清晰,含标准Maven项目结构、ElementUI前端框架及多级目录文档(含绪论、技术选型、系统分析与实现章节)。目前已有111人学习下载,提供可直接运行的毕设级项目工程,包含安装/启动/密码更新等批处理脚本及备份Vue组件,便于快速部署、代码阅读与二次开发。

1. 一个能跑通、能改、能上线的 Spring Boot 服装销售平台:不是 Demo,是带库存校验+订单幂等+前端联调的真实项目

你花两小时搭了个 Spring Boot 商城 demo,加了用户登录、商品列表、购物车——结果一压测就 500,下单重复扣库存,管理员后台连个基础导出都没有。这不是你代码写得差,而是多数“服装销售平台源码”根本没过生产级验证:缺事务边界、没幂等控制、数据库字段和前端表单对不上、连 Swagger 接口文档都懒得生成。这份基于 Spring Boot 的服装销售平台代码,是我去年帮一家区域快时尚品牌重构线上系统时拆出来的最小可运行基线版本:MySQL 8.0 + MyBatis-Plus 3.5.3 + Spring Boot 2.7.18(LTS),含完整前后端联调流程、真实服装类目树结构(男装/女装/童装三级嵌套)、SKU 级库存锁与超卖防护、订单状态机(待支付→已支付→已发货→已完成)、以及可直接替换为 Vue 或 React 的 RESTful 接口契约。它不炫技,但每行代码都踩过坑——比如@Transactional为什么不能加在 service impl 的 private 方法上、为什么@Select("SELECT * FROM product WHERE id = #{id}")比lambdaQuery().eq(Product::getId, id).one()更适合高并发查详情。适合 Java 初中级开发者拿去改业务逻辑、面试前两周突击实战项目、或作为微服务拆分的第一块基石。


2. 从零启动:环境准备、模块划分与核心依赖选型逻辑

2.1 JDK 17 + Maven 3.8.6 是当前最稳的组合:为什么不用 JDK 21?

Spring Boot 2.7.x 官方支持的最高 JDK 版本是 17(JDK 18+ 需手动配置spring-boot-starter-parent的java.version属性并降级部分依赖)。我试过强行用 JDK 21 启动,spring-boot-devtools会报Unsupported class file major version 65错误,因为其内置的 ASM 版本未适配 JDK 21 的字节码规范。而 JDK 17 是 LTS 版本,长期获得安全更新,且与 Spring Boot 2.7.x 的spring-boot-starter-web、spring-boot-starter-data-jpa兼容性经过大量生产验证。Maven 3.8.6 则是最后一个支持 JDK 17 且无maven-surefire-plugin插件冲突的版本——新版本 4.x 在某些 Linux 发行版上会因javax.xml.bind包缺失导致编译失败。

提示:不要用 IDE 自带的 Maven 嵌入版。下载官方二进制包解压后,在~/.m2/settings.xml中配置阿里云镜像源,避免spring-boot-starter-thymeleaf下载超时:

<mirror> <id>aliyunmaven</id> <mirrorOf>*</mirrorOf> <name>Aliyun Maven</name> <url>https://maven.aliyun.com/repository/public</url> </mirror>

2.2 模块化结构:为什么把common、api、service分开?

项目采用标准的多模块 Maven 结构:

clothing-sales-platform/ ├── clothing-common/ # 工具类、全局异常处理器、统一返回体 Result<T> ├── clothing-api/ # DTO、VO、枚举、Swagger 配置(@ApiModel/@ApiOperation) ├── clothing-service/ # Service 接口定义(不含实现) ├── clothing-service-impl/ # Service 实现 + Mapper XML + @Service 注解 ├── clothing-web/ # Controller + 静态资源 + Thymeleaf 模板(可删) └── clothing-admin/ # 管理后台接口(独立端口 8081)

这种分法不是为了炫技,而是解决三个实际问题:

  1. DTO 复用:前端提交的AddCartRequest和后台校验的CartParam必须隔离,避免@NotNull注解污染领域模型;
  2. 依赖收敛:clothing-common被所有模块依赖,但clothing-service-impl不能反向依赖clothing-web,防止 Controller 层逻辑泄漏到 Service;
  3. 部署灵活:clothing-admin可单独打成 war 包部署到 Tomcat,而clothing-web保持 jar 内嵌 Tomcat,适应不同运维策略。

2.3 关键依赖版本锁定:MyBatis-Plus 3.5.3 为何比 3.4.x 更适合服装类目?

服装销售的核心难点是类目树递归查询(如“女装 > 连衣裙 > 碎花款”)和SKU 多维度筛选(颜色+尺码+材质组合)。MyBatis-Plus 3.5.3 引入了LambdaQueryWrapper的apply()方法,可直接拼接 SQL 片段,避免手写 XML 的硬编码风险:

// 查询某类目下所有启用的商品(含子类目) LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Product::getStatus, 1) .apply("category_id in (select id from category where path like '{0}%', category_id)", categoryPath); List<Product> products = productMapper.selectList(wrapper);

而 3.4.x 的nested()方法无法处理path like '0001/0002/%'这种前缀匹配,必须回退到 XML。同时,3.5.3 对@TableField(exist = false)的反射性能优化了 12%,在商品详情页加载 20+ 字段时更稳定。

2.4 数据库设计关键点:为什么用stock_lock表而不是 Redis 扣库存?

服装 SKU 库存(如“黑色 M 码 T 恤”)必须保证强一致性,Redis 的DECR在网络分区时可能丢失扣减。本项目采用DB 行锁 + 乐观锁双保险:

-- stock_lock 表结构(唯一索引:sku_id + order_id) CREATE TABLE stock_lock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sku_id BIGINT NOT NULL, order_id BIGINT NOT NULL, quantity INT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0:锁定中 1:已扣减 2:已释放', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_sku_order (sku_id, order_id) ); -- 扣库存 SQL(先锁再查再更新) UPDATE product_sku SET stock = stock - #{quantity} WHERE id = #{skuId} AND stock >= #{quantity} AND version = #{version}; -- 乐观锁 version 字段

这样既避免 Redis 单点故障,又比纯 DB 事务更轻量——stock_lock表只存临时锁定记录,不参与主业务流水。


3. 核心功能落地:从商品上架到订单生成的四步闭环

3.1 商品管理:三级类目树的递归构建与缓存穿透防护

服装类目必须支持无限层级(如“男装 > 上装 > 衬衫 > 牛津纺”),但直接递归查数据库会引发 N+1 问题。本项目采用预加载 + Redis 缓存方案:

// CategoryService.java @Cacheable(value = "categoryTree", key = "'all'") public List<Category> getCategoryTree() { // 1. 一次性查出所有类目(按 level 排序) List<Category> allCategories = categoryMapper.selectList( new QueryWrapper<Category>().orderByAsc("level").orderByAsc("sort") ); // 2. 构建父子关系(非递归,O(n) 时间复杂度) Map<Long, Category> categoryMap = allCategories.stream() .collect(Collectors.toMap(Category::getId, c -> c)); List<Category> rootList = new ArrayList<>(); for (Category category : allCategories) { if (category.getParentId() == null || category.getParentId() == 0L) { rootList.add(category); } else { Category parent = categoryMap.get(category.getParentId()); if (parent != null) { parent.getChildren().add(category); } } } return rootList; }

注意:@Cacheable的key = "'all'"是固定字符串,避免缓存雪崩。实际项目中应增加@CacheEvict清理逻辑,当新增类目时触发cacheManager.getCache("categoryTree").clear()。

3.2 购物车:Redis Hash 存储 vs MySQL 临时表的取舍

前端提交的购物车数据格式为{ "skuId": 1001, "quantity": 2, "selected": true }。本项目选择Redis Hash(而非 MySQL 临时表),原因有三:

  • 用户未登录时,购物车需以 deviceId 为 key 存储,MySQL 无法支撑海量设备 ID;
  • 并发添加时,RedisHINCRBY原子操作比 MySQLINSERT ON DUPLICATE KEY UPDATE更高效;
  • 订单结算时,只需HGETALL cart:{userId}获取全部条目,无需 JOIN。
// CartService.java public void addToCart(Long userId, Long skuId, Integer quantity) { String cartKey = "cart:" + userId; // HINCRBY 自动创建 field 并累加 redisTemplate.opsForHash().increment(cartKey, skuId.toString(), quantity); // 设置过期时间(30 天) redisTemplate.expire(cartKey, Duration.ofDays(30)); } // 获取购物车(含商品详情) public List<CartVo> getCartItems(Long userId) { String cartKey = "cart:" + userId; Map<Object, Object> entries = redisTemplate.opsForHash().entries(cartKey); List<Long> skuIds = entries.keySet().stream() .map(Object::toString) .map(Long::parseLong) .collect(Collectors.toList()); // 批量查商品(避免 N+1) List<ProductSku> skus = skuMapper.selectBatchIds(skuIds); return skus.stream().map(sku -> { CartVo vo = new CartVo(); vo.setSkuId(sku.getId()); vo.setQuantity(((Long) entries.get(sku.getId().toString())).intValue()); vo.setPrice(sku.getPrice()); vo.setProductName(sku.getProductName()); return vo; }).collect(Collectors.toList()); }

3.3 订单创建:幂等性保障的三种实现方式及最终选择

用户连续点击“提交订单”按钮,必须保证只生成一个订单。本项目采用数据库唯一索引 + 业务状态机组合方案:

-- order_info 表唯一索引(防重复插入) ALTER TABLE order_info ADD UNIQUE KEY uk_user_trade_no (user_id, trade_no);
// OrderService.java @Transactional(rollbackFor = Exception.class) public OrderInfo createOrder(Long userId, List<CartVo> cartItems) { // 1. 生成唯一 trade_no(时间戳+用户ID+随机数) String tradeNo = String.format("%s%06d%s", DateUtil.format(new Date(), "yyyyMMddHHmmss"), userId % 1000000, RandomUtil.randomString(4) ); // 2. 尝试插入(唯一索引冲突则抛 DuplicateKeyException) OrderInfo order = new OrderInfo(); order.setUserId(userId); order.setTradeNo(tradeNo); order.setStatus(OrderStatus.WAIT_PAY.getCode()); orderMapper.insert(order); // 若 trade_no 已存在,此处直接报错 // 3. 扣减库存(见 2.4 节) for (CartVo item : cartItems) { skuService.deductStock(item.getSkuId(), item.getQuantity()); } // 4. 清空购物车 redisTemplate.delete("cart:" + userId); return order; }

为什么不选 Token 机制?Token 需要前端配合生成/校验,移动端 SDK 集成成本高;为什么不选 Redis SETNX?分布式环境下时钟漂移可能导致多个节点同时获取锁。数据库唯一索引是最简单、最可靠、且无需额外中间件的方案。

3.4 支付回调:如何用本地事务兜底应对第三方支付异步通知

支付宝/微信支付回调是异步的,可能重试多次。本项目用本地事务 + 回调幂等表保证最终一致性:

-- pay_callback_log 表(记录每次回调原始参数) CREATE TABLE pay_callback_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_trade_no VARCHAR(64) NOT NULL, notify_id VARCHAR(128) UNIQUE, -- 支付宝 notify_id 唯一 notify_time DATETIME, notify_data TEXT, status TINYINT DEFAULT 0 COMMENT '0:未处理 1:已成功 2:已失败', create_time DATETIME DEFAULT CURRENT_TIMESTAMP );
// PayCallbackController.java @PostMapping("/alipay/callback") public String handleAlipayNotify(@RequestParam Map<String, String> params) { // 1. 验签(省略) if (!AlipaySignature.rsaCheckV1(params, ALIPAY_PUBLIC_KEY, "UTF-8")) { return "fail"; } // 2. 查询是否已处理(幂等) String notifyId = params.get("notify_id"); PayCallbackLog log = callbackLogMapper.selectOne( new QueryWrapper<PayCallbackLog>().eq("notify_id", notifyId) ); if (log != null && log.getStatus() == 1) { return "success"; // 已成功处理,直接返回 success } // 3. 更新订单状态(本地事务) try { orderService.updateOrderStatus(params.get("out_trade_no"), OrderStatus.PAID); // 4. 记录回调日志 PayCallbackLog newLog = new PayCallbackLog(); newLog.setOutTradeNo(params.get("out_trade_no")); newLog.setNotifyId(notifyId); newLog.setNotifyData(JSON.toJSONString(params)); newLog.setStatus(1); callbackLogMapper.insert(newLog); return "success"; } catch (Exception e) { // 记录失败日志,人工介入 log.setStatus(2); callbackLogMapper.updateById(log); return "fail"; } }

4. 避坑指南:五个让新手当场翻车的典型问题与血泪解决方案

4.1 现象:启动时报Caused by: java.lang.ClassNotFoundException: javax.servlet.Filter

原因:Spring Boot 2.7.x 默认使用 Jakarta EE 9+ 规范,javax.*包名已改为jakarta.*,但某些老版本依赖(如shiro-spring-boot-web-starter1.7.1)仍引用javax.servlet。
解决:升级 Shiro 依赖至2.0.0-M3(支持 Jakarta),或在pom.xml中强制排除冲突包:

<dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-web-starter</artifactId> <version>2.0.0-M3</version> <exclusions> <exclusion> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> </exclusion> </exclusions> </dependency>

4.2 现象:MyBatis-Plus 分页插件失效,PageHelper.startPage()不生效

原因:mybatis-plus-boot-starter3.5.3 内置了PaginationInnerInterceptor,若同时引入pagehelper-spring-boot-starter会导致拦截器链冲突。
解决:彻底删除 PageHelper 依赖,改用 MP 原生分页:

// 正确写法(MP 3.5.3+) IPage<Product> page = new Page<>(current, size); IPage<Product> result = productMapper.selectPage(page, wrapper);

4.3 现象:Thymeleaf 模板中@{/static/css/app.css}生成的 URL 是/static/css/app.css而非/css/app.css

原因:Spring Boot 2.7.x 默认静态资源路径为classpath:/static/,但 Thymeleaf 的@{}表达式默认不剥离/static前缀。
解决:在application.yml中配置:

spring: web: resources: static-locations: classpath:/static/ thymeleaf: # 启用 SpringResourceTemplateResolver,自动处理静态资源路径 enabled: true cache: false

并在 HTML 中用@{/css/app.css}(去掉/static)。

4.4 现象:MySQL 8.0 报错Public Key Retrieval is not allowed

原因:MySQL 8.0 默认禁用公钥检索(allowPublicKeyRetrieval=false),而某些 JDBC 驱动版本未显式设置该参数。
解决:在application.yml的 JDBC URL 后追加参数:

spring: datasource: url: jdbc:mysql://localhost:3306/clothing?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true

4.5 现象:Swagger UI 页面空白,Console 报Failed to load resource: the server responded with a status of 404 ()

原因:Spring Boot 2.7.x 默认禁用 Swagger 的/v2/api-docs端点,需显式配置 WebMvc 配置。
解决:创建SwaggerConfig.java:

@Configuration @EnableOpenApi // 注意:不是 @EnableSwagger2 public class SwaggerConfig { @Bean public GroupedOpenApi publicApi() { return GroupedOpenApi.builder() .group("public") .pathsToMatch("/**") .build(); } }

并确保引入springdoc-openapi-ui依赖(非swagger-springmvc)。


5. 进阶技巧:用 JMeter 压测库存扣减 + 日志链路追踪定位慢 SQL

5.1 用 JMeter 模拟 1000 并发抢购,验证库存超卖防护

服装大促时,同一 SKU 可能被数千人同时下单。我们用 JMeter 验证deductStock()方法是否真能防超卖:

  1. 准备测试数据:在product_sku表中插入一条记录,stock = 100,version = 0;
  2. JMeter 配置:
    • 线程组:1000 线程,Ramp-up 为 1 秒,循环 1 次;
    • HTTP 请求:POST/api/order/create,Body 为{"skuId":1001,"quantity":1};
    • 查看结果树:检查响应中code是否全为 200,data.stock是否 ≥ 0;
  3. 关键断言:添加「JSON Extractor」提取$.data.stock,再用「Response Assertion」验证stock >= 0;
  4. 执行后检查:查询数据库SELECT stock FROM product_sku WHERE id = 1001,结果应为0(100 个请求全部成功)或1(1 个失败,99 个成功),绝不能出现负数。

如果出现负库存,说明UPDATE ... WHERE stock >= #{quantity}条件未生效——大概率是quantity参数传错类型(如传了字符串"1"导致 SQL 类型转换失败),此时 MySQL 会静默转为0,条件恒成立。

5.2 用 Logback + MDC 实现订单全流程日志追踪

当用户投诉“订单支付成功但未发货”,需快速定位是支付回调失败,还是发货服务未触发。本项目用 MDC(Mapped Diagnostic Context)注入traceId:

// OrderController.java @PostMapping("/create") public Result<OrderInfo> createOrder(@RequestBody CreateOrderRequest request) { // 生成 traceId 并绑定到当前线程 String traceId = IdUtil.fastSimpleUUID(); MDC.put("traceId", traceId); try { log.info("开始创建订单,用户ID:{},商品列表:{}", request.getUserId(), request.getItems()); OrderInfo order = orderService.createOrder(request.getUserId(), request.getItems()); log.info("订单创建成功,订单号:{}", order.getTradeNo()); return Result.success(order); } finally { MDC.clear(); // 必须清理,否则线程复用时污染日志 } } // logback-spring.xml 配置 <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId:-}] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender>

这样在 ELK 或阿里云 SLS 中,只需搜索traceId: xxxxx,就能串起从 Controller → Service → Mapper → 支付回调的全部日志。

5.3 定位慢 SQL:开启 MySQL 慢查询日志 + 使用EXPLAIN分析执行计划

当商品列表页加载超过 2 秒,需确认是否是 SQL 问题。在my.cnf中开启慢查询:

slow_query_log = ON slow_query_log_file = /var/log/mysql/slow.log long_query_time = 0.5 log_queries_not_using_indexes = ON

重启 MySQL 后,执行慢查询语句,再用EXPLAIN分析:

EXPLAIN SELECT p.*, s.sku_code, s.price FROM product p LEFT JOIN product_sku s ON p.id = s.product_id WHERE p.category_id IN (SELECT id FROM category WHERE path LIKE '0001/%') AND p.status = 1 ORDER BY p.sort DESC LIMIT 20;

重点关注:

  • type列:ALL(全表扫描)表示缺少索引,ref或range为正常;
  • key列:显示实际使用的索引,若为NULL则未命中索引;
  • rows列:扫描行数,超过 1000 行需优化。

本项目已在product(category_id, status, sort)和category(path)上建立复合索引,确保path LIKE '0001/%'走索引。

从那以后我每次上线新功能,都强制走一遍 JMeter 压测 + 慢 SQL 分析 + MDC 日志串联三步验证——不是怕出问题,而是怕问题发生时,自己连日志都捞不到。希望帮到你。

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