☰
Java电商毕设实战:库存并发与退款一致性解决方案
2026/10/9 14:31:49 网站建设 项目流程

简介:本资源是一份面向计算机专业本科生与Java初学者的毕业设计类实践文档,聚焦B/S架构下零食电商系统的全流程设计与实现。文档完整覆盖绪论、系统架构、数据库设计、各功能模块(用户管理、商品展示、购物车、订单处理、定制服务、促销管理)的编码实现细节,以及单元测试、集成测试与压力测试方案,特别适合课程设计、毕设参考或Spring Boot全栈开发入门学习。资源为单个771KB的Word文档(.docx),内容结构清晰,含摘要、中英文关键词、五章正文及详细目录,涵盖技术选型(Spring Boot+MyBatis+MySQL+Bootstrap)、三层架构图、核心表设计说明与关键代码逻辑描述。目前已有300人学习下载,读者可直接获取可落地的电商系统设计思路、模块划分方法、安全实践(如密码加密、HTTPS集成)及未来优化方向(AI推荐、物流对接、移动端拓展),具备较强的教学参考价值与工程复用性。

1. 为什么一个“网上零食购物网站”要用 Java 做?——不是为了炫技,而是因为库存、并发和退款这三件事根本绕不开

你可能在毕业设计选题表里随手勾了“基于Java的网上零食购物网站系统设计与实现”,但真正动手时才发现:这不是做个带商品列表的JSP页面就完事的事。零食类目天然具备高周转、短保质期、多SKU、强促销节奏的特点——用户凌晨下单,仓库凌晨打单,次日中午前必须出库;同一款薯片,可能同时被3个校区的学生抢购,库存扣减差0.1秒就会超卖;用户刚付款就反悔,退款要实时回滚订单+释放库存+通知物流取消揽收。这些不是业务逻辑,是系统底座能力。Java生态(尤其是Spring Boot + MyBatis-Plus + Redis组合)之所以成为高校毕设和中小电商落地的默认选择,恰恰因为它把事务一致性、连接池管理、缓存穿透防护这些“看不见的脏活”封装成了可配置、可调试、可监控的模块。本文不讲MVC分层理论,只聚焦你明天就要跑通的6个关键动作:环境怎么搭不踩坑、数据库表怎么从Java实体自动生成、购物车怎么用Redis存才不会丢数据、订单状态机怎么写才不怕并发修改、支付回调怎么验签防重放、以及——最常被忽略的——如何用MyBatis-Plus的@TableField(fill = FieldFill.INSERT)自动填充创建时间,而不是靠前端传参或SQL写死。适合正在赶毕设进度、想两周内跑通核心链路的Java初学者,也适合需要快速验证方案可行性的技术负责人。


2. 环境筑基:JDK 17 + Spring Boot 3.2 + MySQL 8.0 的最小可行组合

很多同学卡在第一步:下载了JDK却配不好环境变量,或者用Spring Initializr生成项目后发现启动报java.lang.UnsupportedClassVersionError。这不是你的问题,是版本错配的必然结果。当前(2024年中)最稳的组合是JDK 17(LTS)、Spring Boot 3.2.x(支持Jakarta EE 9+)、MySQL 8.0.33+(原生支持caching_sha2_password认证插件)。别碰JDK 21——虽然新,但部分MyBatis-Plus插件尚未完全适配;也别用Spring Boot 2.7——它依赖的Hibernate 5.6对MySQL 8.0的JSON类型支持有缺陷。下面是你必须执行的三步:

2.1 JDK 17 安装与验证:别再用JAVA_HOME指向bin目录

Windows用户常犯的错误是把JAVA_HOME设为C:\Program Files\Java\jdk-17.0.1\bin,这会导致mvn命令找不到javac。正确路径是去掉bin:

# Windows PowerShell(管理员运行) $env:JAVA_HOME="C:\Program Files\Java\jdk-17.0.1" $env:PATH="$env:JAVA_HOME\bin;$env:PATH" java -version # 输出应为 openjdk version "17.0.1" 2021-10-19

Linux/macOS用户注意:/usr/libexec/java_home -v 17能准确定位JDK路径,避免手动猜。

2.2 Spring Boot 3.2 项目初始化:用官方脚手架,拒绝IDE内置模板

IntelliJ IDEA自带的Spring Boot项目向导默认选2.x,极易埋坑。务必访问 https://start.spring.io (注意是官方域名),按以下配置生成ZIP:

  • Project:Maven
  • Language:Java
  • Spring Boot:3.2.7(最新稳定版)
  • Packaging:Jar
  • Java:17
  • Dependencies:Spring Web, Spring Data JPA, MySQL Driver, Lombok, Validation, Spring Boot DevTools

提示:不要勾选“Spring Security”——毕设阶段先保证功能跑通,权限控制可后期补。若IDEA提示“Cannot resolve symbol 'SpringBootApplication'”,说明Maven未刷新,右键pom.xml → “Reload project”。

2.3 MySQL 8.0 配置:重点解决Public Key Retrieval is not allowed错误

这是MySQL 8.0+最经典的连接失败原因。在application.yml中,JDBC URL必须显式开启allowPublicKeyRetrieval=true且指定serverTimezone=Asia/Shanghai:

spring: datasource: url: jdbc:mysql://localhost:3306/snack_shop?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver

注意:useSSL=false仅用于本地开发,生产环境必须启用SSL并配置证书;utf8mb4是必须的,否则emoji表情(如零食包装上的🔥图标)会存成??。


3. 数据建模:用 MyBatis-Plus 的AutoGenerator从 Java 实体类一键生成建表 SQL

“系统设计与实现”文档里常写“数据库E-R图见附录”,但实际开发中,你根本不想手写几十行CREATE TABLE语句。MyBatis-Plus的代码生成器(AutoGenerator)能根据Java实体类自动生成建表SQL、Mapper接口、Service层代码——这才是现代Java开发的常态。关键不是生成,而是生成什么、怎么改、改完怎么验证。

3.1 实体类定义:用@TableName和@TableId控制建表行为

以核心的Product(商品)实体为例,注意三个强制约定:

import com.baomidou.mybatisplus.annotation.*; @TableName("product") // 显式指定表名,避免MP默认下划线转驼峰导致product_info→product_info public class Product { @TableId(type = IdType.ASSIGN_ID) // 使用雪花算法生成Long型ID,比AUTO_INCREMENT更易分布式扩展 private Long id; @TableField("name") // 显式映射字段名,防止Java字段名变更影响SQL private String name; @TableField("stock") // 库存字段必须设为NOT NULL,默认值0 private Integer stock = 0; @TableField("price") // 价格用BigDecimal,避免float精度丢失(0.1+0.2≠0.3) private BigDecimal price; @TableField(fill = FieldFill.INSERT) // 创建时间由MP自动填充,无需前端传 private LocalDateTime createTime; @TableField(fill = FieldFill.INSERT_UPDATE) // 更新时间在insert/update时都填充 private LocalDateTime updateTime; }

逻辑说明:@TableId(type = IdType.ASSIGN_ID)生成的ID是19位Long(如1823456789012345678),MySQL需用BIGINT UNSIGNED存储;@TableField(fill = ...)要求在配置类中注入MetaObjectHandler,否则无效。

3.2 生成器配置:只生成SQL,不生成冗余代码

很多教程教你怎么生成Controller、ServiceImpl,但毕设项目真正需要的是可读、可调试、可修改的SQL。以下是最简生成器代码(放在test/java包下):

@Test public void generateSql() { FastAutoGenerator.create("jdbc:mysql://localhost:3306/snack_shop", "root", "your_password") .globalConfig(builder -> builder .author("your_name") // 作者名,生成SQL注释用 .outputDir("src/main/resources/sql") // SQL输出到resources/sql目录,方便后续执行 .disableOpenDir()) // 生成后不自动打开文件夹 .packageConfig(builder -> builder .parent("com.snack") // 包名前缀 .entity("entity") // 实体类放entity包 .mapper("mapper") // Mapper接口放mapper包 .xml("mapper.xml")) // XML文件放mapper.xml目录(虽然后续用注解,但保留兼容性) .strategyConfig(builder -> builder .addInclude("product", "order", "order_item", "user") // 只生成这4张表 .entityBuilder() .enableLombok() // 用Lombok减少getter/setter .logicDeleteColumnName("is_deleted") // 逻辑删除字段名 .mapperBuilder() .enableMapperAnnotation() // 启用@Mapper注解,不用XML .serviceBuilder() .formatServiceFileName("%sService") // Service类名格式 ) .execute(); }

运行此测试后,在src/main/resources/sql下会生成product.sql等文件,内容类似:

-- product.sql CREATE TABLE `product` ( `id` bigint NOT NULL COMMENT '主键ID', `name` varchar(100) NOT NULL COMMENT '商品名称', `stock` int NOT NULL DEFAULT '0' COMMENT '库存数量', `price` decimal(10,2) NOT NULL COMMENT '价格', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

3.3 手动执行SQL前的三道检查

生成的SQL不能直接source进MySQL,必须人工核验:

  1. 字符集检查:确认DEFAULT CHARSET=utf8mb4,不是utf8(MySQL的utf8实际是utf8mb3,不支持emoji);
  2. 时间字段检查:create_time和update_time必须有DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP,否则MP的fill机制失效;
  3. 索引检查:对高频查询字段(如product.category_id、order.user_id)手动添加索引,MP不会自动生成:
    ALTER TABLE `order` ADD INDEX idx_user_id (user_id); ALTER TABLE `order_item` ADD INDEX idx_order_id (order_id);

4. 核心链路:购物车、下单、支付回调的三段式落地

零食网站的生死线不在首页美观度,而在“加购→结算→支付→发货”这条链路是否原子化、幂等、可观测。很多毕设项目演示时卡在“下单失败”,根源是没处理好库存扣减与订单创建的事务边界。这里给出经过真实压测验证的最小实现。

4.1 Redis购物车:用Hash结构存用户购物车,避免关系型数据库锁表

关系型数据库(MySQL)不适合存购物车——每次增删都要UPDATE,高并发下极易锁表。Redis的Hash结构是天然解法:HSET cart:{userId} {productId} {quantity}。Spring Boot整合Redis只需两步:

  1. 在pom.xml添加依赖:
    <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
  2. 在application.yml配置Redis:
    spring: redis: host: localhost port: 6379 database: 0 timeout: 2000

Java操作购物车的核心方法:

@Service public class CartService { @Autowired private StringRedisTemplate redisTemplate; // 添加商品到购物车(原子操作) public void addToCart(Long userId, Long productId, Integer quantity) { String key = "cart:" + userId; // HINCRBY实现原子增减,避免先GET再SET的竞态 redisTemplate.opsForHash().increment(key, String.valueOf(productId), quantity); // 设置过期时间,30天不活跃自动清理 redisTemplate.expire(key, Duration.ofDays(30)); } // 获取购物车所有商品 public Map<String, String> getCartItems(Long userId) { String key = "cart:" + userId; return redisTemplate.opsForHash().entries(key); // 返回Map<productIdStr, quantityStr> } }

参数说明:HINCRBY是Redis原子命令,redisTemplate.opsForHash().increment()底层调用它,确保多个请求同时增加同一商品数量不会覆盖;expire设置TTL,防止Redis内存无限增长。

4.2 下单事务:用@Transactional包裹库存扣减与订单创建,但必须加库存校验

这是最容易翻车的环节。常见错误是:

@Transactional public Order createOrder(Long userId, List<CartItem> items) { // 错误示范:先扣库存,再创建订单 for (CartItem item : items) { productMapper.decreaseStock(item.getProductId(), item.getQuantity()); // 可能超卖! } orderMapper.insert(order); // 如果这里抛异常,库存已扣但订单未建 }

正确做法是先查库存,再扣减,最后建单,且扣减SQL必须带WHERE stock >= #{quantity}条件:

@Mapper public interface ProductMapper { // 关键:UPDATE时校验库存充足,返回影响行数 @Update("UPDATE product SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity}") int decreaseStock(@Param("id") Long id, @Param("quantity") Integer quantity); } @Service public class OrderService { @Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId, List<OrderItem> items) { // 1. 检查每件商品库存是否充足 for (OrderItem item : items) { Product product = productMapper.selectById(item.getProductId()); if (product == null || product.getStock() < item.getQuantity()) { throw new RuntimeException("商品" + item.getProductName() + "库存不足"); } } // 2. 批量扣减库存(原子性由SQL WHERE保证) for (OrderItem item : items) { int affected = productMapper.decreaseStock(item.getProductId(), item.getQuantity()); if (affected == 0) { // 被其他事务抢先扣减,触发重试或失败 throw new RuntimeException("库存扣减失败,请重试"); } } // 3. 创建订单(此时库存已锁定) Order order = new Order(); order.setUserId(userId); order.setStatus(OrderStatus.UNPAID.name()); orderMapper.insert(order); // ... 关联插入order_item return order; } }

4.3 支付回调:用MD5验签+幂等Token防重放,拒绝裸URL接收参数

支付宝/微信支付回调是安全重灾区。绝不能写@PostMapping("/pay/callback") public String callback(@RequestParam String out_trade_no)——这等于把支付结果暴露在GET参数里,极易被伪造。标准做法是:

  1. 验签:用支付平台提供的密钥对回调参数做MD5签名比对;
  2. 幂等:回调参数中必须包含out_trade_no(商户订单号),数据库订单表对该字段建唯一索引;
  3. 异步处理:回调接口只做验签和落库,发MQ或线程池处理发货逻辑,避免阻塞。

简化版验签工具类:

@Component public class PaySignUtil { private static final String KEY = "your_alipay_merchant_key"; // 从支付宝后台获取 public static boolean verifySign(Map<String, String> params) { String sign = params.remove("sign"); // 移除sign字段参与验签 String content = params.entrySet().stream() .filter(entry -> !entry.getKey().equals("sign_type")) .sorted(Map.Entry.comparingByKey()) .map(entry -> entry.getKey() + "=" + entry.getValue()) .collect(Collectors.joining("&")); String expectedSign = DigestUtils.md5Hex(content + KEY).toUpperCase(); return expectedSign.equals(sign); } }

回调Controller:

@PostMapping("/pay/callback") public String alipayCallback(HttpServletRequest request) { Map<String, String> params = getParameterMap(request); // 封装request.getParameterMap() if (!PaySignUtil.verifySign(params)) { return "fail"; // 验签失败直接返回fail,不处理 } String outTradeNo = params.get("out_trade_no"); String tradeStatus = params.get("trade_status"); if ("TRADE_SUCCESS".equals(tradeStatus)) { // 更新订单状态为已支付,并发消息通知库存服务 orderMapper.updateStatus(outTradeNo, OrderStatus.PAID.name()); // TODO: 发送RocketMQ消息触发发货 } return "success"; // 必须返回success,否则支付宝会重复推送 }

5. 避坑指南:上线前必须排查的5个血泪问题

这些坑我在带3届毕设学生时反复遇到,轻则功能异常,重则答辩被问住。每一条都按“现象→原因→解决”给出可立即执行的动作。

5.1 现象:商品详情页图片显示为http://localhost:8080/images/1.jpg,部署到服务器后404

原因:开发时用<img src="/images/1.jpg">,Spring Boot静态资源默认路径是src/main/resources/static,但生产环境Nginx未配置/images/代理到该目录。
解决:

  • 方案A(推荐):将图片存到src/main/resources/static/images/,Nginx配置location /images/ { alias /path/to/project/static/images/; };
  • 方案B:用OSS或本地文件服务,Java代码中ResourceLoader.getResource("classpath:static/images/1.jpg")读取流。

5.2 现象:MySQL中price字段存入19.9,查出来变成19.899999999999998

原因:Java用float或double接收数据库DECIMAL类型,浮点数二进制表示精度丢失。
解决:实体类中价格字段必须声明为BigDecimal,MyBatis-Plus会自动映射;JSP页面用<fmt:formatNumber value="${product.price}" pattern="#.##"/>格式化显示。

5.3 现象:用户登录后,点击“我的订单”返回空列表,但数据库里明明有数据

原因:User实体类中id字段用了@TableId(type = IdType.AUTO),而MySQL表主键是BIGINT,但MP生成的SQL用了INT,导致ID截断。
解决:检查product.sql中id字段类型是否为BIGINT NOT NULL;若为INT,手动改为BIGINT并重新执行SQL。

5.4 现象:@TableField(fill = FieldFill.INSERT)不生效,create_time始终为NULL

原因:未在Spring Boot配置类中注入MyMetaObjectHandler,或实体类字段名与数据库字段名不一致(如Java是createTime,数据库是create_time但没加@TableField("create_time"))。
解决:

@Component public class MyMetaObjectHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); // 起始字段填充 this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); } }

5.5 现象:用redisTemplate.opsForHash().entries("cart:1")返回空Map,但用redis-cli能查到数据

原因:StringRedisTemplate默认使用StringRedisSerializer序列化Key和Value,而entries()方法要求Key和Value都是String类型;如果之前用Jackson2JsonRedisTemplate存过数据,序列化格式不兼容。
解决:统一使用StringRedisTemplate,存入时确保Value是String:redisTemplate.opsForHash().put("cart:1", "1001", "2");或彻底清空Redis:redis-cli FLUSHALL。


6. 进阶技巧:用 MyBatis-Plus 的LambdaQueryWrapper写出可维护的查询,告别硬编码字段名

毕设答辩时,老师常问:“如果数据库字段user_name改成username,你的代码要改几处?”——答案若是“到处找WHERE user_name = ?”,说明你没用好MyBatis-Plus的Lambda表达式。它能把字段名检查从运行时提前到编译期,改字段名时IDE会直接报错,而不是等上线后查不到用户才崩溃。

6.1 传统写法 vs Lambda写法对比

传统方式(脆弱,字段名拼错无提示):

// ❌ 危险:字段名"user_name"写成"user_nam",编译通过,运行时报错 QueryWrapper<User> wrapper = new QueryWrapper<>(); wrapper.eq("user_name", "zhangsan").eq("status", 1); List<User> users = userMapper.selectList(wrapper);

Lambda方式(安全,改字段名立刻编译失败):

// ✅ 安全:User::getUserName是方法引用,IDE能跳转、能重构、拼错直接红标 LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUserName, "zhangsan").eq(User::getStatus, 1); List<User> users = userMapper.selectList(wrapper);

6.2 复杂查询实战:查“近7天销量Top10零食”,含关联统计

需求:统计每个商品近7天的销售总量,按销量降序取前10。传统写法要手写JOIN SQL,而MyBatis-Plus可组合Lambda与自定义SQL:

// 在Mapper接口中定义 @Select("SELECT p.id, p.name, SUM(oi.quantity) as total_quantity " + "FROM product p " + "LEFT JOIN order_item oi ON p.id = oi.product_id " + "LEFT JOIN `order` o ON oi.order_id = o.id " + "WHERE o.status = #{status} AND o.create_time >= #{sevenDaysAgo} " + "GROUP BY p.id, p.name " + "ORDER BY total_quantity DESC LIMIT 10") List<ProductSales> selectTop10ByWeek(@Param("status") String status, @Param("sevenDaysAgo") LocalDateTime sevenDaysAgo); // 调用时传参 LocalDateTime sevenDaysAgo = LocalDateTime.now().minusDays(7); List<ProductSales> top10 = productMapper.selectTop10ByWeek("PAID", sevenDaysAgo);

其中ProductSales是DTO类,非实体类:

@Data public class ProductSales { private Long id; private String name; private Integer totalQuantity; // 注意:SUM返回Integer,不是Long }

6.3 动态条件组装:搜索框输入“薯片”时查商品名,输入“1001”时查ID

用户搜索时,后端需根据输入内容智能匹配字段。LambdaQueryWrapper支持链式动态拼接:

public List<Product> searchProducts(String keyword) { LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.isNumeric(keyword)) { // 输入纯数字,按ID精确匹配 wrapper.eq(Product::getId, Long.parseLong(keyword)); } else { // 按商品名模糊匹配 wrapper.like(Product::getName, keyword); } return productMapper.selectList(wrapper); }

关键点:StringUtils.isNumeric()来自Apache Commons Lang,需引入依赖;wrapper.like()生成WHERE name LIKE '%keyword%',注意性能,数据量大时需加全文索引。

我带过的最后一届学生,有个男生在答辩前夜发现订单状态更新不及时,追查到是@Transactional没加rollbackFor = Exception.class,导致自定义异常不回滚。他改完代码,凌晨三点给我发消息说“老师,终于看到库存回滚了”。那一刻我意识到,所谓“系统设计与实现”,不是画几张UML图,而是让每一行代码都在真实场景里扛住压力、守住边界、兜住底线。希望这篇笔记里那些带// ✅和// ❌的注释,能帮你少走些弯路。希望帮到你。

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

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

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

立即咨询