简介:当当网作为国内知名电商平台,其完整的项目源代码是初中级Java开发者学习真实业务系统的理想素材。这份代码展示了从用户登录、商品展示、购物车到订单地址管理等核心电商模块的实现方式,适合用于训练代码阅读能力,理解一个完整Web项目从前端交互到后端服务的整体链路。
压缩包共406个文件,大小约3.29MB,以Java源文件、JSP页面、CSS样式和SQL脚本为主,辅以9个jar依赖库和大量商品展示图片,结构上包含前端界面、后端服务、数据库设计、接口定义与配置文件等部分。代码中体现了Spring Boot、MyBatis等主流框架的配合使用,可帮助学习RESTful API的接口规范、ORM实体映射以及跨环境配置的差异处理。
资源在CSDN已获得1221人学习浏览,适合具有一定Java基础、希望从单体项目入手补充项目经验的开发者,按模块拆解分析、跟踪调用链并结合实际编码练习,能较快提升服务端开发与数据库设计的实战能力。
1. 「整个项目源代码」到底意味着什么:先想清楚再动手
「整个项目源代码」这类标题,最容易让想练手的人误判:以为下载一个压缩包解压就能跑,结果解压完看到几十个模块、三四份 README、没有数据库脚本,连该从哪里启动都找不到。真正值得复现的不是一堆代码文件,而是把商品、购物车、订单、库存、支付回调这一条交易链路跑通。这篇文章以某知名图书商城的完整项目复现为例,讲清楚后端服务、前端商城、管理后台三端工程怎么组织,核心表怎么设计,下单扣库存怎么保证不超卖,支付回调怎么做幂等,以及本地环境怎么一键拉起来。适合两类人:打算把它当练手项目、想给简历写完整项目经验的开发者,以及接手了类似代码包但不知道从哪下手的同学。
2. 先立技术底座:分层架构与核心数据模型
复现一个电商项目,最容易犯的错是一上来就写业务代码,结果写到一半发现表结构撑不住需求,或者接口和前端约定对不上。我这里习惯的节奏是:先定架构形态,再定四张核心表,最后出一份接口清单,然后才开写。
2.1 单体优先还是微服务:小团队最稳的分层方式
看到「整个项目」四个字,第一反应可能是微服务:网关、注册中心、配置中心、分布式事务中间件全上一套。我一开始也这么干过,结果在本地跑五个服务用了两天,还没开始写业务。
后续做同类项目,我通常默认单体优先。理由很直接:一是部署成本低,一个 Spring Boot 应用包加 MySQL 和 Redis 就能跑;二是调试链路短,下单接口从 Controller 穿到 Service 再穿到 Mapper,断点可以从头跟到尾,不用跨服务跳来跳去;三是事务好处理,订单和库存数据在同一库里,本地事务就能保证一致性,不需要引入分布式事务。微服务要处理的注册发现、配置同步、服务间鉴权,对这个练手项目没有业务价值,反而稀释了主链路的复杂度。
那什么时候拆?等订单量真的起来了,或者商品团队、订单团队需要独立发版时再按域拆。拆的顺序一般是从订单域开始,因为交易链路对稳定性要求最高。复现阶段把单体做干净,后期拆分的成本反而低。分层上我一般保持 Controller、Service、Mapper 三层,Controller 只做参数校验和路由,Service 放业务规则,Mapper 只碰 SQL。另外加一层 domain 放实体和状态枚举,避免枚举值散落在业务代码里。
2.2 商品、库存、订单、用户:四张核心表的字段设计
四张表是这套项目的骨架:用户表、图书表、库存表、订单表。设计时有几个容易被忽略的点:金额一律用分存整数,不用 decimal 和 float,避免浮点误差引起对账翻车;订单表要冗余用户昵称和商品标题、封面,防止商品下架后历史订单不可读;库存单独拆一张表,因为图书详情是读多写少,库存是高频更新,拆开之后图书缓存可以放心用,扣库存不会把商品缓存弄失效。
-- 用户表 CREATE TABLE `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(32) NOT NULL, `password` VARCHAR(128) NOT NULL COMMENT '存 BCrypt 哈希,不存明文', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 图书表 CREATE TABLE `book` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `author` VARCHAR(64) NOT NULL DEFAULT '', `category_id` INT NOT NULL, `price_cents` INT NOT NULL COMMENT '价格,单位分', `cover_url` VARCHAR(256) NOT NULL DEFAULT '', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_category` (`category_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 库存表 CREATE TABLE `stock` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `book_id` BIGINT NOT NULL, `quantity` INT NOT NULL DEFAULT 0, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_book` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表与订单项表 CREATE TABLE `order` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` BIGINT NOT NULL, `user_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '下单时冗余', `status` TINYINT NOT NULL COMMENT '10待支付 20已支付 30已取消 40已关闭', `total_cents` INT NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `paid_at` DATETIME NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `book_id` BIGINT NOT NULL, `book_title` VARCHAR(128) NOT NULL DEFAULT '', `price_cents` INT NOT NULL COMMENT '下单时单价快照', `quantity` INT NOT NULL, PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段说明里最值得留意的三个设计取舍:订单状态用 TINYINT 加注释,而不是字符串,理由是状态流转要频繁做条件更新,数字比较比字符串快,而且前期就把状态枚举定死,后面写代码不会出现「已支付」和「支付成功」两种写法并存的问题。订单号单独建唯一索引,不用自增主键对外暴露,避免别人通过订单 ID 差值估算出货量。订单项里的 price_cents 是快照,不是下单时现查图书表,因为图书表价格随时会改,历史订单必须反映成交那一刻的价格。
2.3 先画接口清单再写代码:十二个核心接口
「整个项目」规模一大,前后端最容易脱节。常见做法是先出一份接口清单,前端拿到后可以并行开发,后端拿着做任务拆分,等联调时按清单逐条过,谁缺谁补就清楚了。我通常把接口数量控制在能覆盖主链路的范围内,不追求 REST 风格教条,而是按业务动作命名。
| 模块 | 路径 | 方法 | 说明 |
|---|---|---|---|
| 认证 | /api/auth/login | POST | 登录,返回 token |
| 认证 | /api/auth/register | POST | 注册 |
| 商品 | /api/books | GET | 分页查询,支持分类筛选 |
| 商品 | /api/books/{id} | GET | 图书详情 |
| 购物车 | /api/cart | GET | 当前用户购物车 |
| 购物车 | /api/cart/items | POST | 加入购物车 |
| 购物车 | /api/cart/items/{id} | DELETE | 移除购物车项 |
| 订单 | /api/orders | POST | 创建订单 |
| 订单 | /api/orders/{id} | GET | 订单详情 |
| 订单 | /api/orders/{id}/cancel | POST | 取消订单(限未支付) |
| 支付 | /api/payments/notify | POST | 支付回调 |
| 管理后台 | /api/admin/books | POST | 上架图书 |
| 管理后台 | /api/admin/books/{id}/stock | PUT | 调整库存 |
接口清单定完,还要定一个响应包装:统一返回{ code, message, data },code 为 0 表示成功,非 0 表示业务异常。错误码分区,比如 10001 是参数错误,20001 是库存不足,30001 是订单状态冲突。这样前后端联调时看到 code 就能定位模块,不用翻日志猜。
3. 把「整个项目」拆成可运行的最小集合:工程初始化
架构定好,下一步是把工程实际建出来。这里有个血泪经验:一开始不要试图把「整个项目」准备得面面俱到,先搭出能启动、能连库、能渲染首页的最小子集,跑通后再往里面加模块。子集跑不起来,后面加再多代码都是空转。
3.1 三端工程怎么拆:web、admin、server
复现电商项目,我一般拆三个工程:用户商城前端、管理后台前端、后端服务。管理后台单独拆出来的原因有两个:一是权限模型不同,管理端需要管理员角色和操作审计,和用户端混在一起会让鉴权代码变得复杂;二是部署粒度不同,运营人员改商品上架不需要发整个商城的前端包。三端放在同一个仓库,用目录区分,本地开发时各自独立启动。
bookstore/ ├── server/ # Spring Boot 后端服务 │ ├── pom.xml │ └── src/main/ │ ├── java/com/example/bookstore/ │ │ ├── controller/ # HTTP 入口 │ │ ├── service/ # 业务逻辑 │ │ ├── mapper/ # SQL 映射 │ │ └── domain/ # 实体、枚举 │ └── resources/ │ ├── application.yml │ └── db/ # 初始化脚本 ├── web/ # 用户商城前端(Vue) ├── admin/ # 管理后台前端(Vue) └── deploy/ └── docker-compose.yml后端用 Spring Boot,前端用 Vue,这是目前市面上同类项目最常见的组合。前端选 Vue 而不是 React,对练手项目来说更好上手,而且 Element 组件库把表格、表单、弹窗这些管理后台高频组件都封装好了,管理端能少写很多样板代码。如果团队更熟 React,换成 React 也完全可行,后端不感知前端框架差异,接口清单是唯一的契约。
3.2 后端工程初始化:用脚手架建骨架
后端骨架不手写 POM,直接用 Spring Initializr 生成。命令行方式如下,IDE 内置的初始化向导选同样的依赖也可以:
# 在 bookstore 目录下执行,生成 server 工程 curl https://start.spring.io/starter.tgz \ -d dependencies=web,mysql,data-redis,validation \ -d language=java \ -d type=maven-project \ -d baseDir=server \ -d groupId=com.example \ -d artifactId=bookstore-server \ -d packageName=com.example.bookstore \ | tar -xzvf -四个依赖分别对应:web 提供 Spring MVC 和内置 Tomcat,mysql 是 MySQL 驱动,data-redis 提供 Redis 客户端和 Spring Data Redis 封装,validation 是参数校验的 JSR 303 实现,后面写接收注册请求时直接加 @Valid 就能校验字段。这里我一般会把 actuator 也加上,虽然它不直接贡献业务功能,但本地调试时看健康检查端点、确认服务是否真正起来了,比看日志快得多。
生成完的骨架可以空跑,但连不上数据库,因为还没有数据源配置和初始化脚本。下一步先把 application.yml 配好,再准备建表和种子数据。
3.3 数据库初始化脚本:从建库到种子数据
数据源配置里最容易踩的坑是时区。MySQL 8 对时区敏感,连接串里不指定 serverTimezone,启动时大概率报 CST 或 UTC 的时区错误,或者不报错但时间和本地对不上。
spring: datasource: url: jdbc:mysql://localhost:3306/bookstore?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: bookstore password: bookstore redis: host: localhost port: 6379 jackson: time-zone: Asia/Shanghai数据库初始化脚本放在 server 工程的 resources/db 下,配合启动时执行。种子数据的目的是让首页第一次渲染时有内容,不然前端联调时列表永远是空,容易误判接口坏了。
-- 初始化脚本,建议在首次启动时自动执行 CREATE DATABASE IF NOT EXISTS bookstore DEFAULT CHARACTER SET utf8mb4; INSERT INTO book (title, author, category_id, price_cents, status) VALUES ('示例图书·卷一', '示例作者A', 1, 4900, 1), ('示例图书·卷二', '示例作者B', 1, 6800, 1), ('示例图书·卷三', '示例作者C', 2, 3500, 1); INSERT INTO stock (book_id, quantity) VALUES (1, 100), (2, 80), (3, 50);注意初始化脚本要可重复执行,或者只执行一次的机制,否则第二次启动时直接报主键冲突。我一般用 Spring 的 SQL init 配置配合CREATE TABLE IF NOT EXISTS,种子数据用INSERT ... ON DUPLICATE KEY UPDATE保证幂等。这一步做完,后端服务应该能启动、能连库、能查出种子图书,最小子集才算立住。
4. 交易主链路落地:从加购到支付回调
骨架和表结构就绪后,进入整个复现项目最核心的部分:交易主链路。这条链路是电商系统区别于普通 CRUD 系统的分水岭,也是最容易出现并发问题的地方。我的原则是先把下单、扣库存、支付回调三个动作写对,再考虑查询优化和页面美化。
4.1 下单与扣库存:事务边界和行锁是关键
加购接口本身不复杂,本质是对购物车表的增删改查。真正有难度的是从购物车创建订单的那一步,它同时要扣库存、写订单主表、写订单项表,三个动作必须在一个事务里,任何一个失败都要全部回滚。
@Transactional public Order createOrder(Long userId, List<CartItem> items) { // 1. 汇总金额,单位是分 long totalCents = items.stream() .mapToLong(CartItem::getPriceCents) .sum(); // 2. 逐项扣库存,UPDATE 带条件,由数据库保证不超卖 for (CartItem item : items) { int updated = stockMapper.deduct(item.getBookId(), item.getQuantity()); if (updated == 0) { throw new BusinessException("库存不足: " + item.getBookId()); } } // 3. 生成订单号和订单记录 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setStatus(OrderStatus.WAIT_PAY); order.setTotalCents(totalCents); orderMapper.insert(order); // 4. 写订单项,保存价格快照 for (CartItem item : items) { orderItemMapper.insert(convertToItem(order.getId(), item)); } return order; }对应的 Mapper SQL 是关键:
-- 条件扣减,quantity >= #{qty} 是防超卖的核心 UPDATE stock SET quantity = quantity - #{qty} WHERE book_id = #{bookId} AND quantity >= #{qty}这段逻辑里最反直觉的地方是:不要在 Java 代码里先 SELECT 库存再判断够不够、然后再 UPDATE。两个请求同时读到库存是 1,都判断够,都去 UPDATE,结果库存变成 -1。正确做法是把库存判断放到 UPDATE 的 WHERE 条件里,数据库的行锁会保证同一时刻只有一个事务能更新成功,返回影响行数 0 就说明库存不足,直接抛异常回滚。
参数上有两个调法需要注意:一是扣减的 quantity 要限制单次购买数量,比如单本图书最多购买 5 册,防止有人一次下几百册把库存打穿;二是事务注解要加在 public 方法上,同类内部调用会导致事务失效,这是个经典翻车点。另外订单号生成我用的是时间戳加随机数和用户 ID 后四位,长度控制在 32 位以内,测试时肉眼也能读出大致时间。
4.2 支付回调:幂等处理不做好会翻车
真实项目里支付回调是异步推送的,可能同时来两条、也可能网络抖动后重试。如果回调处理不幂等,订单状态可能被覆盖,库存可能被重复回补。复现项目没有真实支付渠道,常见做法是在本地模拟一个回调接口,逻辑和真实支付平台一致:App 跳转支付页 → 本地模拟支付成功 → 回调服务端。
@PostMapping("/api/payments/notify") public Result handleNotify(@RequestBody PayNotifyRequest payload) { // 1. 查订单,不存在直接返回失败 Order order = orderMapper.findByOrderNo(payload.getOrderNo()); if (order == null) { return Result.fail(30001, "订单不存在"); } // 2. 已经支付,直接返回成功,保证重复回调不重复处理 if (order.getStatus() == OrderStatus.PAID) { return Result.ok(); } // 3. 条件更新:只有当前状态是待支付,才能改为已支付 int updated = orderMapper.updateStatus(order.getOrderNo(), OrderStatus.WAIT_PAY, OrderStatus.PAID); if (updated == 0) { return Result.fail(30002, "订单状态冲突"); } // 4. 到这里才真正完成支付,触发后续流程 log.info("支付成功 orderNo={}", payload.getOrderNo()); return Result.ok(); }对应的 SQL:
UPDATE `order` SET status = #{targetStatus}, paid_at = NOW() WHERE order_no = #{orderNo} AND status = #{expectStatus}这里的状态条件更新值得多说一句:判断当前状态和修改状态两个动作必须合并到一条 UPDATE 语句里,而不是先 SELECT 判断再 UPDATE。先查再改在并发下会出问题:两个回调同时进来都查到待支付状态,都走更新逻辑,第二个更新虽然能成功,但触发两次后续发货流程。改成 WHERE status = 期望前置状态 后,数据库层面保证只有第一个请求能更新成功,第二个请求影响行数为 0,直接走状态冲突分支。
调试时我会在本地模拟回调接口上打印完整的请求报文,因为真实支付平台推送的字段格式经常比你定义的多几个,用严格 JSON 反序列化会直接报错。处理方式是 DTO 里只定义需要的字段,多余字段忽略,不校验未知字段。这个习惯帮我省掉了大量联调时间。
4.3 订单超时关闭:定时任务扫表与延迟队列的取舍
待支付订单不能一直挂着,一般 30 分钟未支付就关闭并回补库存。这个动作的实现方式,决定这个复现项目的「工程感」到底怎么样。最简单可靠的方案是定时任务扫表,配上条件更新保证幂等。
@Scheduled(fixedDelay = 60_000) @Transactional public void closeExpiredOrders() { // 查询超时未支付的订单,限制批次大小,防止一次扫太多 List<Order> expired = orderMapper.findExpired( LocalDateTime.now().minusMinutes(30), 200); for (Order order : expired) { int updated = orderMapper.updateStatus(order.getOrderNo(), OrderStatus.WAIT_PAY, OrderStatus.CLOSED); if (updated > 0) { // 只对真正关闭成功的订单回补库存 orderMapper.restoreStock(order.getOrderNo()); } } }用条件更新的好处是:定时任务重复执行也不会重复关单,因为第二次执行时订单状态已经不是待支付了。回补库存也只会执行一次。这个方案在订单量几千到几万的规模下完全够用,定时任务每 60 秒扫一次,最多会有 1 分钟的延迟误差,对用户来说感知不到。
什么时候换延迟队列?当待支付订单量到了几十万,一次扫 200 条需要循环很多次,数据库压力明显上升时再换。常见做法是用 Redis 的过期事件或者 RabbitMQ 的延迟队列,但这两者都引入了新的依赖和新的失败场景。本地复现阶段,定时任务扫表已经能把「超时关单 → 回补库存」的完整链路跑通了,我不建议一步到位上延迟队列。
5. 避坑记录:复现电商项目时最常踩的 5 个坑
这类项目我在不同机器上搭过很多遍,每一步的坑都熟。这一章不写理论,直接记录 5 个高频踩坑点,每一条都是「现象 → 原因 → 解决」。
5.1 订单提前 8 小时被关闭,超时判断全乱
现象:订单 14:00 创建,设置 30 分钟超时,结果 18:00 左右就被定时任务关闭,关闭时间比预期晚了整整一个时区差。另一台机器上则相反,订单刚创建 5 分钟就被关掉。
原因:应用服务和 MySQL 的时区不一致。应用默认 UTC,MySQL 连接串没指定 serverTimezone,Java 侧读出的时间与数据库存储时间相差 8 小时,定时任务拿本地时间比较,自然错位。
解决:数据源连接串统一加serverTimezone=Asia/Shanghai,同时把 Jackson 的 time-zone 也设成 Asia/Shanghai,保证 JSON 序列化出来的时间戳对前端也友好。这个配置在 2.2 节的代码里已经给出来了,建议所有环境统一,不要靠服务器默认时区碰运气。
5.2 并发下单把库存扣成负数
现象:对库存只有 1 本的图书发起 200 个并发下单请求,最终订单成功数量和实际库存对不上,库存出现负数。
原因:代码里先 SELECT 库存,判断大于 0 再 UPDATE 扣减。两个请求同时在第一步读到库存 1,都认为有货,都执行 UPDATE,库存变 -1。
解决:改成条件 UPDATE,即UPDATE stock SET quantity = quantity - #{qty} WHERE book_id = #{bookId} AND quantity >= #{qty},判断库存充足与否的职责交给数据库的行锁和 WHERE 条件。影响行数为 0 的请求直接抛库存不足。这个方案的副作用是并发下单时会有一定比例的请求失败,对一致性要求高的电商场景是可接受的,失败请求让用户重试即可。
5.3 支付回调重复推送导致库存多回补
现象:订单支付成功后,用户申请退款,退款流程刚走完,支付平台的重试回调又来了,系统把订单状态改回已支付,同时执行了第二次库存回补。
原因:回调处理里先判断订单状态、再更新状态,两步之间没有原子性。退款动作在两次回调之间执行,第二次回调拿到的状态判断结果是旧值。
解决:所有状态流转都改成条件更新,UPDATE order SET status = #{target} WHERE order_no = #{orderNo} AND status = #{expect}。更新影响行数为 0 就说明状态已被其他流程改动,直接拒绝本次回调。这条规则同样适用于退款、取消订单、超时关闭,凡是状态机里的每一个跳转,都必须带前置状态条件。
5.4 前端跨域登录成功,后续请求却全部 401
现象:前端跑在 8080,后端跑在 8081,登录接口调用成功返回了 token,但紧接着的购物车接口全部返回 401,登录状态像没有生效一样。
原因:CORS 配置里设置了跨域允许,但没有开启 allowCredentials,浏览器默认不允许跨域请求携带 Cookie。另一种常见原因是后端 Origin 配置写成了*,而带凭证的跨域请求不允许使用通配符 Origin。
解决:后端 CorsFilter 里指定具体 Origin 列表,开启setAllowCredentials(true),同时前端 axios 请求里加withCredentials: true。两边的配置是成对生效的,缺一个就白调。排查这类问题最有效的办法是打开浏览器开发者工具看请求响应头里有没有 Access-Control-Allow-Credentials: true。
5.5 换一台机器就跑不起来,依赖版本不一致是玄学
现象:项目在本机跑得好好的,打包发给别人后,依赖各种报错。大部分是 Redis 连不上或者序列化异常,少数情况是数据库驱动版本行为和预期不一致。
原因:复现项目时有人习惯直接引入最新版本依赖,但最新版本在不同机器上的兼容行为可能有差异,尤其是 Spring Data Redis 的序列化策略换过好几次默认值。
解决:固定依赖版本并在 POM 里显式声明,不用父 POM 的隐式管理;Docker Compose 里镜像 tag 也不要写 latest,统一指定像 mysql:8.0、redis:7 这种明确版本。这样换机器时最差不过是重新拉镜像,不会因为版本漂移出现偶发问题。
6. 让它彻底跑起来并验证:Docker Compose 一键启动与检查清单
代码和表结构都有了,最后一步是让整个项目在干净环境里一键启动,并对照检查清单确认不是「能启动」而是「能走通」。这一步做完,才算真正复现了这套项目。
6.1 用 Docker Compose 起全套服务,不污染本机环境
我一般会在项目的 deploy 目录放一个 docker-compose.yml,把 MySQL、Redis、后端服务一次性定义好。这样不管是自己换电脑还是把项目交给别人,都不会被本机装没装 MySQL、版本对不对卡住。
version: "3.8" services: mysql: image: mysql:8.0 container_name: bookstore-mysql environment: MYSQL_DATABASE: bookstore MYSQL_USER: bookstore MYSQL_PASSWORD: bookstore MYSQL_ROOT_PASSWORD: root ports: - "3306:3306" volumes: - ./mysql/init:/docker-entrypoint-initdb.d healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10 redis: image: redis:7 container_name: bookstore-redis ports: - "6379:6379" server: build: ../server container_name: bookstore-server depends_on: mysql: condition: service_healthy redis: condition: service_started ports: - "8081:8081"这里必须说明depends_on的一个坑:它默认只保证启动顺序,不保证依赖服务已经可用。Spring Boot 启动时要连数据库,如果 MySQL 还没就绪就启动应用,会直接失败。所以我在 mysql 服务上加了 healthcheck,并在 server 的 depends_on 里用 condition: service_healthy 强制等待 MySQL 健康检查通过。启动命令就是docker compose up -d --build,日志用docker compose logs -f server跟踪,看「Started BookstoreApplication」就算起来成功。
6.2 验证清单:跑通主链路比看到首页更重要
能在浏览器里看到商城首页,只是「项目能启动」,离「整个项目复现成功」还差得远。真正对这套代码有信心的标志是下面这张清单全部通过。
| 验证项 | 预期结果 | 失败时排查方向 |
|---|---|---|
| 注册并登录 | 返回 token,带 token 访问 /api/cart 返回空购物车 | 数据库 user 表是否有记录,密码是否为 BCrypt 哈希 |
| 首页商品列表 | 显示 3 本种子图书 | 检查 book 表数据,确认 status=1 |
| 加入购物车并下单 | 订单状态变为待支付,库存减少对应数量 | 看 server 日志里有没有库存不足异常 |
| 模拟支付回调 | 订单状态变为已支付,库存不再变化 | 检查回调接口是否走幂等分支 |
| 等待 30 分钟 | 订单自动关闭,库存回补 | 检查定时任务是否执行,扫描批次是否正确 |
| 管理后台调库存 | 商品详情页库存同步更新 | 确认管理端接口走的是同一套后端服务 |
最后补一个实战技巧:本地没有真实支付渠道,模拟支付回调可以做成一个本地脚本,每次测试直接跑,不用手点页面。
# 模拟支付成功回调,orderNo 换成实际订单号 curl -X POST http://localhost:8081/api/payments/notify \ -H "Content-Type: application/json" \ -d '{"orderNo": "202501011200001234", "amount": 4900}'把这条 curl 存成一个mock-pay.sh,配合定时关单的等待时间,整个支付链路可以在两分钟内完整走完一次。「整个项目源代码」不应该是一个只能看不能跑的压缩包,把它还原成能下单、能支付、能关单、能回补库存的闭环,才算真正掌握这套电商项目。我也曾经以为代码越多越完整,后来发现主链路跑通加上幂等、并发、超时三个边界都处理干净,比堆几十个用不到的模块实在得多。希望帮到你。
本文还有配套的精品资源,点击获取