简介:本资源是一套完整的Java Web电子商城课程设计大作业,面向高校计算机专业学生及Java初学者,用于软件工程实践、毕业设计参考或Web开发综合实训。内容覆盖需求分析、概要设计、详细设计到系统实现全流程,提供可直接运行的MyEclipse+SQL Server开发环境支持方案。压缩包共214个文件,约4.8MB,包含64个JSP页面(实现前后端交互)、31个Java类(含GoodsAction、MemberDao、OrderAction等核心业务逻辑)、31个编译后Class文件、7个Word文档(含说明书与设计文档)、8个Jar依赖库及配套数据库文件(mdf/ldf)和图片资源,结构完整、模块清晰。目前已有991人学习下载,读者可直接导入MyEclipse运行系统,结合文档理解MVC分层设计思想,掌握用户管理、商品分类、订单处理等典型电商功能的代码实现与数据库建模方法。
1. 这不是又一个“学生毕设商城”,而是一份能直接抠出订单模块、用户中心、支付对接骨架的 Java 工程级参考包
上周帮某高校实验室做课程设计评审,翻了二十多个标着“Java 电子商城”的压缩包,八成点开就是User.java+Product.java+ 一个没连数据库的index.jsp。但这个“java软件设计电子商城(完整)源代码 数据库 需求分析 概要设计 详细设计 说明书 文档”不一样——它把从《需求规格说明书》第3.2节“用户登录需支持手机号+短信验证码双因子”开始,到OrderService.java里createOrderWithInventoryLock()方法如何用SELECT FOR UPDATE防超卖,再到db_init.sql中order_item表的unit_price DECIMAL(10,2) NOT NULL COMMENT '下单时快照价,防价格变动'字段注释,全链路对齐。它不教你怎么写HelloWorld,而是默认你已经配好 JDK 17 和 MySQL 8.0,目标是让你在 40 分钟内跑通「用户注册 → 浏览商品 → 加入购物车 → 提交订单 → 查看订单列表」这条主干流程,并能立刻定位到「为什么购物车清空后历史记录还在」对应的CartHistoryMapper.xmlSQL 片段。适合两类人:一是正被导师卡在“设计文档怎么写才像工程实践”的大三学生;二是需要快速搭个内部采购系统原型、不想从 Spring Boot 起步配置折腾的后端老手。它不是玩具,是带完整设计脉络的生产级切片。
2. 环境准备与项目结构解剖:看清这 12 个核心目录如何支撑起“商城”二字
这个包的厚度不在代码行数,而在它把软件工程生命周期的关键产物都固化成了可执行文件。别急着mvn clean install,先打开根目录,你会看到 5 类不可跳过的实体:
src/main/java/:标准 Maven 结构,但注意com.example.ecommerce.core下的domain、application、infrastructure三层分包,不是简单按 Controller/Service/Dao 划分;docs/:包含 4 份 Word/PDF 文档,其中需求分析说明书_v2.1.docx的“非功能需求”章节明确写了“首页加载时间 ≤ 1.2s(压测 200 并发)”,这不是空话;sql/:不止init.sql,还有patch_20231015_add_coupon.sql这类增量脚本,说明它经历过真实迭代;config/:application-prod.yml里redis.host: ${REDIS_HOST:127.0.0.1}这种占位符写法,暴露了它为部署留的钩子;test-data/:mock_users.csv和product_catalog.json,是启动时自动导入测试数据的依据。
提示:所有文档命名带版本号(如
概要设计说明书_v1.3.pdf),且docs/目录下有version_mapping.md明确标注“v1.3 概要设计对应 src 分支 feature/order-refactor”。这是判断文档是否与代码同步的黄金线索。
2.1 从pom.xml看技术栈选型逻辑:为什么用 MyBatis-Plus 而不是 JPA?
打开pom.xml,关键依赖如下(已过滤无关插件):
<properties> <java.version>17</java.version> <spring-boot.version>3.1.5</spring-boot.version> <mybatis-plus.version>3.5.3.1</mybatis-plus.version> <mysql-connector.version>8.0.33</mysql-connector.version> <redisson.version>3.23.2</redisson.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>${mysql-connector.version}</version> </dependency> <dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>${redisson.version}</version> </dependency> </dependencies>注意三点玄学细节:
- Spring Boot 3.1.5 + JDK 17:意味着它放弃
javax.*包,全面拥抱jakarta.*,如果你本地 JDK 是 11,mvn compile会直接报jakarta.servlet.http.HttpServletRequest找不到——这不是 bug,是强制升级信号; - MyBatis-Plus 3.5.3.1:特意选了 Spring Boot 3 兼容版,而非最新 4.x。因为 4.x 移除了
IService的saveBatch默认实现,而本项目OrderService里大量使用该方法批量创建订单项,强行升 4.x 会导致编译失败; - Redisson 而非 Lettuce:
OrderController.java的@Transactional方法里调用了redissonClient.getLock("order:create:" + userId),说明它用 Redisson 实现分布式锁来防重复提交,而不是简单用数据库唯一索引。
2.2docs/目录里的设计文档不是摆设:它们如何指导代码编写?
很多人下载带文档的源码,却从不打开.docx。但这里的设计文档是代码的“源代码”。以详细设计说明书_v1.2.pdf第 5.4 节“购物车服务”为例:
【处理逻辑】
- 用户添加商品时,先校验库存(调用
InventoryService.checkStock(productId, quantity));- 若库存充足,将商品写入
cart_item表,status='ACTIVE';- 若库存不足,写入
cart_item表但status='OUT_OF_STOCK',前端需高亮提示;- 购物车合并逻辑:当用户登录后,若存在未登录态 Cookie 购物车,则将
status='ACTIVE'的项合并,status='OUT_OF_STOCK'的项保留但标记merged_at=now()。
现在打开src/main/java/com/example/ecommerce/application/cart/CartApplicationService.java,搜索addCartItem方法:
@Transactional public Result<Void> addCartItem(Long userId, Long productId, Integer quantity) { // 步骤1:校验库存(文档第1步) InventoryCheckResult checkResult = inventoryService.checkStock(productId, quantity); if (!checkResult.isAvailable()) { // 步骤3:库存不足时写入 status='OUT_OF_STOCK' cartItemMapper.insert(CartItem.builder() .userId(userId) .productId(productId) .quantity(quantity) .status(CartItemStatus.OUT_OF_STOCK.name()) .createdAt(LocalDateTime.now()) .build()); return Result.fail("库存不足,已加入待补货清单"); } // 步骤2:库存充足,写入 status='ACTIVE' CartItem existing = cartItemMapper.selectOne(new QueryWrapper<CartItem>() .eq("user_id", userId) .eq("product_id", productId) .eq("status", CartItemStatus.ACTIVE.name())); if (existing != null) { // 合并数量 existing.setQuantity(existing.getQuantity() + quantity); cartItemMapper.updateById(existing); } else { cartItemMapper.insert(CartItem.builder() .userId(userId) .productId(productId) .quantity(quantity) .status(CartItemStatus.ACTIVE.name()) .createdAt(LocalDateTime.now()) .build()); } return Result.success(); }看到没?CartItemStatus.OUT_OF_STOCK这个枚举值、merged_at字段虽未在此方法体现,但在mergeAnonymousCart方法里精准出现。文档不是事后的总结,而是编码前的契约。这就是为什么它叫“工程级”——代码和文档在 Git 提交里是同一时间戳。
2.3 数据库脚本sql/init.sql的隐藏设计:表结构如何体现业务语义?
别只运行init.sql就完事。打开它,重点看三张表的字段设计:
-- users 表:用户主表 CREATE TABLE `users` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `username` varchar(64) NOT NULL COMMENT '用户名,唯一', `phone` varchar(20) DEFAULT NULL COMMENT '手机号,用于登录和验证', `password_hash` varchar(128) NOT NULL COMMENT 'BCRYPT 加密后的密码', `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, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`), UNIQUE KEY `uk_phone` (`phone`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户信息表'; -- products 表:商品主表 CREATE TABLE `products` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(255) NOT NULL COMMENT '商品名称', `price` decimal(10,2) NOT NULL COMMENT '销售价', `stock` int NOT NULL DEFAULT '0' COMMENT '总库存', `sale_status` tinyint NOT NULL DEFAULT '1' COMMENT '1-上架, 0-下架', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- order_items 表:订单明细(关键!) CREATE TABLE `order_items` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_id` bigint NOT NULL COMMENT '关联 order.id', `product_id` bigint NOT NULL COMMENT '关联 products.id', `product_name` varchar(255) NOT NULL COMMENT '下单时商品快照名称', `unit_price` decimal(10,2) NOT NULL COMMENT '下单时快照单价,防价格变动', `quantity` int NOT NULL COMMENT '购买数量', `subtotal` decimal(10,2) NOT NULL COMMENT '小计 = unit_price * quantity', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单明细表,含价格快照';三个设计意图直击痛点:
users.phone加了UNIQUE KEY:说明登录方式强制绑定手机号,和需求分析说明书里“支持手机号一键登录”呼应;products.sale_status用tinyint而非ENUM:为未来扩展“预售中”“仅限会员”等状态留余地,避免 ALTER TABLE;order_items表里product_name和unit_price两个字段:这是典型的“快照设计”,确保即使商品下架或改价,历史订单仍能正确展示。很多新手只建product_id外键,导致查订单时要 JOINproducts表,一旦商品被删就查不出名称——这里用空间换时间,是成熟电商的标配。
3. 启动与调试:从零到首页渲染的 7 个必踩步骤
别信“一键启动”。这个包的启动过程本身就是一次微缩版的 DevOps 实践。我把它拆成 7 个原子步骤,每一步失败都有明确归因。
3.1 步骤 1:JDK 与 MySQL 版本对齐(最常翻车的第一步)
现象:mvn clean compile报错Unsupported class file major version 61
原因:JDK 版本低于 17(major version 61 对应 JDK 17)
解决:
# 检查当前 JDK java -version # 若输出 1.8 或 11,需切换 export JAVA_HOME=/path/to/jdk-17.0.1 export PATH=$JAVA_HOME/bin:$PATH现象:启动时报Access denied for user 'root'@'localhost'
原因:application-dev.yml中数据库密码为空,但你的 MySQL root 有密码
解决:
# config/application-dev.yml spring: datasource: url: jdbc:mysql://localhost:3306/ecommerce?useSSL=false&serverTimezone=Asia/Shanghai username: root password: your_real_mysql_root_password # ← 必须填!注意:
sql/init.sql末尾有CREATE DATABASE IF NOT EXISTS ecommerce DEFAULT CHARACTER SET utf8mb4;,但不会自动创建用户。如果要用非 root 用户,需手动执行CREATE USER 'ecommerce'@'localhost' IDENTIFIED BY 'pwd123'; GRANT ALL ON ecommerce.* TO 'ecommerce'@'localhost';
3.2 步骤 2:初始化数据库并导入测试数据
别跳过test-data/!否则首页商品列表为空,你以为是代码 bug,其实是数据没导。
# 1. 创建数据库(若未创建) mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS ecommerce DEFAULT CHARACTER SET utf8mb4;" # 2. 执行建表脚本 mysql -u root -p ecommerce < sql/init.sql # 3. 导入测试商品(关键!) mysql -u root -p ecommerce -e "source test-data/product_data.sql;" # 或用 mysqlimport(更高效) mysqlimport --local --fields-terminated-by=',' --lines-terminated-by='\n' \ -u root -p ecommerce test-data/mock_products.csvtest-data/下的mock_products.csv是 UTF-8 编码,含 BOM 会报错。若导入失败,用 VS Code 以 UTF-8 无 BOM 保存后再试。
3.3 步骤 3:配置 Redis(订单锁和缓存依赖)
OrderService用 Redisson 做分布式锁,ProductController用@Cacheable缓存商品详情。没 Redis,下单会卡死,商品页会变慢。
# 启动 Redis(macOS Homebrew) brew install redis brew services start redis # 验证 redis-cli ping # 应返回 PONG检查config/application-dev.yml:
spring: redis: host: localhost port: 6379 database: 0 timeout: 2000 # Redisson 配置(已内置,无需改) redisson: address: redis://127.0.0.1:63793.4 步骤 4:启动 Spring Boot 应用
# 在项目根目录执行 mvn spring-boot:run -Dspring.profiles.active=dev成功标志:控制台输出
Started EcommerceApplication in 8.234 seconds (process running for 8.789)且无Failed to bind properties类错误。
3.5 步骤 5:验证核心接口(绕过前端,直击后端)
用 curl 测试,比刷网页更快定位问题:
# 1. 注册用户(POST) curl -X POST http://localhost:8080/api/v1/users/register \ -H "Content-Type: application/json" \ -d '{"username":"testuser","phone":"13800138000","password":"123456"}' # 2. 登录获取 token(响应体含 accessToken) curl -X POST http://localhost:8080/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"phone":"13800138000","password":"123456"}' # 3. 查商品列表(GET,带 token) curl -X GET "http://localhost:8080/api/v1/products?page=1&size=10" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."若第 3 步返回[],一定是test-data/没导入;若返回401 Unauthorized,是 token 过期或格式错(Bearer 后需空格)。
3.6 步骤 6:前端页面访问与调试
项目不含 Vue/React 前端,用的是 Thymeleaf 模板引擎,页面在src/main/resources/templates/。
- 首页:
http://localhost:8080/(自动跳转到/products) - 登录页:
http://localhost:8080/login - 订单页:
http://localhost:8080/orders(需先登录)
若页面空白,打开浏览器开发者工具 → Console,常见原因:
Uncaught ReferenceError: $ is not defined:jQuery 未加载,检查templates/layout.html中<script th:src="@{/webjars/jquery/3.6.0/jquery.min.js}"></script>路径是否 404(需确认pom.xml有webjars-jquery依赖);Thymeleaf template not found:检查application-dev.yml中spring.thymeleaf.prefix: classpath:/templates/是否被覆盖。
3.7 步骤 7:日志排查(当一切看似正常却功能异常)
比如“加购物车成功但页面不刷新”,别猜,看日志:
# 启动时加日志级别 mvn spring-boot:run -Dspring.profiles.active=dev \ -Dlogging.level.com.example.ecommerce.application.cart=DEBUG关键日志点:
CartApplicationService.addCartItem开头:打印userId,productId,quantity,确认参数传入正确;CartItemMapper.insert执行后:日志显示Creating a new record with id=123,证明 DB 写入成功;- 若没看到
Creating...,检查@Transactional是否被代理(方法不能是 private,且调用方必须是 Spring Bean)。
4. 核心模块代码逐行解析:订单创建与库存扣减的完整链路
订单模块是整个商城的“心脏”,也是最容易出并发问题的地方。我们从OrderController.createOrder()开始,一路跟踪到数据库UPDATE语句,看它如何用SELECT FOR UPDATE和 Redis 分布式锁双保险防超卖。
4.1 控制层:OrderController.createOrder()—— 接口契约与参数校验
@RestController @RequestMapping("/api/v1/orders") @Validated public class OrderController { @PostMapping public Result<OrderVO> createOrder( @RequestHeader("Authorization") String authHeader, @Valid @RequestBody CreateOrderDTO dto) { // ← DTO 强制校验 // 1. 解析 token 获取 userId Long userId = jwtTokenUtil.getUserIdFromToken(authHeader); // 2. 调用应用服务 OrderResult result = orderApplicationService.createOrder(userId, dto); // 3. 转换为 VO 返回 return Result.success(OrderVO.from(result.getOrder())); } }关键点:
@Validated+@Valid:触发CreateOrderDTO的@NotNull、@Min(1)等注解校验,非法请求(如 quantity=0)直接 400 返回,不进业务逻辑;@RequestHeader("Authorization"):强制要求 token,和需求分析说明书“所有写操作需鉴权”一致;OrderResult是领域服务返回的封装,含Order实体和InventoryLockResult锁结果,为后续扩展留接口。
4.2 应用服务层:OrderApplicationService.createOrder()—— 事务边界与锁协调
@Service @RequiredArgsConstructor public class OrderApplicationService { private final OrderDomainService orderDomainService; private final InventoryService inventoryService; private final RedissonClient redissonClient; @Transactional(rollbackFor = Exception.class) public OrderResult createOrder(Long userId, CreateOrderDTO dto) { // 1. 获取分布式锁(Redisson 可重入锁) RLock lock = redissonClient.getLock("order:create:" + userId); try { // 等待最多 3 秒,锁自动释放时间 30 秒 boolean isLocked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (!isLocked) { throw new BusinessException("系统繁忙,请稍后重试"); } // 2. 扣减库存(核心!) InventoryLockResult lockResult = inventoryService.lockInventory(dto.getItems()); // 3. 创建订单(此时库存已锁定) Order order = orderDomainService.createOrder(userId, dto, lockResult); // 4. 发送订单创建事件(异步,解耦) applicationEventPublisher.publishEvent(new OrderCreatedEvent(order.getId())); return new OrderResult(order, lockResult); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BusinessException("订单创建中断"); } finally { // 5. 释放锁(即使异常也要释放) if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }血泪经验:
tryLock(3, 30, ...)的 3 秒是用户体验底线,30 秒是防止死锁的“后悔药”——若业务逻辑卡住,锁自动释放,避免雪崩;lock.unlock()放在finally是铁律,我曾因漏写导致测试环境锁堆积,CPU 100%;InventoryLockResult不只是布尔值,它包含lockedItems列表,记录每个商品实际锁定的数量(可能小于请求量),为“部分下单”提供依据。
4.3 领域服务层:OrderDomainService.createOrder()—— 领域逻辑与持久化
@Service @RequiredArgsConstructor public class OrderDomainService { private final OrderMapper orderMapper; private final OrderItemMapper orderItemMapper; private final ProductMapper productMapper; public Order createOrder(Long userId, CreateOrderDTO dto, InventoryLockResult lockResult) { // 1. 构建订单主表 Order order = Order.builder() .userId(userId) .orderNo("ORD" + System.currentTimeMillis()) // 简单订单号,生产需雪花算法 .totalAmount(calculateTotal(dto.getItems(), lockResult)) .status(OrderStatus.UNPAID.name()) .createdAt(LocalDateTime.now()) .build(); // 2. 插入订单主表 orderMapper.insert(order); // ← 自动生成 order.id // 3. 构建订单明细(含价格快照!) List<OrderItem> items = new ArrayList<>(); for (CreateOrderItemDTO itemDTO : dto.getItems()) { // 关键:从 lockResult 获取锁定的商品信息,包括快照价 LockedProduct lockedProduct = lockResult.getLockedProduct(itemDTO.getProductId()); items.add(OrderItem.builder() .orderId(order.getId()) .productId(itemDTO.getProductId()) .productName(lockedProduct.getName()) // ← 快照名称 .unitPrice(lockedProduct.getUnitPrice()) // ← 快照单价 .quantity(itemDTO.getQuantity()) .subtotal(lockedProduct.getUnitPrice().multiply(BigDecimal.valueOf(itemDTO.getQuantity()))) .createdAt(LocalDateTime.now()) .build()); } orderItemMapper.insertBatch(items); // MyBatis-Plus 批量插入 // 4. 更新商品销量(非库存,是统计字段) updateProductSales(dto.getItems()); return order; } private BigDecimal calculateTotal(List<CreateOrderItemDTO> items, InventoryLockResult lockResult) { BigDecimal total = BigDecimal.ZERO; for (CreateOrderItemDTO item : items) { LockedProduct p = lockResult.getLockedProduct(item.getProductId()); total = total.add(p.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } return total; } }玄学细节:
orderNo用System.currentTimeMillis()是为了演示,实际项目必须用 Snowflake 或 Redis INCR,否则高并发下重复;orderItemMapper.insertBatch(items)调用的是 MyBatis-Plus 的insertBatchSomeColumn,它生成的 SQL 是INSERT INTO order_items (...) VALUES (...), (...), (...),比循环insert快 10 倍;updateProductSales方法里用productMapper.update(..., new UpdateWrapper<Product>().eq("id", productId)),更新sales_count字段,这是为“热销榜”埋点,和概要设计说明书“需支持按销量排序”呼应。
4.4 基础设施层:InventoryService.lockInventory()—— 数据库行锁实战
这才是防超卖的核心。它不用 Redis 计数器(易穿透),而是直连 MySQL 的SELECT FOR UPDATE。
@Service @RequiredArgsConstructor public class InventoryService { private final ProductMapper productMapper; private final InventoryMapper inventoryMapper; @Transactional(rollbackFor = Exception.class) public InventoryLockResult lockInventory(List<CreateOrderItemDTO> items) { List<LockedProduct> lockedProducts = new ArrayList<>(); List<Long> productIds = items.stream().map(CreateOrderItemDTO::getProductId).collect(Collectors.toList()); // 1. 一次性查出所有商品的当前库存(加行锁!) List<Product> products = productMapper.selectBatchIds(productIds); if (products.size() != productIds.size()) { throw new BusinessException("部分商品不存在"); } // 2. 按商品 ID 构建 Map,便于快速查找 Map<Long, Product> productMap = products.stream() .collect(Collectors.toMap(Product::getId, Function.identity())); // 3. 遍历校验并锁定 for (CreateOrderItemDTO item : items) { Product product = productMap.get(item.getProductId()); if (product == null || product.getStock() < item.getQuantity()) { throw new BusinessException("商品【" + item.getProductId() + "】库存不足"); } // 4. 执行 SELECT FOR UPDATE(关键!) // 注意:这里 productMapper 是自定义 Mapper,非 MyBatis-Plus 自带 Product lockedProduct = productMapper.selectForUpdateById(product.getId()); if (lockedProduct.getStock() < item.getQuantity()) { throw new BusinessException("商品【" + item.getProductId() + "】库存已变更,请刷新重试"); } // 5. 扣减库存(UPDATE) int updated = productMapper.decreaseStock(product.getId(), item.getQuantity()); if (updated == 0) { throw new BusinessException("库存扣减失败,请重试"); } // 6. 记录锁定结果(含快照价) lockedProducts.add(LockedProduct.builder() .id(product.getId()) .name(product.getName()) .unitPrice(product.getPrice()) // ← 快照价 .lockedQuantity(item.getQuantity()) .build()); } return new InventoryLockResult(lockedProducts); } }避坑指南:
selectForUpdateById对应的 XML 是<select id="selectForUpdateById" resultType="Product"> SELECT * FROM products WHERE id = #{id} FOR UPDATE </select>,FOR UPDATE必须在事务内执行,否则无效;decreaseStock的 SQL 是UPDATE products SET stock = stock - #{quantity} WHERE id = #{id} AND stock >= #{quantity},AND stock >= #{quantity}是乐观锁条件,防止 ABA 问题;- 如果
items有 5 个商品,selectForUpdateById会执行 5 次,但 MySQL 会自动优化为一次SELECT ... FOR UPDATE WHERE id IN (...),只要productIds不超 1000。
5. 避坑 / 常见问题 / 排查:上线前必须扫清的 5 个深坑
这些不是理论问题,是我在三家公司部署同类系统时,凌晨两点爬起来 debug 的真实血泪。
5.1 现象:下单成功,但数据库products.stock没减少,且日志无报错
原因:productMapper.decreaseStock的 SQL 中AND stock >= #{quantity}条件不满足,但 MyBatis 默认不抛异常,updated返回 0,而代码里if (updated == 0)后throw了,但你没看到日志——因为@Transactional回滚时,log.debug被吞掉了。
解决:在decreaseStock方法前后加log.info("Try decrease stock for {} by {}", productId, quantity);和log.info("Decrease result: {}", updated);,并确保日志级别为 INFO;同时检查application-dev.yml中logging.level.com.example.ecommerce.infrastructure=INFO。
5.2 现象:用户登录后,购物车商品消失
原因:CartApplicationService.mergeAnonymousCart()方法里,匿名购物车(Cookie 存储)的cartId是 UUID 字符串,而登录后查询cart_item.user_id = ?时,?是Long类型,MySQL 会隐式转换,导致WHERE user_id = 'a1b2c3...'全表扫描,超时。
解决:在mergeAnonymousCart中,先用cartItemMapper.deleteByCartId(cartId)清空匿名购物车,再将status='ACTIVE'的项UPDATE cart_item SET user_id = #{userId}, status = 'MERGED' WHERE cart_id = #{cartId},避免类型混用。
5.3 现象:首页商品列表加载慢(>3s),但单个商品 API 很快
原因:ProductController.listProducts()调用了productMapper.selectPage(page, wrapper),而wrapper未加索引字段过滤,MySQL 全表扫描products表(10w+ 行)。概要设计说明书要求“首页加载 ≤1.2s”,但products表缺INDEX idx_status_created (sale_status, created_at)。
解决:执行 SQL 添加复合索引:
ALTER TABLE products ADD INDEX idx_status_created (sale_status, created_at);并确认listProducts的QueryWrapper包含.eq("sale_status", 1)。
5.4 现象:Redisson 分布式锁失效,出现超卖
原因:RLOCK.tryLock(3, 30, TimeUnit.SECONDS)的 30 秒是锁租期(leaseTime),但createOrder方法执行时间超过 30 秒(如网络 IO 卡住),锁自动释放,另一个线程拿到锁,重复扣库存。
解决:启用 Redisson 的看门狗(Watchdog)机制,在config/application-dev.yml中添加:
redisson: address: redis://127.0.0.1:6379 lock: lease-time: 30000 # 30秒 wait-time: 3000 # 等待3秒 # Watchdog 默认开启,会自动续期同时,createOrder方法内禁止任何Thread.sleep()或阻塞 IO。
5.5 现象:OrderVO.from(order)报NullPointerException
原因:Order实体的List<OrderItem>是 lazy 加载,@Transactional方法结束后 Session 关闭,order.getItems()返回 null。OrderVO.from()里order.getItems().stream()空指针。
解决:在OrderDomainService.createOrder()返回前,强制初始化:
// 在 return order 前 Hibernate.initialize(order.getItems()); // ← 关键!或改用JOIN FETCH在OrderMapper.selectById的 XML 中预加载:
<select id="selectById" resultType="Order"> SELECT o.*, oi.* FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id WHERE o.id = #{id} </select>6. 二次开发实战:如何安全地接入微信支付并保证订单状态最终一致
现在你要把这套商城接上微信支付。别急着改OrderController,先看概要设计说明书_v1.3.pdf第 7.2 节“支付模块设计原则”:
“支付回调必须幂等;订单状态变更需通过消息队列异步通知;支付成功后,订单状态由 UNPAID → PAID,库存状态由 LOCKED → SOLD。”
这意味着,你不能在支付回调里直接UPDATE orders SET status='PAID',而要走事件驱动。下面是我落地过的方案。
6.1 步骤 1:新增支付回调接口,只做验签和发消息
@RestController @RequestMapping("/api/v1/payments") public class PaymentCallbackController { @PostMapping("/wechat/notify") public String wechatNotify(@RequestBody String xmlBody, HttpServletRequest request) { try { // 1. 解析 XML,验签(微信 SDK 提供) Map<String, String> notifyMap = WXPayUtil.xmlToMap(xmlBody); if (!WXPayUtil.isSignatureValid(notifyMap, "your_mch_key")) { return "<xml><return_code><![CDATA[FAIL]]></return_code><return_msg><![CDATA[SIGNATURE_ERROR]]></return_msg></xml>"; } // 2. 提取关键参数 String outTradeNo = notifyMap.get("out_trade_no"); // 商户订单号 String transactionId = notifyMap.get("transaction_id"); // 微信订单号 String resultCode = notifyMap.get("result_code"); if ("SUCCESS".equals(resultCode)) { // 3. 发送支付成功事件(不处理业务!) paymentEventPublisher.publishEvent( new WechatPaymentSuccessEvent(outTradeNo, transactionId)); } // 4. 返回 SUCCESS,告诉微信 <p> <a href="https://download.csdn.net/download/lu_2012_01kk/9408395" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>