GitHub仓库 :github.com/2530622506/...
01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?
02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?
03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
本篇继续写给"已经会前端、正在转后端"的你。购物车是前端同学最熟悉的业务之一:加入购物车、数量、勾选、合计金额、角标数量、不可售提示。但在后端工程里,购物车不是一个存在浏览器内存里的数组,而是和登录用户绑定、落到 MySQL、每次查询都要重新组装商品与 SKU 当前状态的业务模块。本文出现的 Vue / TypeScript / Axios 片段全部是"前端侧示意代码",只用于类比理解;真实代码以当前仓库
backend/为准。
1. 这篇解决什么问题
前面第 10 篇讲了 SKU、价格、可售库存、锁定库存和乐观锁。现在我们把 SKU 放到用户行为里:用户在商品详情页选择某个 SKU,输入数量,然后点击"加入购物车"。从前端看,这像是把一条数据 push 到 Pinia / Vuex / Zustand 里;从后端看,它要解决更多问题:用户是谁?能不能加入别人的购物车?SKU 是否存在?商品是否已上架?价格是否大于 0?库存是否足够?同一个用户是否已经加过同一个 SKU?查询购物车时,商品下架了怎么办?库存变少了怎么办?SKU 被删除了怎么办?合计金额能不能直接作为订单金额?
本篇要解决 8 个核心问题:
- 前端购物车状态和后端购物车表有什么区别;
- 为什么
userId必须来自登录态,不能由前端提交; - 加入购物车为什么保存
skuId,而不是只保存productId; CartAddRequest为什么只有skuId和quantity;- 后端加入购物车前如何校验商品、SKU、价格和库存;
- 为什么同一个用户同一个 SKU 不能重复加入,数据库唯一索引如何兜底;
- 查询购物车时为什么要批量查询 SKU 和商品,避免 N+1;
- 为什么购物车金额只是预估金额,真正订单金额必须在创建订单时重新计算。
读完本篇,你应该能独立解释这些现象:未登录访问 /api/cart/items 为什么返回 UNAUTHORIZED;请求体里偷偷传 userId=2 为什么不会把商品加入 2 号用户购物车;数量 0 或 100 为什么返回 VALIDATION_ERROR;草稿商品的 SKU 为什么不能加入购物车;同一个用户重复加入 1 号 SKU 为什么返回 CART_ITEM_ALREADY_EXISTS;商品下架后购物车行为什么不删除,而是 saleable=false;未勾选购物车行为什么不计入 estimatedTotal。
2. 用前端知识类比
前端实现购物车时,最自然的写法是维护一个数组:
ts
// 前端侧示意代码:页面内购物车状态
interface CartItemView {
skuId: number
productTitle: string
specText: string
currentUnitPrice: string
quantity: number
checked: boolean
availableStock: number
saleable: boolean
}
const cartItems = ref<CartItemView[]>([])
点击"加入购物车"时,前端可能会先判断本地是否已经存在:
ts
// 前端侧示意代码:本地去重只能提升体验,不能替代后端唯一约束
function addLocalCartItem(item: CartItemView) {
const exists = cartItems.value.some(row => row.skuId === item.skuId)
if (exists) {
toast('该商品已经在购物车中')
return
}
cartItems.value.push(item)
}
这段逻辑只能管理当前页面状态。用户换浏览器、换设备、刷新页面、登录另一个账号后,前端内存都会变化。后端购物车则不同,它是用户维度的持久化数据,存放在 mall_cart_item 表里。只要用户登录,后端就能按 user_id 查询这个用户自己的购物车。
更重要的是,前端状态不可信。前端可以传 userId,但后端不能相信;前端可以显示库存足够,但库存可能已经被别人买走;前端可以隐藏不可售按钮,但用户可以手动构造请求。所以后端必须重新查询 SKU 和商品,重新判断可售,重新判断库存,重新判断重复行。
你可以把后端购物车理解成"登录用户在服务端的购物车 store"。但它不是简单 store,因为它不会把所有展示字段都冗余保存。购物车表只保存用户、SKU、数量、勾选状态和时间;商品标题、SKU 编码、规格、当前价格、当前库存、是否可售、行金额,都是查询时从商品和 SKU 模块组装出来的当前值。
3. 后端核心概念讲解
3.1 购物车是用户资源
购物车不是公共资源,而是"当前登录用户"的资源。当前项目的接口路径是 /api/cart/items,路径里没有 userId。这很有意义:前端不需要告诉后端"我要查哪个用户的购物车",后端会根据 JWT 认证上下文判断当前用户是谁。
如果接口设计成 /api/users/{userId}/cart/items,普通用户就可能尝试把路径里的 userId 改成别人的 ID。虽然后端仍然可以校验路径用户和当前用户是否一致,但当前项目选择更简单的方式:购物车永远是"我的购物车",userId 只来自 CurrentUserService.requireCurrentUserId()。
这和前端路由里的"我的资料页"类似。页面可能叫 /profile,而不是 /users/1/profile。因为当前用户是谁由登录态决定,不需要前端传用户 ID。
3.2 购物车保存 SKU,不保存商品
用户加入购物车时,选择的是具体规格。比如 iPhone 15 黑色 128G 和 iPhone 15 蓝色 256G 是同一个商品下的两个 SKU,价格和库存可能不同。如果购物车只保存 productId,后端不知道用户到底要买哪个规格。因此 mall_cart_item 保存的是 sku_id。
商品标题、SKU 编码、规格描述、当前价格、库存是展示字段,不全部保存在购物车表里。查询购物车时,后端根据 sku_id 批量查 SKU,再根据 SKU 的 product_id 批量查商品,最后组装响应。这种设计避免购物车表和商品表字段重复过多,也保证商品改名或价格变化后,购物车查询能看到当前值。
3.3 购物车金额只是预估
CartItemResponse.currentUnitPrice 是查询购物车时的当前单价,lineAmount 是当前单价乘以购物车数量,CartResponse.estimatedTotal 是已勾选且可售行的小计之和。它们都只是"预估金额",不是订单成交金额。
为什么?因为用户从购物车页到提交订单之间,价格和库存可能变化。真正创建订单时,后端必须重新读取 SKU 当前价格和库存,重新锁库存,并保存订单快照。不能让前端把购物车页显示的 estimatedTotal 原样提交给订单服务。前端展示金额是体验,订单金额是交易事实。
3.4 为什么保留不可售购物车行
商品下架、SKU 删除、库存不足时,购物车查询不会直接删除这行,而是尽量保留行并标记 saleable=false。这样用户能看到"这个东西之前加过,但现在不可购买"。如果后端静默删除,用户可能困惑:购物车里的东西为什么突然没了?
当前项目的测试明确验证了这些规则:商品下架后购物车行仍返回,但 saleable=false;库存变少不足以覆盖购物车数量时,行仍返回但不可售;SKU 被删除时,行仍返回 skuId,但商品和价格字段为空,saleable=false。这体现了后端查询接口对前端体验的支持:保留上下文,让页面能给用户解释。
4. 在本项目中对应哪些文件
本篇主要基于这些真实文件:
| 文件 | 作用 |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/cart/CartController.java |
当前登录用户购物车 HTTP 入口,提供加入购物车和查询我的购物车 |
backend/contract/src/main/java/com/example/fullstackmall/contract/cart/ICartFacade.java |
购物车用例契约,强调 userId 来自登录上下文 |
backend/service/src/main/java/com/example/fullstackmall/service/cart/CartFacade.java |
购物车业务编排:用户身份、可售校验、重复校验、批量组装响应 |
backend/contract/src/main/java/com/example/fullstackmall/contract/cart/CartAddRequest.java |
加入购物车请求 DTO,只允许提交 skuId 和 quantity |
backend/contract/src/main/java/com/example/fullstackmall/contract/cart/CartItemResponse.java |
购物车行响应,包含商品、SKU、价格、库存、可售状态和小计 |
backend/contract/src/main/java/com/example/fullstackmall/contract/cart/CartResponse.java |
我的购物车响应,包含明细、数量合计和预估总额 |
backend/service/src/main/java/com/example/fullstackmall/service/cart/entity/CartItemEntity.java |
mall_cart_item 表对应 Entity |
backend/service/src/main/java/com/example/fullstackmall/service/cart/service/CartItemDbService.java |
购物车数据库服务,所有用户资源查询都带 userId |
backend/service/src/main/java/com/example/fullstackmall/service/cart/mapper/CartItemMapper.java |
购物车 Mapper,后续订单会用到删除已勾选购物车项 |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/service/SkuDbService.java |
查询 SKU、库存、价格 |
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java |
查询商品状态和标题 |
sql/01_schema.sql |
mall_cart_item 表结构、唯一索引和约束 |
sql/02_seed.sql |
购物车演示数据:1 号用户勾选 1 号 SKU,2 号用户未勾选 4 号 SKU |
backend/service/src/test/java/com/example/fullstackmall/service/cart/CartControllerTest.java |
验证登录、加入、重复、可售、隔离、金额等规则 |
backend/service/src/test/java/com/example/fullstackmall/service/cart/CartItemMapperTest.java |
验证 Entity 映射、用户隔离和唯一约束 |
5. 购物车模块全链路图
加入购物车链路:
查询购物车链路:
购物车数据组装关系:
6. 逐段读源码
6.1 Controller:购物车是 /api/cart/items
CartController 的类定义:
java
@RestController
@RequestMapping("/api/cart/items")
@Tag(name = "购物车")
public class CartController {
加入购物车接口:
java
@PostMapping
@Operation(summary = "加入购物车")
public ResponseEntity<ApiResponse<CartItemResponse>> addItem(
@Valid @RequestBody CartAddRequest request,
HttpServletRequest servletRequest
) {
CartItemResponse response = cartFacade.addItem(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
查询我的购物车接口:
java
@GetMapping
@Operation(summary = "查询我的购物车")
public ApiResponse<CartResponse> queryMyCart(HttpServletRequest servletRequest) {
return ApiResponse.success(
cartFacade.queryMyCart(),
TraceIdContext.get(servletRequest)
);
}
两个接口路径一样,HTTP 方法不同。POST /api/cart/items 是新增购物车行,成功返回 201 Created;GET /api/cart/items 是查询当前登录用户购物车,返回 CartResponse。Controller 不接收 userId,这不是遗漏,而是刻意设计:当前用户从登录态取。
6.2 Request:CartAddRequest 为什么只有两个字段
CartAddRequest 定义:
java
public class CartAddRequest {
@NotNull(message = "SKU ID 不能为空")
private Long skuId;
@NotNull(message = "商品数量不能为空")
@Min(value = 1, message = "商品数量不能小于 1")
@Max(value = 99, message = "单个 SKU 最多加入 99 件")
private Integer quantity;
}
它只有 skuId 和 quantity。没有 userId,因为 userId 必须来自当前登录上下文;没有 price,因为价格来自 SKU 当前数据;没有 productTitle,因为标题来自商品当前数据;没有 checked,因为新增购物车时服务端默认勾选;没有 lineAmount,因为小计由服务端计算。
前端侧示意代码:
ts
// 前端侧示意代码:加入购物车只提交 skuId 和 quantity
interface CartAddPayload {
skuId: number
quantity: number
}
async function addToCart(payload: CartAddPayload) {
return request.post('/api/cart/items', payload)
}
如果前端偷偷传:
json
{"skuId":1,"quantity":1,"userId":2,"price":1}
当前后端也不会把 userId 和 price 当成可信字段。测试 shouldAddSaleableSkuForCurrentUserAndIgnoreSubmittedUserId 就验证了:请求体传 userId=2,最终保存的仍然是当前登录用户 xiaoming 对应的 userId。
6.3 Entity:购物车表保存什么
CartItemEntity 对应 mall_cart_item:
java
@Data
@TableName("mall_cart_item")
public class CartItemEntity {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("user_id")
private Long userId;
@TableField("sku_id")
private Long skuId;
private Integer quantity;
private Integer checked;
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
private LocalDateTime updatedAt;
}
购物车表只保存最小必要事实:哪个用户、哪个 SKU、数量、是否勾选、时间。它不保存商品标题,不保存当前价格,不保存库存。这样做的优点是不会让购物车数据和商品数据长期不一致。商品改名后,再查询购物车可以看到新标题;SKU 价格变化后,再查询购物车可以看到当前价格。
当然,这也带来一个结果:购物车金额不是历史承诺,而是当前估算。真正订单会保存商品标题、SKU 编码、规格、单价、数量等快照字段,这是后续订单章节要讲的内容。
6.4 SQL 表结构:唯一索引和约束
sql/01_schema.sql 中的购物车表:
sql
CREATE TABLE IF NOT EXISTS mall_cart_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '购物车行 ID',
user_id BIGINT UNSIGNED NOT NULL COMMENT '所属用户 ID',
sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID',
quantity INT UNSIGNED NOT NULL COMMENT '购买数量,当前限制 1 到 99',
checked TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '是否勾选:0 否,1 是',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_mall_cart_item_user_sku (user_id, sku_id),
KEY idx_mall_cart_item_user_updated_at (user_id, updated_at),
CONSTRAINT chk_mall_cart_item_quantity CHECK (quantity BETWEEN 1 AND 99),
CONSTRAINT chk_mall_cart_item_checked CHECK (checked IN (0, 1))
)
uk_mall_cart_item_user_sku 是关键:同一个用户同一个 SKU 只能有一行。前端可以在本地判断重复,后端也会先查 existsByUserIdAndSkuId,但并发下最终靠数据库唯一索引兜底。两个请求同时加入同一个 SKU 时,可能都先查到不存在,但插入时只能一个成功,另一个会触发数据库唯一约束异常。
idx_mall_cart_item_user_updated_at 用于按用户查询购物车并按更新时间排序。购物车查询一定带 user_id,所以这个索引符合查询模式。quantity 和 checked 还有数据库 check 约束,和 DTO 校验形成双层保护。
6.5 加入购物车业务:CartFacade.addItem
核心代码:
java
@Override
public CartItemResponse addItem(CartAddRequest request) {
Long userId = currentUserService.requireCurrentUserId();
SkuEntity sku = skuDbService.requireById(request.getSkuId());
ProductEntity product = productDbService.requireById(sku.getProductId());
requireSaleableProductAndSku(product, sku);
if (request.getQuantity() > sku.getAvailableStock()) {
throw conflict(ApiCode.CART_QUANTITY_EXCEEDS_STOCK);
}
if (cartItemDbService.existsByUserIdAndSkuId(userId, sku.getId())) {
throw conflict(ApiCode.CART_ITEM_ALREADY_EXISTS);
}
LocalDateTime now = LocalDateTime.now();
CartItemEntity entity = new CartItemEntity();
entity.setUserId(userId);
entity.setSkuId(sku.getId());
entity.setQuantity(request.getQuantity());
entity.setChecked(CHECKED);
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
try {
cartItemDbService.save(entity);
} catch (DataIntegrityViolationException exception) {
throw conflict(ApiCode.CART_ITEM_ALREADY_EXISTS);
}
return assembleItem(entity, sku, product);
}
第一行 currentUserService.requireCurrentUserId() 是安全边界。购物车属于当前登录用户,不能让前端传用户 ID。
第二行 skuDbService.requireById(request.getSkuId()) 确认 SKU 存在。不存在时返回 SKU_NOT_FOUND。第三行根据 SKU 的 productId 查商品。这里没有只相信前端传来的商品信息,因为前端传来的标题、状态、库存都可能是旧的或伪造的。
requireSaleableProductAndSku(product, sku) 会检查商品已上架、价格大于 0、可售库存大于 0。这个校验保证不可售 SKU 不能加入购物车。
request.getQuantity() > sku.getAvailableStock() 检查本次加入数量是否超过当前可售库存。注意这还不是下单扣库存,只是加入购物车前的合理性校验。加入购物车不会减少库存,真正锁库存发生在订单创建阶段。
existsByUserIdAndSkuId 防止重复加入。同一个用户同一个 SKU 已经有一行时,当前项目选择返回 CART_ITEM_ALREADY_EXISTS,提示前端去修改数量,而不是自动合并数量。不同项目可能会选择"重复加入则数量增加",但本项目的规则是"重复加入被拒绝"。
保存时 catch DataIntegrityViolationException,是为了处理并发重复加入。这个 catch 不是多余的。后端写并发安全时要记住:查询判断只是提前提示,数据库约束才是最终兜底。
6.6 可售校验:requireSaleableProductAndSku
java
private void requireSaleableProductAndSku(ProductEntity product, SkuEntity sku) {
boolean productOnSale = ProductStatus.ON_SALE.name().equals(product.getStatusCode());
boolean positivePrice = sku.getSalePrice() != null && sku.getSalePrice().compareTo(BigDecimal.ZERO) > 0;
boolean hasStock = sku.getAvailableStock() != null && sku.getAvailableStock() > 0;
if (!productOnSale || !positivePrice || !hasStock) {
throw conflict(ApiCode.CART_SKU_NOT_SALEABLE);
}
}
这段校验把商品和 SKU 两层条件合起来:商品必须 ON_SALE,SKU 价格必须大于 0,SKU 可售库存必须大于 0。只有商品上架但库存为 0,也不能加入购物车;只有 SKU 有库存但商品是草稿,也不能加入购物车。
前端详情页通常也会做类似判断:商品下架隐藏按钮、库存 0 禁用按钮、价格异常不显示购买区。但后端仍然要重新判断,因为前端状态可能过期,也可能被绕过。
6.7 查询购物车:queryMyCart
查询购物车从当前用户开始:
java
Long userId = currentUserService.requireCurrentUserId();
List<CartItemEntity> cartItems = cartItemDbService.listByUserId(userId);
if (cartItems.isEmpty()) {
return CartResponse.builder()
.items(Collections.emptyList())
.totalQuantity(0)
.estimatedTotal(zeroMoney())
.build();
}
空购物车返回空列表、数量 0、金额 0.00。前端不需要自己判断 data 是否为空对象,响应结构稳定。
然后批量查询 SKU 和商品:
java
Set<Long> skuIds = cartItems.stream()
.map(CartItemEntity::getSkuId)
.collect(Collectors.toSet());
Map<Long, SkuEntity> skuMap = skuDbService.listExistingByIds(skuIds).stream()
.collect(Collectors.toMap(SkuEntity::getId, Function.identity()));
Set<Long> productIds = skuMap.values().stream()
.map(SkuEntity::getProductId)
.collect(Collectors.toSet());
Map<Long, ProductEntity> productMap = productDbService.listExistingByIds(productIds).stream()
.collect(Collectors.toMap(ProductEntity::getId, Function.identity()));
这就是避免 N+1 查询。假设购物车有 20 行,如果每一行都单独查一次 SKU,再单独查一次商品,就可能产生 40 次查询。当前代码先收集所有 SKU ID,一次查出 SKU;再收集所有商品 ID,一次查出商品。前端同学可以类比成:不要在列表每一项组件里各自发请求,而是在父组件批量请求数据后统一分发。
6.8 响应组装:assembleItem
assembleItem 会把购物车行、SKU、商品组装成前端需要的字段:
java
private CartItemResponse assembleItem(CartItemEntity item, SkuEntity sku, ProductEntity product) {
BigDecimal unitPrice = sku == null ? null : sku.getSalePrice();
BigDecimal lineAmount = unitPrice == null
? null
: unitPrice.multiply(BigDecimal.valueOf(item.getQuantity())).setScale(MONEY_SCALE, RoundingMode.HALF_UP);
boolean saleable = sku != null
&& product != null
&& ProductStatus.ON_SALE.name().equals(product.getStatusCode())
&& unitPrice != null
&& unitPrice.compareTo(BigDecimal.ZERO) > 0
&& sku.getAvailableStock() != null
&& sku.getAvailableStock() >= item.getQuantity();
return CartItemResponse.builder()
.id(item.getId())
.skuId(item.getSkuId())
.productId(sku == null ? null : sku.getProductId())
.productTitle(product == null ? null : product.getTitle())
.skuCode(sku == null ? null : sku.getSkuCode())
.specText(sku == null ? null : sku.getSpecText())
.currentUnitPrice(unitPrice)
.quantity(item.getQuantity())
.availableStock(sku == null ? null : sku.getAvailableStock())
.checked(Integer.valueOf(CHECKED).equals(item.getChecked()))
.saleable(saleable)
.lineAmount(lineAmount)
.updatedAt(item.getUpdatedAt())
.build();
}
这里的 saleable 比加入购物车时更细:它要求 SKU 存在、商品存在、商品仍然上架、价格有效、库存不少于购物车数量。也就是说,加入购物车时可售,不代表以后永远可售。每次查询都重新计算,前端才能得到最新提示。
如果 SKU 被删除,sku == null,响应仍然保留 skuId,但 productId、currentUnitPrice 等字段为空,saleable=false。这就是前面说的"保留行但标记不可售"。它给前端留下展示错误状态的机会。
6.9 汇总计算:totalQuantity 和 estimatedTotal
java
int totalQuantity = responses.stream()
.mapToInt(CartItemResponse::getQuantity)
.sum();
BigDecimal estimatedTotal = responses.stream()
.filter(item -> Boolean.TRUE.equals(item.getChecked()))
.filter(item -> Boolean.TRUE.equals(item.getSaleable()))
.map(CartItemResponse::getLineAmount)
.reduce(zeroMoney(), BigDecimal::add);
totalQuantity 是全部返回明细的数量合计,不管是否勾选、是否可售。这适合前端显示购物车角标:用户购物车里一共有多少件。
estimatedTotal 只统计已勾选且可售的行。未勾选行不参与结算,不应该计入合计;不可售行虽然还在购物车里,但不能下单,也不应该计入预估总额。测试 shouldExcludeUncheckedRowsFromEstimatedTotalAndIsolateOtherUser 验证了 2 号用户购物车有一行未勾选商品,totalQuantity=1,但 estimatedTotal=0.0。
6.10 DbService:所有用户资源查询都带 userId
CartItemDbService 的注释非常关键:
java
/**
* 购物车数据库访问 Service。所有用户资源查询都必须带 userId 条件。
*/
常用方法:
java
public List<CartItemEntity> listByUserId(Long userId) {
return lambdaQuery()
.eq(CartItemEntity::getUserId, userId)
.orderByDesc(CartItemEntity::getUpdatedAt)
.orderByDesc(CartItemEntity::getId)
.list();
}
public CartItemEntity getByUserIdAndSkuId(Long userId, Long skuId) {
return lambdaQuery()
.eq(CartItemEntity::getUserId, userId)
.eq(CartItemEntity::getSkuId, skuId)
.one();
}
购物车这种用户私有资源,绝对不能只按 skuId 或 id 查。所有查询都要带 userId 条件,防止读取或修改其他用户的数据。这是后端权限隔离的基本功。
7. 本地运行 / curl 验证
7.1 登录普通用户
种子用户中可以使用 xiaoming / Mall123456:
bash
curl -sS -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"xiaoming","password":"Mall123456"}'
复制 accessToken:
bash
export USER_TOKEN='<复制 accessToken>'
7.2 未登录访问购物车
bash
curl -i -X POST http://localhost:8080/api/cart/items \
-H 'Content-Type: application/json' \
-d '{"skuId":1,"quantity":1}'
预期返回 401 和 UNAUTHORIZED。购物车是登录用户资源,未登录不知道"我的购物车"是谁的。
7.3 查询我的购物车
bash
curl -i http://localhost:8080/api/cart/items \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'X-Trace-Id: cart-query-001'
根据种子数据,xiaoming 对应用户 1,购物车里有 1 号 SKU,数量 2,勾选状态为 true。响应里会组装商品标题 九成新 iPhone 15、SKU 编码 IPHONE15-BLACK-128G、规格 黑色 / 128G、当前单价 4599、可售库存 10、行金额 9198、预估总额 9198。
7.4 加入购物车并验证 userId 不可信
找一个还没有在当前用户购物车里的已上架 SKU,然后:
bash
curl -i -X POST http://localhost:8080/api/cart/items \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: cart-add-001' \
-d '{"skuId":4,"quantity":1,"userId":999}'
即使请求体里有 userId=999,后端也会忽略。真实保存的 userId 来自 token。你可以用数据库查询验证:
sql
SELECT id, user_id, sku_id, quantity, checked
FROM mall_cart_item
WHERE sku_id = 4;
7.5 验证数量范围
bash
curl -i -X POST http://localhost:8080/api/cart/items \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"skuId":1,"quantity":0}'
预期返回 VALIDATION_ERROR。数量 100 也会返回 VALIDATION_ERROR,因为 CartAddRequest 限制单个 SKU 最多加入 99 件。
7.6 验证重复加入
如果用户 1 的购物车已经有 1 号 SKU:
bash
curl -i -X POST http://localhost:8080/api/cart/items \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"skuId":1,"quantity":1}'
预期返回 CART_ITEM_ALREADY_EXISTS。当前项目规则是重复加入被拒绝,前端应引导用户去修改数量,而不是无声新增一行重复数据。
7.7 验证库存不足
如果某个 SKU 当前可售库存是 2,你请求加入 3 件:
bash
curl -i -X POST http://localhost:8080/api/cart/items \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"skuId":<库存为2的skuId>,"quantity":3}'
预期返回 CART_QUANTITY_EXCEEDS_STOCK。这只是加入购物车阶段的保护。即使加入成功,创建订单时仍然会重新校验库存。
7.8 验证商品下架后购物车行保留但不可售
把 1 号商品下架后,再查询用户 1 的购物车。你会看到购物车行仍然存在,但 saleable=false,estimatedTotal=0。这说明购物车查询不会因为商品不可售就直接删除用户数据,而是把当前状态告诉前端。
8. 常见错误
8.1 在请求体里接收 userId
购物车属于当前登录用户,userId 必须来自登录态。前端传来的 userId 不可信。当前项目即使请求体有 userId,也不会用于保存购物车。
8.2 购物车只保存 productId
用户买的是具体规格,不是抽象商品。购物车必须保存 skuId,否则无法确定价格、规格和库存。
8.3 把前端价格写入购物车
价格来自 SKU 当前数据,不能相信前端提交。购物车响应里的金额只是查询时计算,订单金额还要在下单时重新计算并保存快照。
8.4 加入购物车时不查商品状态
SKU 存在不代表可以加入购物车。商品必须已上架,价格必须有效,库存必须大于 0。草稿或下架商品的 SKU 不能加入购物车。
8.5 只靠前端防重复加入
前端本地可以防重复点击或重复添加,但并发下必须靠后端检查和数据库唯一索引 uk_mall_cart_item_user_sku 兜底。
8.6 查询购物车时写 N+1 查询
不要循环每个购物车行单独查 SKU 和商品。当前项目先批量查 SKU,再批量查商品,用 Map 组装响应。
8.7 商品不可售时直接删除购物车行
直接删除会让用户困惑。当前项目保留购物车行,标记 saleable=false,让前端展示"商品已下架"或"库存不足"。
8.8 把 estimatedTotal 当订单金额
estimatedTotal 只是购物车页展示金额。创建订单时必须重新查价格、锁库存、生成订单快照。不要让前端把 estimatedTotal 当成成交金额提交。
8.9 查询或删除购物车不带 userId
所有购物车资源访问都要带 userId 条件。只按购物车行 id 操作,会带来越权风险。
8.10 忽略 checked
购物车里有些商品可能未勾选,不应该参与预估总额和下单。当前项目的 estimatedTotal 只统计 checked 且 saleable 的行。
8.9 为什么本项目当前先实现"加入"和"查询"
你可能会问:真实电商购物车通常还有修改数量、勾选 / 取消勾选、删除单项、清空失效商品等能力,为什么本项目这一章重点只讲 POST /api/cart/items 和 GET /api/cart/items?原因不是这些能力不重要,而是学习后端要先抓住主链路。购物车模块最核心的后端思维有三个:第一,当前用户从登录态来,不能由前端请求体指定;第二,购物车行保存 sku_id 和数量,查询时再动态组装商品、规格、价格、库存;第三,同一个用户同一个 SKU 只能有一行,既要业务层提前判断,也要数据库唯一索引兜底。只要这三点掌握了,后续扩展修改数量、勾选和删除,本质都是在同一个用户隔离边界内,对同一张 mall_cart_item 表做更细的状态变更。
从前端角度类比,你可以把当前实现理解成先完成 Redux / Pinia 里最重要的两个 action:addItem 和 loadCart。没有这两个 action,页面无法建立购物车状态;有了这两个 action,后续的 updateQuantity、toggleChecked、removeItem 都只是围绕已有状态树继续增加 mutation。但后端和前端最大的区别是:前端 mutation 只影响当前浏览器内存,后端 mutation 会影响数据库里的长期数据,并且必须防止用户越权修改别人的数据。因此每新增一个购物车写接口,都必须重复检查 user_id 条件,而不是只根据 cart_item.id 更新。
如果未来要实现"修改数量",接口可能是 PATCH /api/cart/items/{id},但后端不能只写 UPDATE mall_cart_item SET quantity = ? WHERE id = ?。正确方向应该是:先从 CurrentUserService.requireCurrentUserId() 拿到当前用户,再用 id + user_id 找购物车行,然后查当前 SKU,确认 SKU 仍存在、商品仍上架、价格仍有效,并且新数量没有超过当前 available_stock。最后更新 quantity 和 updated_at。这样做看起来麻烦,但它能保证用户 A 即使猜到用户 B 的购物车行 ID,也无法修改用户 B 的数量。
如果未来要实现"勾选 / 取消勾选",也不能把它当成纯前端 UI 状态。购物车勾选会影响下一章订单创建时读取哪些行。本项目的 CartItemDbService.listCheckedByUserId 已经提前为订单链路准备好了查询方法:只查当前用户,并且只查 checked=1 的行。也就是说,checked 是跨请求保存的业务状态,不是页面临时变量。前端可以用 checkbox 展示它,但后端必须把它落库,否则用户刷新页面、换设备登录、或者直接进入订单确认页时,勾选状态就会丢失。
如果未来要实现"删除购物车项",安全边界仍然相同:删除 SQL 必须带 user_id。本项目在 CartItemMapper.removeCheckedByUserIdAndIds 中已经示范了这种写法:删除本次下单使用的购物车项时,SQL 同时限制 user_id、checked=1 和 id IN (...)。这段代码看似服务于订单模块,实际上也给购物车删除接口提供了非常好的参考:所有用户资源写操作都应该让"当前用户条件"进入最终 SQL,而不是只在 Controller 层相信前端传来的 ID。
8.10 购物车接口和公开商品接口的权限差异
商品详情接口通常可以公开访问,因为它展示的是平台希望所有用户看到的公开信息;购物车接口必须登录后访问,因为它读写的是某个用户的私人资源。前端开发时,你可以把商品详情接口理解成静态资源或公共页面数据,把购物车接口理解成个人中心接口。两者都可能返回商品标题、SKU、价格,但权限语义完全不同。
这个区别会影响前端请求封装。公开商品接口没有 token 时也应该能正常加载,购物车接口没有 token 时应该跳登录页或提示登录。后端代码则用 CurrentUserService.requireCurrentUserId() 表达这个边界:只要当前请求没有合法 JWT,就拿不到用户 ID,购物车业务也就无法继续执行。注意,这比"请求体带 userId"安全得多,因为 JWT 是后端签发并校验的身份凭证,而请求体字段只是用户自己填写的数据。
也因此,调试购物车接口时不要先怀疑 MyBatis-Plus、Redis 或 SQL。第一步应该看请求头里是否带了 Authorization: Bearer <token>,第二步看 token 对应的是哪个用户,第三步再看数据库里这个用户是否有对应购物车行。很多前端转后端同学刚开始调试时,会直接在数据库查 mall_cart_item,发现有数据却接口返回空,然后误以为查询逻辑错了。实际原因往往是:数据库里有的是用户 1 的购物车,而当前 token 是用户 2,后端按 user_id=2 查询当然返回空。这不是 bug,而是正确的用户隔离。
8.11 前端页面应该如何展示 saleable=false
CartItemResponse.saleable 是购物车页非常重要的字段。它告诉前端:这行记录还在购物车里,但当前已经不适合进入结算。导致 saleable=false 的原因可能是商品下架、SKU 删除、价格无效、库存不足。后端没有直接删除这行,是为了让用户知道"为什么原来加入的商品现在不能买了"。这和前端状态管理里的"保留错误态"很像:请求失败时你不会立刻把整个模块状态清空,而是会显示错误提示,让用户知道下一步怎么处理。
前端侧示意代码可以这样理解:
ts
// 前端侧示意代码:根据后端 saleable 字段渲染,不代表仓库中存在真实前端源码。
function renderCartRow(item: CartItem) {
const disabled = !item.saleable
return {
title: item.productTitle ?? '商品信息已变化',
quantity: item.quantity,
priceText: item.currentUnitPrice ?? '--',
disabled,
reason: disabled ? '当前不可结算,请调整或删除' : '',
}
}
注意这里前端只是"展示"和"禁用结算",不要自己根据标题、价格、库存再推导一套最终交易规则。真正能不能下单,下一章订单创建时后端还会重新校验。购物车页的 saleable=false 是给用户体验服务的提示,订单创建的校验才是交易一致性的最终防线。这个分层非常重要:前端负责让用户少犯错,后端负责保证系统不会错。
8.12 用户隔离排查清单
购物车是学习"多用户资源隔离"的绝佳模块。以后你做地址、收藏、优惠券、订单列表、个人资料,都会遇到同样的问题:资源看起来只有一个 id,但真正访问时必须加上当前用户身份。可以把下面这张清单贴在你的后端调试笔记里。

对购物车来说,清单可以落到 5 个具体问题:第一,Controller 是否没有接收 userId;第二,Facade 是否调用了 CurrentUserService.requireCurrentUserId();第三,listByUserId、existsByUserIdAndSkuId、listCheckedByUserId 这类方法是否都带用户条件;第四,删除或更新时最终 SQL 是否也带用户条件;第五,测试是否覆盖"用户 A 看不到用户 B 购物车"的场景。本项目测试中已经有 shouldQueryOnlyCurrentUserAndAssembleProductSkuAndAmount、shouldExcludeUncheckedRowsFromEstimatedTotalAndIsolateOtherUser、shouldLetAdminSeeOnlyAdminsOwnEmptyCart 这类用例,它们不是边角测试,而是在保护后端系统最核心的安全边界。
前端工程师转后端时,最容易低估"用户隔离"的重要性。前端页面天然运行在某个用户浏览器里,好像当前用户总是明确的;但后端服务同时接收所有用户的请求,如果 SQL 少一个 user_id 条件,就可能把所有人的数据混在一起。购物车模块把这个问题讲得很清楚:同样是 GET /api/cart/items,不同 JWT 得到的是不同数据;同样是 sku_id=1,不同用户可以各有一条购物车行;同样是删除已勾选项,也只能删除当前用户自己的行。理解这一点,你就真正开始从"页面状态思维"切换到"服务端数据边界思维"了。
9. 本章小练习
练习 1:画出购物车表和商品、SKU 的关系
画出 mall_cart_item、mall_product_sku、mall_product 三张表的关系。说明购物车为什么存 sku_id,不存 product_id。
练习 2:解释 userId 为什么不能由前端提交
阅读 CartAddRequest 和 CartFacade.addItem,说明当前用户 ID 从哪里来。再结合测试 shouldAddSaleableSkuForCurrentUserAndIgnoreSubmittedUserId,解释为什么请求体里的 userId 被忽略。
练习 3:用 curl 验证未登录不能加入购物车
不带 Authorization 请求 POST /api/cart/items,记录 HTTP 状态和业务 code。说明它和普通参数错误有什么区别。
练习 4:验证重复加入
对用户 1 重复加入 1 号 SKU,观察 CART_ITEM_ALREADY_EXISTS。再阅读 mall_cart_item 的唯一索引,解释为什么数据库也能防重复。
练习 5:解释 estimatedTotal 的计算规则
阅读 CartFacade.queryMyCart 中 estimatedTotal 的 stream 计算,说明为什么只统计 checked 且 saleable 的行。
练习 6:设计前端购物车类型
根据 CartItemResponse 和 CartResponse 写一段"前端侧示意代码":
ts
// 前端侧示意代码:根据后端响应定义购物车类型
interface CartItem {
id: number
skuId: number
productId?: number
productTitle?: string
skuCode?: string
specText?: string
currentUnitPrice?: string
quantity: number
availableStock?: number
checked: boolean
saleable: boolean
lineAmount?: string
updatedAt: string
}
interface CartResponse {
items: CartItem[]
totalQuantity: number
estimatedTotal: string
}
练习 7:读测试当业务文档
把 CartControllerTest 中这些测试方法名改写成中文规则:shouldRequireAuthentication、shouldRejectSkuOfDraftProduct、shouldRejectQuantityExceedingAvailableStock、shouldKeepRowButMarkItUnsaleableAfterProductOffShelf、shouldKeepRowWhenSkuWasDeleted。
练习 8:解释为什么购物车不扣库存
结合第 10 篇库存知识说明:加入购物车为什么不应该减少 available_stock?真正扣减或锁定库存应该发生在哪个阶段?
10. 再深入一点:购物车不是订单
购物车和订单很像,都有商品、SKU、数量、金额,但它们的业务性质完全不同。购物车是意向,订单是承诺。购物车可以变化,订单需要快照。购物车里的价格是当前估算,订单里的价格是成交依据。购物车可以保留不可售行提醒用户,订单不能包含不可售商品。购物车不锁库存,订单创建才锁库存。
从前端角度看,购物车页的"合计金额"和订单确认页的"应付金额"可能长得很像,但后端语义不同。购物车合计可以随着商品改价自动变化;订单应付金额一旦生成,就应该保存在订单表和订单项表里,即使商品后来改价,历史订单金额也不能变。
这也是为什么 CartItemResponse 的注释强调:currentUnitPrice 仅用于预估,创建订单时服务端会重新校验并保存价格快照。前端不要把购物车响应里的金额当成可信交易参数。前端提交订单时可以提交幂等键、选择项等必要信息,但价格、库存、商品标题快照必须由后端从数据库重新读取并生成。
11. 排查购物车问题的顺序
购物车问题常见现象包括:加入失败、重复提示、合计金额不对、别人购物车数据出现在我的页面、商品明明在购物车却不能结算。建议按这个顺序排查:

第一步永远先看登录。购物车接口不是公开接口,没登录就没有当前用户。第二步看业务 code:SKU_NOT_FOUND、CART_SKU_NOT_SALEABLE、CART_QUANTITY_EXCEEDS_STOCK、CART_ITEM_ALREADY_EXISTS 的排查方向完全不同。第三步看数据库时一定带 user_id,否则很容易把别人的购物车数据当成自己的。第四步看组装字段,确认商品和 SKU 当前状态是否变化。第五步对照测试,确认你看到的是 bug 还是符合业务规则。
12. 下一章预告:订单创建、事务与库存锁定
本篇讲完购物车之后,下一篇会进入真正的交易链路:订单创建、事务与库存锁定。你会看到后端如何读取当前用户已勾选购物车项,如何再次批量查询 SKU 和商品,如何重新计算订单金额,如何用事务写订单主表和订单项表,如何调用 lockStock 把可售库存转为锁定库存,如何在成功创建订单后删除本次已勾选购物车项。
这会是前端转后端非常关键的一章。前端点击"提交订单"看起来只是一次按钮点击,但后端必须保证多个表要么一起成功,要么一起失败。订单创建不能出现"订单表写了、库存没锁""库存锁了、订单项没写""购物车删了、订单失败"这种半成功状态。事务就是为了解决这种一致性问题。
13. 本篇总结
本篇你需要带走 10 个结论:
- 后端购物车是用户维度的持久化资源,不是浏览器内存数组;
/api/cart/items不接收 userId,当前用户必须从 JWT 登录态获取;- 购物车保存
sku_id,因为用户购买的是具体规格; - 加入购物车请求只允许提交
skuId和quantity,价格、标题、用户、勾选状态都由后端决定或组装; - 加入购物车前要校验 SKU 存在、商品上架、价格有效、库存大于 0、数量不超过当前库存;
- 同用户同 SKU 不能重复加入,后端查询提前提示,数据库唯一索引并发兜底;
- 查询购物车时批量查询 SKU 和商品,避免 N+1;
- 商品下架、库存不足、SKU 删除时,购物车行保留但
saleable=false; totalQuantity统计全部数量,estimatedTotal只统计已勾选且可售的行;- 购物车金额只是预估,订单金额必须在创建订单时重新计算并保存快照。
如果你能解释"为什么请求体传 userId=2 也不会加入 2 号用户购物车",并能说清楚"购物车为什么保留不可售行但不计入 estimatedTotal",说明你已经理解了用户资源隔离和查询时动态组装这两个后端核心能力。下一篇我们会把购物车推进到订单创建,开始学习事务、库存锁定和订单快照。