本篇写给正在从前端转后端的你。你可以把它当成“后台商品管理页”的后端源码走读:管理员点击“新增商品”“上架”“下架”按钮以后,后端到底做了哪些校验、写了哪些表、为什么不能只相信前端按钮状态。本文所有前端代码都只是“前端侧示意代码”,用于类比理解;真实工程以当前仓库
backend/代码为准。
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|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
1. 这篇解决什么问题
前面第 8 篇我们完整读了公开商品详情接口:匿名用户请求 /api/products/{id},后端经过 Security、Controller、Facade、Redis、MySQL、DTO 组装,最后返回统一 JSON。那是一条典型“读链路”。本篇开始进入管理端“写链路”:管理员创建商品草稿,然后把草稿上架,必要时再下架。
写链路比读链路更容易踩坑。读接口写错了,常见结果是页面展示不出来、展示慢、展示旧数据;写接口写错了,可能造成数据库脏数据、越权创建、非法状态流转、缓存和数据库不一致,甚至影响订单和库存。对于前端同学来说,最容易产生的误解是:既然后台页面按钮已经控制了状态,那后端是不是只要按前端传来的 targetStatus 更新数据库就行?答案是否定的。前端按钮只是体验层,真正的业务规则必须落在后端。
本篇要解决 6 个核心问题:
- 商品为什么不是创建后直接上架,而是先成为
DRAFT草稿; DRAFT、ON_SALE、OFF_SHELF三个状态分别代表什么;- 为什么状态流转必须由后端状态机控制,不能只相信前端按钮;
- 管理员创建商品、上架、下架分别经过哪些代码文件;
- 上架前为什么要校验分类启用和至少一个可售 SKU;
- 状态变化后为什么必须删除商品详情缓存。
读完本篇,你应该能独立解释这几个现象:匿名用户为什么看不到 DRAFT 商品;管理员创建商品时即使请求体里没有状态,返回也是 DRAFT;把已经上架的商品再次上架为什么会报 INVALID_PRODUCT_STATUS_TRANSITION;没有可售 SKU 的商品为什么不能上架;商品状态变化成功后为什么要调用 productDetailCacheService.evict(productId)。
2. 用前端知识类比
如果你做过后台管理系统,商品列表页通常会有“新建”“编辑”“上架”“下架”等按钮。前端可能会根据接口返回的 status 控制按钮是否可点击。例如草稿商品显示“上架”,已上架商品显示“下架”,已下架商品显示“重新上架”。这种按钮控制能让用户少犯错,但它不是安全边界。
看一个前端侧示意代码:
// 前端侧示意代码:按钮状态只提升体验,不代表后端可以不校验
function getProductActions(status: 'DRAFT' | 'ON_SALE' | 'OFF_SHELF') {
if (status === 'DRAFT') {
return ['edit', 'publish']
}
if (status === 'ON_SALE') {
return ['view', 'offShelf']
}
return ['edit', 'publishAgain']
}
这段代码能让页面看起来合理,但用户可以绕过页面直接发请求。比如打开 DevTools,或者用 curl、Postman、脚本直接请求:
curl -X PATCH http://localhost:8080/api/admin/products/1/status \
-H 'Authorization: Bearer <admin-token>' \
-H 'Content-Type: application/json' \
-d '{"targetStatus":"ON_SALE"}'
如果 1 号商品已经是 ON_SALE,前端页面本来不会给你“再次上架”按钮,但请求仍然可能被手动构造出来。所以后端必须再次判断:当前状态是什么?目标状态是什么?这条状态转换是否允许?上架前是否满足分类和 SKU 条件?如果不满足,必须拒绝请求。
这就像前端表单校验和后端参数校验的关系。前端可以用 required、maxlength、表单规则来减少错误输入,但后端仍然要用 @NotBlank、@Size、@NotNull 做最终校验。因为前端校验可被绕过,后端才是数据可信边界。商品状态机也是同理:前端按钮可被绕过,后端状态机不能被绕过。
还可以把商品状态理解成前端组件状态,但它比组件状态更严肃。前端组件的 loading、visible、selectedTab 通常只影响当前页面展示;商品的 status_code 写在 MySQL 里,会影响匿名用户能否看到商品、用户能否加入购物车、订单能否创建、缓存是否应该失效。它不是临时 UI state,而是持久化业务 state。
flowchart LR
A[前端按钮状态\n体验层] --> B[HTTP 请求\n可被手动构造]
B --> C[后端参数校验\n字段是否合法]
C --> D[后端状态机\n流转是否合法]
D --> E[业务前置条件\n分类与 SKU]
E --> F[MySQL 持久化\n真实业务状态]
F --> G[缓存失效\n公开详情重新读取]
3. 后端核心概念讲解
3.1 什么是商品生命周期
生命周期就是一个业务对象从创建到结束会经历哪些阶段。商品在本项目里不是只有“存在 / 不存在”两种状态,而是有 3 个明确状态:
| 状态 | 含义 | 匿名用户是否可见 | 管理员常见动作 |
|---|---|---|---|
DRAFT | 草稿,资料可继续维护 | 否 | 编辑、上架 |
ON_SALE | 已上架,可公开查询 | 是 | 下架、查看 |
OFF_SHELF | 已下架,保留历史数据 | 否 | 重新上架、编辑 |
为什么需要 OFF_SHELF,而不是直接删除?因为电商系统里商品可能被订单、购物车、支付记录引用。直接删除会让历史订单找不到商品快照,也会让运营无法追溯。真实系统里经常使用“软删除”或“状态下架”来保留历史数据。本项目用 status_code 表示生命周期状态,公开查询只展示 ON_SALE。
3.2 什么是状态机
状态机可以理解为“状态 + 允许的转换规则”。不是任何状态都能随便跳到任何状态。当前项目里的规则是:
DRAFT -> ON_SALE:草稿可以上架;ON_SALE -> OFF_SHELF:已上架商品可以下架;OFF_SHELF -> ON_SALE:已下架商品可以重新上架;- 其他转换全部拒绝。
例如 ON_SALE -> ON_SALE 被拒绝,因为它没有实际业务意义;DRAFT -> OFF_SHELF 被拒绝,因为草稿本来就没公开,谈不上“下架”;OFF_SHELF -> DRAFT 当前项目也不允许,因为这个流程没有被产品规则定义。后端只实现明确允许的路径,未定义路径默认拒绝,这是状态机设计的重要原则。
stateDiagram-v2
[*] --> DRAFT: 管理员创建商品
DRAFT --> ON_SALE: 上架\n校验分类启用 + 可售 SKU
ON_SALE --> OFF_SHELF: 下架\n公开详情不可见
OFF_SHELF --> ON_SALE: 重新上架\n再次校验分类 + SKU
ON_SALE --> ON_SALE: 拒绝
DRAFT --> OFF_SHELF: 拒绝
OFF_SHELF --> DRAFT: 拒绝
3.3 状态字段为什么放在数据库
前端状态通常存在内存里,刷新页面后需要重新请求接口;后端业务状态必须持久化到数据库。当前项目在 mall_product 表里定义了 status_code 字段,默认值是 DRAFT。这意味着商品状态是长期事实,不会因为服务重启、页面刷新或缓存失效而丢失。
缓存可以存商品详情,但缓存不是事实来源。商品状态真正写入 MySQL 后,Redis 中旧的详情缓存必须删除。下一次公开详情请求如果需要展示商品,就重新从 MySQL 读取最新状态和字段。这个模式和前端缓存很像:如果 Pinia 或 React Query 里缓存了旧详情,后台保存成功后要 invalidate query;后端的 Redis 也需要类似的失效动作。
3.4 管理端权限和公开端权限
公开商品接口在 SecurityConfig 里允许匿名访问,但管理端商品接口路径是 /api/admin/products,属于管理员权限范围。创建商品和变更状态都在 ProductAdminController 中,普通用户和未登录用户不能调用。权限和状态机是两层规则:权限回答“你有没有资格操作”;状态机回答“即使你有资格,这个操作在当前业务状态下是否合法”。
flowchart TD
A["请求 /api/admin/products"] --> B{"是否登录"}
B -- "否" --> B1["401 UNAUTHORIZED"]
B -- "是" --> C{"是否 ADMIN"}
C -- "否" --> C1["403 FORBIDDEN"]
C -- "是" --> D["进入 ProductAdminController"]
D --> E{"参数是否合法"}
E -- "否" --> E1["400 VALIDATION_ERROR"]
E -- "是" --> F["ProductFacade 执行业务规则"]
F --> G{"状态机和前置条件是否通过"}
G -- "否" --> G1["业务错误 code"]
G -- "是" --> H["写入 MySQL 并删除缓存"]
4. 在本项目中对应哪些文件
本篇主要读这些真实文件:
| 文件 | 作用 |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductAdminController.java | 管理员商品接口入口,包含创建商品和变更状态 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/IProductFacade.java | 商品模块对外用例契约,说明创建和状态变更的业务语义 |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java | 编排分类校验、当前用户、状态机、SKU 校验、数据库写入、缓存失效 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductCreateRequest.java | 创建商品请求 DTO,定义分类、标题、描述的校验规则 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductStatusChangeRequest.java | 状态变更请求 DTO,定义目标状态不能为空 |
backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductStatus.java | 商品生命周期枚举:DRAFT、ON_SALE、OFF_SHELF |
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java | 商品数据库服务,提供 save、requireById、公开查询等能力 |
backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java | 校验分类存在、启用或仅存在 |
backend/service/src/main/java/com/example/fullstackmall/service/inventory/service/SkuDbService.java | 上架前检查是否存在价格有效且有库存的 SKU |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java | 商品详情缓存服务,状态变化后调用 evict 删除旧缓存 |
sql/01_schema.sql | mall_product 和 mall_product_sku 表结构 |
sql/02_seed.sql | 演示商品状态和 SKU 数据 |
backend/service/src/test/java/com/example/fullstackmall/service/product/ProductControllerTest.java | 验证创建、权限、状态流转等 HTTP 行为 |
backend/service/src/test/java/com/example/fullstackmall/service/inventory/SkuControllerTest.java | 验证没有可售 SKU 时不能上架 |
你读源码时可以按“入口 -> 契约 -> 业务编排 -> 数据服务 -> 测试证明”的顺序,而不是一上来就看所有类。
5. 商品状态变更全链路图
先看创建商品的链路。管理员提交表单,后端不接收前端传来的状态,而是在 ProductFacade.createProduct 内固定设置为 DRAFT。
sequenceDiagram
participant UI as 后台商品表单\n前端侧示意
participant Sec as Spring Security
participant C as ProductAdminController
participant F as ProductFacade
participant Cat as CategoryDbService
participant User as CurrentUserService
participant DB as MySQL mall_product
participant Cache as ProductDetailCacheService
UI->>Sec: POST /api/admin/products\nAuthorization + JSON
Sec->>Sec: 校验登录和 ADMIN 权限
Sec->>C: 放行到 createProduct
C->>C: @Valid 校验请求体
C->>F: createProduct(request)
F->>Cat: requireEnabledCategory(categoryId)
F->>User: requireCurrentUserId()
F->>F: statusCode = DRAFT\ntrim / normalize 字段
F->>DB: insert mall_product
F->>Cache: evict(product.id)
F-->>C: ProductDetailResponse
C-->>UI: 201 + ApiResponse
再看变更状态的链路。它的关键不是“把数据库字段改成目标状态”,而是先读取当前状态,再验证转换,再根据目标状态执行额外校验。
sequenceDiagram
participant UI as 后台状态按钮\n前端侧示意
participant C as ProductAdminController
participant F as ProductFacade
participant P as ProductDbService
participant Cat as CategoryDbService
participant Sku as SkuDbService
participant DB as MySQL mall_product
participant Cache as ProductDetailCacheService
UI->>C: PATCH /api/admin/products/{id}/status\n{targetStatus}
C->>F: changeStatus(id, request)
F->>P: requireById(productId)
P-->>F: ProductEntity(currentStatus)
F->>F: validateTransition(current, target)
alt targetStatus 是 ON_SALE
F->>Cat: requireEnabledCategory(categoryId)
F->>Sku: requireSaleableSku(productId)
else targetStatus 不是 ON_SALE
F->>Cat: requireCategory(categoryId)
end
F->>DB: update status_code + updated_at
F->>Cache: evict(productId)
F-->>C: ProductDetailResponse
C-->>UI: ApiResponse
6. 逐段读源码
6.1 管理端入口:ProductAdminController
管理端商品 Controller 的路径是:
@RestController
@RequestMapping("/api/admin/products")
@Tag(name = "管理员商品管理")
public class ProductAdminController {
这说明它不是公开商品接口,而是管理后台接口。第 8 篇读过公开接口 /api/products/{id},本篇读的是 /api/admin/products。路径上多了 /admin,权限语义完全不同。
创建商品接口:
@PostMapping
@Operation(summary = "创建商品草稿")
public ResponseEntity<ApiResponse<ProductDetailResponse>> createProduct(
@Valid @RequestBody ProductCreateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(response, TraceIdContext.get(servletRequest)));
}
这里有 4 个点:第一,HTTP 方法是 POST,代表创建资源;第二,请求体用 @RequestBody 绑定到 ProductCreateRequest;第三,@Valid 会触发后端参数校验;第四,创建成功返回 201 Created,不是普通 200 OK。前端同学写 Axios 时要知道,201 也是成功响应,不要只把 200 当成功。
状态变更接口:
@PatchMapping("/{id}/status")
@Operation(summary = "变更商品状态")
public ApiResponse<ProductDetailResponse> changeStatus(
@PathVariable Long id,
@Valid @RequestBody ProductStatusChangeRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.changeStatus(id, request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
这里使用 PATCH,表示局部更新商品资源的状态字段。路径里的 {id} 表示商品 ID,请求体里的 targetStatus 表示想变成什么状态。注意 Controller 仍然没有自己判断状态机,它只负责接参数、调用 Facade、包装响应。业务规则集中在 ProductFacade。
6.2 创建请求 DTO:ProductCreateRequest
创建商品请求只允许前端传 3 个字段:categoryId、title、description。
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductCreateRequest {
@NotNull(message = "商品分类不能为空")
@Positive(message = "商品分类 ID 必须大于 0")
private Long categoryId;
@NotBlank(message = "商品标题不能为空")
@Size(max = 100, message = "商品标题不能超过 100 个字符")
private String title;
@Size(max = 2000, message = "商品描述不能超过 2000 个字符")
private String description;
}
这里故意没有 status、createdBy、createdAt、updatedAt。这就是后端契约设计。前端创建商品时不能决定初始状态,也不能决定创建人,更不能自己传创建时间。状态和创建人属于服务端可信字段,必须由后端生成。
前端侧示意请求类型可以这样理解:
// 前端侧示意代码:创建商品时只提交后端允许的字段
interface ProductCreatePayload {
categoryId: number
title: string
description?: string | null
}
如果前端偷偷加一个 status: 'ON_SALE',当前后端也不会把它用于创建状态。因为 Java DTO 里没有这个字段,ProductFacade.createProduct 又会固定设置 DRAFT。这就是“请求契约”和“服务端生成字段”的边界。
6.3 状态枚举:ProductStatus
商品状态定义在 contract 模块中:
public enum ProductStatus {
/** 草稿:只能由管理员维护,匿名用户不可见。 */
DRAFT,
/** 已上架:匿名用户可以查询,且上架时必须存在可售 SKU。 */
ON_SALE,
/** 已下架:保留历史数据,但匿名用户不可见。 */
OFF_SHELF
}
为什么放在 contract 模块?因为状态枚举不仅服务端内部用,Request、Response 也会用到。ProductStatusChangeRequest 接收目标状态,ProductDetailResponse 返回当前状态,前端也会根据这些字符串展示标签和按钮。它是 API 契约的一部分。
前端侧示意类型可以写成:
// 前端侧示意代码:根据后端 enum 建立联合类型
export type ProductStatus = 'DRAFT' | 'ON_SALE' | 'OFF_SHELF'
但要注意:前端类型只是帮助你写代码时有提示,不能替代后端校验。后端枚举反序列化和 @NotNull 能保证 targetStatus 是合法枚举且不为空,状态机能保证这个目标状态在当前商品状态下允许到达。
6.4 创建商品业务:createProduct
ProductFacade.createProduct 是创建商品的核心:
@Override
public ProductDetailResponse createProduct(ProductCreateRequest request) {
CategoryEntity category = categoryDbService.requireEnabledCategory(request.getCategoryId());
LocalDateTime now = LocalDateTime.now();
ProductEntity product = new ProductEntity();
product.setCategoryId(category.getId());
product.setTitle(request.getTitle().trim());
product.setDescription(normalizeOptionalText(request.getDescription()));
// 后台新建商品只能从草稿开始,不能由前端直接伪造为已上架。
product.setStatusCode(ProductStatus.DRAFT.name());
product.setCreatedBy(currentUserService.requireCurrentUserId());
product.setCreatedAt(now);
product.setUpdatedAt(now);
productDbService.save(product);
// 先写 MySQL,再删除可能由“提前猜 ID”产生的空值缓存。
productDetailCacheService.evict(product.getId());
return toDetailResponse(product, category);
}
逐行拆解:
第一行 requireEnabledCategory 说明创建商品时分类必须存在且启用。前端下拉框可以只展示启用分类,但后端仍然要查数据库确认,因为前端传来的 categoryId 可以被篡改。用户如果传一个禁用分类 ID,后端应返回 CATEGORY_NOT_FOUND,而不是把商品创建到不可用分类下。
LocalDateTime.now() 生成服务端时间。不要让前端传 createdAt。前端机器时间可能不准,也可能被恶意修改。后端统一生成时间,才能保证排序、审计和数据一致。
product.setTitle(request.getTitle().trim()) 表示后端会去掉标题首尾空格。测试里创建 " MacBook Air M3 ",返回和数据库保存的标题都是 MacBook Air M3。这类规范化逻辑放后端很重要,因为不同前端入口可能处理不一致。
normalizeOptionalText(request.getDescription()) 会把可选描述中的纯空白处理成 null。这比原样保存空字符串更清晰:没有描述就是没有描述,不要让数据库里出现大量无意义空白。
最关键的是 product.setStatusCode(ProductStatus.DRAFT.name())。创建商品只能创建草稿,不能直接上架。即使前端页面上设计了“一键创建并上架”,后端也应该把它拆成两个明确动作:先创建草稿,再走上架状态机。这样每一步都有清晰校验和测试。
currentUserService.requireCurrentUserId() 从认证上下文里拿当前管理员 ID。创建人不能由前端提交。否则普通用户可以伪造 createdBy=5 冒充管理员。
productDbService.save(product) 写入 MySQL。写成功后调用 productDetailCacheService.evict(product.getId())。注释里提到“提前猜 ID”的空值缓存:如果有人在商品创建前请求过 /api/products/{id},Redis 里可能缓存了这个 ID 不存在的短 TTL 空值。创建成功后删除缓存,能避免新商品因为旧空值缓存继续被公开详情判断为不存在。
6.5 状态变更请求:ProductStatusChangeRequest
状态变更请求非常小:
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductStatusChangeRequest {
@NotNull(message = "目标状态不能为空")
private ProductStatus targetStatus;
}
小不代表简单。这个 DTO 只表达前端“想去哪里”,不表达“能不能去”。能不能去由 ProductFacade.changeStatus 读取当前状态后判断。前端侧示意代码可以是:
// 前端侧示意代码:状态按钮提交目标状态
async function changeProductStatus(id: number, targetStatus: ProductStatus) {
return request.patch(`/api/admin/products/${id}/status`, { targetStatus })
}
这里的 targetStatus 仍然是不可信输入。后端会把它当成“请求意图”,而不是“最终事实”。最终事实必须经过状态机、分类、SKU 等规则确认后才能写入 MySQL。
6.6 状态变更业务:changeStatus
核心代码如下:
@Override
public ProductDetailResponse changeStatus(Long productId, ProductStatusChangeRequest request) {
ProductEntity product = productDbService.requireById(productId);
ProductStatus currentStatus = ProductStatus.valueOf(product.getStatusCode());
ProductStatus targetStatus = request.getTargetStatus();
// 状态机由后端控制,不能只相信前端传入的目标状态。
validateTransition(currentStatus, targetStatus);
CategoryEntity category = targetStatus == ProductStatus.ON_SALE
? categoryDbService.requireEnabledCategory(product.getCategoryId())
: categoryDbService.requireCategory(product.getCategoryId());
if (targetStatus == ProductStatus.ON_SALE) {
// 上架前必须至少存在一个价格有效且有可售库存的 SKU。
skuDbService.requireSaleableSku(productId);
}
product.setStatusCode(targetStatus.name());
product.setUpdatedAt(LocalDateTime.now());
productDbService.updateById(product);
// 状态变化会影响公开可见性,数据库成功后必须让旧详情缓存失效。
productDetailCacheService.evict(productId);
return toDetailResponse(product, category);
}
第一步 productDbService.requireById(productId) 先从数据库读取商品。如果商品不存在,直接抛业务异常。后端必须先知道当前状态,才能判断状态流转是否合法。
第二步把数据库里的字符串 statusCode 转成枚举 ProductStatus。数据库字段是 VARCHAR(20),Java 业务里用 enum 更安全。字符串容易写错,枚举能让编译器帮助发现一部分问题。
第三步拿到请求目标状态 request.getTargetStatus()。然后立刻调用 validateTransition(currentStatus, targetStatus)。这是状态机的核心。注意顺序:不是先 update,再检查;也不是只看目标状态是否属于枚举;而是用“当前状态 + 目标状态”判断这一步是否允许。
第四步根据目标状态校验分类。如果目标是 ON_SALE,必须使用 requireEnabledCategory,因为公开商品不能挂在停用分类下。如果目标不是上架,例如下架到 OFF_SHELF,则只需要 requireCategory。这说明同一个商品、同一个分类,在不同业务动作下校验强度可能不同。上架面向用户公开,所以更严格;下架是收回公开可见性,所以只要求分类记录还存在。
第五步,如果目标状态是 ON_SALE,调用 skuDbService.requireSaleableSku(productId)。商品只有 SPU 信息还不够,必须至少有一个价格大于 0 且可售库存大于 0 的 SKU,才真正能卖。否则用户看到商品详情后无法购买,或者订单创建时才发现无库存,体验和数据都会混乱。
第六步才是真正更新数据库:设置 statusCode,刷新 updatedAt,调用 productDbService.updateById(product)。这一步必须在所有前置校验之后。后端写操作的基本原则是:先校验,再写入;写入成功后,再处理缓存失效。
第七步 productDetailCacheService.evict(productId) 删除商品详情缓存。状态变化会影响公开可见性:DRAFT -> ON_SALE 后原本 404 的详情可能应该可见;ON_SALE -> OFF_SHELF 后原本可见的详情应该不可见。如果不删缓存,匿名用户可能继续看到已下架商品,或者继续拿到空值缓存看不到刚上架商品。
6.7 状态机实现:validateTransition
状态机代码很短:
private void validateTransition(ProductStatus currentStatus, ProductStatus targetStatus) {
boolean allowed = (currentStatus == ProductStatus.DRAFT && targetStatus == ProductStatus.ON_SALE)
|| (currentStatus == ProductStatus.ON_SALE && targetStatus == ProductStatus.OFF_SHELF)
|| (currentStatus == ProductStatus.OFF_SHELF && targetStatus == ProductStatus.ON_SALE);
if (!allowed) {
throw new BusinessException(
ApiCode.INVALID_PRODUCT_STATUS_TRANSITION,
"商品状态不能从 " + currentStatus + " 变更为 " + targetStatus
);
}
}
它没有写成很多嵌套 if,而是先算出 allowed。只要不是这 3 条白名单转换,就抛 INVALID_PRODUCT_STATUS_TRANSITION。这是一种安全的写法:默认拒绝,明确允许。
如果你以后扩展状态,比如增加 ARCHIVED 归档或 PENDING_REVIEW 待审核,不要随手在前端加按钮就结束。你必须回到后端状态机,明确哪些状态可以进入新状态、哪些状态可以离开新状态、每条转换需要哪些前置条件、会不会影响缓存、测试是否覆盖非法转换。
前端侧示意代码可以用来渲染按钮,但不应该成为唯一规则来源:
// 前端侧示意代码:只用于渲染按钮,最终以后端 validateTransition 为准
const nextStatusMap: Record<ProductStatus, ProductStatus[]> = {
DRAFT: ['ON_SALE'],
ON_SALE: ['OFF_SHELF'],
OFF_SHELF: ['ON_SALE'],
}
这段前端映射要和后端规则保持一致,但当两者冲突时,后端说了算。前端可以根据接口返回的错误 code 修正页面提示。
6.8 上架前的 SKU 校验
SkuDbService.requireSaleableSku 的核心逻辑是:
public void requireSaleableSku(Long productId) {
boolean exists = lambdaQuery()
.eq(SkuEntity::getProductId, productId)
.gt(SkuEntity::getSalePrice, BigDecimal.ZERO)
.gt(SkuEntity::getAvailableStock, 0)
.count() > 0;
if (!exists) {
throw new BusinessException(
ApiCode.PRODUCT_NOT_READY_FOR_SALE,
ApiCode.PRODUCT_NOT_READY_FOR_SALE.defaultMessage()
);
}
}
这里用 MyBatis-Plus 的 lambdaQuery() 查 SKU 表,条件是:属于当前商品、销售价大于 0、可售库存大于 0。只要有一条满足,就允许上架。否则抛 PRODUCT_NOT_READY_FOR_SALE。
这段逻辑体现了后端业务完整性。商品标题、描述、分类只是 SPU 层信息,真正能不能卖还要看 SKU。比如一件衣服商品可能有红色 M 码、红色 L 码、黑色 M 码等 SKU;如果所有 SKU 都没库存,商品上架对用户没有意义。当前项目用简单规则“至少一个可售 SKU”作为上架门槛。
SkuControllerTest.shouldRejectPublishingWithoutSaleableSku 也验证了这个规则:没有 SKU 的草稿商品上架会返回 PRODUCT_NOT_READY_FOR_SALE;有 SKU 但库存为 0 的商品同样不能上架。测试证明:后端不是只看商品表的状态字段,而会跨到 SKU 表检查可售条件。
flowchart TD
A[请求上架商品] --> B[读取 mall_product]
B --> C{状态转换是否允许}
C -- 否 --> C1[INVALID_PRODUCT_STATUS_TRANSITION]
C -- 是 --> D{分类是否启用}
D -- 否 --> D1[CATEGORY_NOT_FOUND]
D -- 是 --> E{是否存在 salePrice > 0\n且 availableStock > 0 的 SKU}
E -- 否 --> E1[PRODUCT_NOT_READY_FOR_SALE]
E -- 是 --> F[更新 status_code = ON_SALE]
F --> G[删除商品详情缓存]
6.9 数据库字段:mall_product 和 mall_product_sku
sql/01_schema.sql 中的商品表有这些关键字段:
CREATE TABLE IF NOT EXISTS mall_product (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '商品主键',
category_id BIGINT UNSIGNED NOT NULL COMMENT '分类 ID',
title VARCHAR(100) NOT NULL COMMENT '商品标题',
subtitle VARCHAR(100) NOT NULL DEFAULT '' COMMENT '商品副标题',
description VARCHAR(2000) NULL COMMENT '商品描述',
status_code VARCHAR(20) NOT NULL DEFAULT 'DRAFT' COMMENT '状态:DRAFT、ON_SALE、OFF_SHELF',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建管理员 ID',
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),
KEY idx_mall_product_category_id (category_id),
KEY idx_mall_product_status_created_at (status_code, created_at),
KEY idx_mall_product_created_by (created_by)
)
注意 status_code 的默认值是 DRAFT,但业务代码仍然显式设置 ProductStatus.DRAFT.name()。默认值是数据库层兜底,业务代码显式设置能让规则更清晰。idx_mall_product_status_created_at 索引用于按状态和创建时间查询商品,公开列表只查 ON_SALE 时可以利用这个索引。
SKU 表中和上架有关的字段是:
CREATE TABLE IF NOT EXISTS mall_product_sku (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'SKU 主键',
product_id BIGINT UNSIGNED NOT NULL COMMENT '所属 SPU 商品 ID',
sku_code VARCHAR(64) NOT NULL COMMENT '全局唯一 SKU 编码',
spec_text VARCHAR(200) NOT NULL COMMENT '规格描述,例如 黑色 / 128G',
sale_price DECIMAL(12,2) NOT NULL COMMENT '销售价',
available_stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '可售库存',
locked_stock INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '锁定库存,阶段 7 使用',
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'
)
requireSaleableSku 用的是 sale_price > 0 和 available_stock > 0。这两个条件来自真实表字段,不是凭空想象。以后第 10 篇会专门讲 SKU、库存和并发,本篇你只要先理解:上架不是改一个商品状态这么简单,它需要检查商品是否真的具备销售条件。
6.10 测试如何证明业务规则
ProductControllerTest.shouldAllowAdminToCreateDraftWithTrustedCreatorId 证明了 3 件事:管理员可以创建商品;标题和描述会被 trim;创建出来的商品状态是 DRAFT,创建人是服务端从登录态取到的管理员 ID。
ProductControllerTest.shouldRejectInvalidCreateRequest 证明了参数校验生效:categoryId=0、空标题会返回 VALIDATION_ERROR。
ProductControllerTest.shouldReturn404WhenCategoryDoesNotExistOrIsDisabled 证明创建商品不能使用停用分类。种子数据里分类 3 是禁用状态,创建到分类 3 会返回 CATEGORY_NOT_FOUND。
ProductControllerTest.shouldRejectUnauthenticatedAndNormalUserCreateRequests 证明权限边界存在:未登录创建返回 UNAUTHORIZED,普通用户登录后创建返回 FORBIDDEN。
ProductControllerTest.shouldAllowDraftToGoOnSale 证明 DRAFT -> ON_SALE 是允许转换,并且数据库中的 statusCode 真正变成 ON_SALE。
ProductControllerTest.shouldRejectIllegalStatusTransition 证明非法状态转换会返回 INVALID_PRODUCT_STATUS_TRANSITION。例如已上架商品再次请求上架,不应该被当成成功。
这些测试不是额外负担,而是业务文档。前端转后端时,如果你读不懂某段业务代码,可以先看测试方法名。测试名字通常直接告诉你“这个系统承诺了什么”。
7. 本地运行 / curl 验证
下面命令假设你已经按前面章节启动了 MySQL、Redis 和 Spring Boot 服务。具体启动方式可回看第 2 篇和第 7 篇。管理端接口需要管理员 token,种子数据里通常有
admin / Admin123456。
7.1 登录管理员获取 token
curl -sS -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Admin123456"}'
响应里会有 token 字段。为了后续命令方便,可以手动复制出来:
export ADMIN_TOKEN='<复制登录响应里的 token>'
如果你拿不到 token,先别排查商品接口,先回到第 6 篇检查登录、JWT、Spring Security 和本地种子用户。
7.2 创建商品草稿
curl -i -X POST http://localhost:8080/api/admin/products \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: product-create-001' \
-d '{
"categoryId": 1,
"title": " 前端转后端学习套装 ",
"description": " 用真实项目学习 Spring Boot "
}'
你应该关注:HTTP 状态是否为 201;响应 code 是否为 SUCCESS;data.title 是否被 trim;data.description 是否被 trim;data.status 是否为 DRAFT;traceId 是否和请求头一致。
如果你看到 401,说明没有带 token 或 token 过期;如果看到 403,说明当前用户不是管理员;如果看到 VALIDATION_ERROR,说明请求体没有通过 DTO 校验;如果看到 CATEGORY_NOT_FOUND,说明分类不存在或停用。
7.3 未登录和普通用户不能创建
未登录请求:
curl -i -X POST http://localhost:8080/api/admin/products \
-H 'Content-Type: application/json' \
-d '{"categoryId":1,"title":"未登录创建","description":null}'
应该返回 401 和 UNAUTHORIZED。如果你用普通用户 token 请求,则应该返回 403 和 FORBIDDEN。这就是“认证”和“授权”的区别:未登录是不知道你是谁;已登录但不是管理员,是知道你是谁但你没权限。
7.4 验证草稿公开不可见
假设刚创建的商品 ID 是 NEW_ID。请求公开详情:
curl -i http://localhost:8080/api/products/$NEW_ID \
-H 'X-Trace-Id: public-draft-001'
即使数据库有这条商品,也应该公开不可见。原因是公开详情调用 requirePublishedById,只查 ON_SALE。这不是 bug,而是业务规则。
7.5 尝试上架
如果商品还没有可售 SKU,上架可能失败:
curl -i -X PATCH http://localhost:8080/api/admin/products/$NEW_ID/status \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-H 'X-Trace-Id: publish-001' \
-d '{"targetStatus":"ON_SALE"}'
如果返回 PRODUCT_NOT_READY_FOR_SALE,说明后端正确执行了 SKU 前置校验。你可以先用项目里的 SKU 管理接口为这个商品创建一个价格大于 0、库存大于 0 的 SKU,再重新上架。SKU 细节会在第 10 篇展开。
种子数据里 2 号商品是 DRAFT,并且有一条 SKU:SWITCH-OLED-WHITE,价格 1699,库存 8。所以可以用它验证上架:
curl -i -X PATCH http://localhost:8080/api/admin/products/2/status \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"targetStatus":"ON_SALE"}'
成功后,公开详情 /api/products/2 就应该从 404 变成可查询。这个变化能帮你理解:状态字段影响公开可见性。
7.6 验证非法转换
1 号商品种子数据是 ON_SALE。再次把它改成 ON_SALE:
curl -i -X PATCH http://localhost:8080/api/admin/products/1/status \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"targetStatus":"ON_SALE"}'
预期返回 INVALID_PRODUCT_STATUS_TRANSITION。这证明后端不是盲目执行“把状态改为目标值”,而是校验“当前状态到目标状态”这条边是否存在。
7.7 下架商品并验证公开不可见
curl -i -X PATCH http://localhost:8080/api/admin/products/1/status \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"targetStatus":"OFF_SHELF"}'
成功后再请求:
curl -i http://localhost:8080/api/products/1
公开详情应该变成 404。注意数据库里的商品没有删除,只是状态变为 OFF_SHELF。管理端仍然可以查到它,公开端不能查到它。
7.8 验证缓存失效
如果 1 号商品之前被公开详情请求过,Redis 里可能有 mall:product:detail:v1:1。下架成功后,后端会调用 evict(1) 删除缓存。你可以用:
redis-cli get mall:product:detail:v1:1
如果 key 不存在,说明缓存已失效。下一次公开详情会回源 MySQL,根据 OFF_SHELF 返回 404。这个验证很重要,因为没有缓存失效时,你可能会看到数据库已经下架,但前端仍然能拿到旧详情。
8. 常见错误
8.1 把前端按钮当成业务规则
前端隐藏按钮只能防普通误点,不能防手动请求。后端必须独立判断权限、参数、状态机和前置条件。只靠前端按钮控制状态,是后端写操作里最危险的误区之一。
8.2 创建商品时允许前端传 status
如果创建接口允许前端传 status=ON_SALE 并直接保存,就绕过了上架前的分类和 SKU 校验。当前项目正确做法是创建请求 DTO 不包含 status,后端固定创建 DRAFT。
8.3 只判断目标状态是否合法
targetStatus 是枚举只能证明目标值在集合里,不能证明当前状态能到达目标状态。ON_SALE 是合法枚举,但 ON_SALE -> ON_SALE 是非法转换。状态机必须同时看当前状态和目标状态。
8.4 上架前不检查 SKU
如果没有可售 SKU 也允许上架,用户能看到商品却不能购买。更糟的是后续订单链路可能出现库存不足、价格缺失等错误。当前项目用 requireSaleableSku 在上架前拦截。
8.5 上架前不检查分类启用
分类停用后,挂在该分类下的商品不应继续公开上架。前端分类下拉框只展示启用分类不够,后端必须查数据库确认分类状态。
8.6 状态变更后忘记删除缓存
商品公开详情可能已经缓存在 Redis。上架、下架、重新上架都会影响公开可见性,数据库更新成功后必须删除缓存。否则前端会看到旧状态或旧详情。
8.7 忽略 HTTP 201
创建接口成功返回 201 Created。前端封装如果只把 status === 200 当成功,就会误判创建失败。更稳妥的方式是按 HTTP 2xx 判断成功,并继续检查业务 code。
8.8 把 createdBy 交给前端
创建人必须来自登录态,不能来自请求体。前端传来的用户 ID 不可信。当前项目通过 CurrentUserService.requireCurrentUserId() 取当前管理员 ID。
8.9 直接删除商品代替下架
删除会破坏历史关联。下架保留商品记录,只改变公开可见性,更符合电商系统的审计和历史数据需求。
8.10 没有用测试保护状态机
状态机规则看起来简单,但一旦多人维护,很容易被无意改坏。shouldAllowDraftToGoOnSale、shouldRejectIllegalStatusTransition、shouldRejectPublishingWithoutSaleableSku 这类测试就是规则护栏。
9. 本章小练习
练习 1:画出状态机
不看本文的 Mermaid 图,自己画出 DRAFT、ON_SALE、OFF_SHELF 的允许转换,并标出哪些转换会返回 INVALID_PRODUCT_STATUS_TRANSITION。
练习 2:解释创建商品为什么只能是草稿
阅读 ProductCreateRequest 和 ProductFacade.createProduct,用自己的话解释:为什么创建请求里没有 status 字段?为什么后端要固定设置 ProductStatus.DRAFT.name()?
练习 3:用 curl 验证非法转换
对 1 号商品发送 targetStatus=ON_SALE,观察响应 code。然后阅读 validateTransition,说明为什么这次请求被拒绝。
练习 4:验证草稿上架后公开可见
用 2 号商品做实验:先请求 /api/products/2,确认公开 404;再用管理员 token 把它上架;最后再次请求 /api/products/2,确认公开可见。记录每一步的 HTTP 状态和业务 code。
练习 5:解释无 SKU 不能上架
阅读 SkuDbService.requireSaleableSku,说明它检查了哪两个 SKU 字段。再结合 SkuControllerTest.shouldRejectPublishingWithoutSaleableSku,解释为什么库存为 0 的 SKU 不能支撑商品上架。
练习 6:设计前端按钮,但写明后端兜底
写一段“前端侧示意代码”,根据 ProductStatus 渲染按钮。然后在注释里写明:这些按钮只是体验层,后端仍然会用状态机校验。
// 前端侧示意代码:按钮渲染不等于业务安全
function renderStatusButton(status: ProductStatus) {
const actions = {
DRAFT: ['上架'],
ON_SALE: ['下架'],
OFF_SHELF: ['重新上架'],
} satisfies Record<ProductStatus, string[]>
return actions[status]
}
练习 7:找出缓存失效位置
在 ProductFacade 中找到创建商品、变更状态、修改副标题后调用 productDetailCacheService.evict 的位置。解释为什么这些写操作都会影响公开商品详情缓存。
练习 8:读测试当文档
阅读 ProductControllerTest 中与创建商品、权限、状态变更相关的测试方法名。把每个方法名改写成一句中文业务规则。
10. 再深入一点:状态机应该放在哪里
对于小项目来说,你可能会把状态判断写在 Controller 里:如果当前是草稿并且目标是上架,就允许;否则返回错误。这样写一开始能跑,但很快会变乱。Controller 会同时处理 HTTP 参数、权限上下文、业务判断、数据库更新、缓存失效,最后变成难测试的大杂烩。
当前项目把状态机放在 ProductFacade,这是比较合适的选择。Facade 代表一个用例编排层,它知道“变更商品状态”这个业务动作要协调哪些下层服务:商品 DB、分类 DB、SKU DB、缓存、当前用户等。Controller 不需要知道这些细节,测试也可以围绕 Facade 或 Controller 分层验证。
如果未来状态越来越复杂,可以继续演进为独立的状态机类,例如 ProductStatusMachine,专门负责转换规则;也可以把不同转换建模成命令,例如 PublishProductCommand、OffShelfProductCommand。但在当前项目规模下,把 validateTransition 放在 ProductFacade 内部简单、直接、可读。不要为了“架构感”过早抽象。后端工程的一个重要能力是判断复杂度什么时候值得引入,而不是看到状态机三个字就马上引入一套框架。
从前端类比,简单组件里你可以直接写 computed 控制按钮;复杂页面里才会拆 composable、store、状态机库。后端也一样:先用清晰的函数表达规则,等规则变多、复用变多、测试复杂后,再抽独立模块。
11. 下一章预告:SKU、库存与并发
本篇讲的是商品 SPU 的生命周期:创建草稿、上架、下架、重新上架。下一篇会深入 SKU 和库存。你会看到商品为什么要分 SPU 和 SKU,mall_product 与 mall_product_sku 的关系是什么,价格、可售库存、锁定库存、version 字段分别解决什么问题。
库存是前端转后端非常关键的一章。前端可以禁用按钮、防重复点击、在页面上显示“仅剩 1 件”,但真正防止超卖必须靠数据库原子更新、条件更新、乐观锁或事务。当前项目里 SkuDbService 和 SkuMapper 已经包含价格版本更新、库存调整、锁定库存、释放锁定库存、支付成功确认锁定库存等方法。下一章我们会从最基础的 SKU 概念讲起,再逐步过渡到并发安全。
12. 本篇总结
本篇你需要带走 10 个结论:
- 商品状态是持久化业务状态,不是前端页面临时状态;
- 当前项目的商品生命周期只有
DRAFT、ON_SALE、OFF_SHELF三种状态; - 创建商品只能创建
DRAFT,不能由前端直接伪造成已上架; - 状态机采用默认拒绝策略,只允许
DRAFT -> ON_SALE、ON_SALE -> OFF_SHELF、OFF_SHELF -> ON_SALE; - 管理端权限和状态机是两层规则:有权限不代表任何状态转换都合法;
- 上架前必须校验分类存在且启用,因为上架商品会公开给匿名用户;
- 上架前必须至少有一个价格有效且库存大于 0 的 SKU;
- 状态变化会影响公开详情可见性,所以数据库更新成功后必须删除 Redis 商品详情缓存;
201 Created、401 UNAUTHORIZED、403 FORBIDDEN、VALIDATION_ERROR、INVALID_PRODUCT_STATUS_TRANSITION、PRODUCT_NOT_READY_FOR_SALE都是前后端联调时要关注的信号;- 测试方法名就是业务规则文档,读测试能帮你更快理解后端代码想保护什么。
如果你能自己解释“为什么 2 号草稿商品可以上架,而 1 号已上架商品再次上架会失败”,并能说清楚“上架前为什么要查 SKU,状态变化后为什么要删缓存”,说明你已经开始具备后端业务规则建模能力。下一章我们会继续沿着这条线,进入 SKU、库存与并发安全。