09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门

本篇写给正在从前端转后端的你。你可以把它当成"后台商品管理页"的后端源码走读:管理员点击"新增商品""上架""下架"按钮以后,后端到底做了哪些校验、写了哪些表、为什么不能只相信前端按钮状态。本文所有前端代码都只是"前端侧示意代码",用于类比理解;真实工程以当前仓库 backend/ 代码为准。

1. 这篇解决什么问题

前面第 8 篇我们完整读了公开商品详情接口:匿名用户请求 /api/products/{id},后端经过 Security、Controller、Facade、Redis、MySQL、DTO 组装,最后返回统一 JSON。那是一条典型"读链路"。本篇开始进入管理端"写链路":管理员创建商品草稿,然后把草稿上架,必要时再下架。

写链路比读链路更容易踩坑。读接口写错了,常见结果是页面展示不出来、展示慢、展示旧数据;写接口写错了,可能造成数据库脏数据、越权创建、非法状态流转、缓存和数据库不一致,甚至影响订单和库存。对于前端同学来说,最容易产生的误解是:既然后台页面按钮已经控制了状态,那后端是不是只要按前端传来的 targetStatus 更新数据库就行?答案是否定的。前端按钮只是体验层,真正的业务规则必须落在后端。

本篇要解决 6 个核心问题:

  1. 商品为什么不是创建后直接上架,而是先成为 DRAFT 草稿;
  2. DRAFTON_SALEOFF_SHELF 三个状态分别代表什么;
  3. 为什么状态流转必须由后端状态机控制,不能只相信前端按钮;
  4. 管理员创建商品、上架、下架分别经过哪些代码文件;
  5. 上架前为什么要校验分类启用和至少一个可售 SKU;
  6. 状态变化后为什么必须删除商品详情缓存。

读完本篇,你应该能独立解释这几个现象:匿名用户为什么看不到 DRAFT 商品;管理员创建商品时即使请求体里没有状态,返回也是 DRAFT;把已经上架的商品再次上架为什么会报 INVALID_PRODUCT_STATUS_TRANSITION;没有可售 SKU 的商品为什么不能上架;商品状态变化成功后为什么要调用 productDetailCacheService.evict(productId)

2. 用前端知识类比

如果你做过后台管理系统,商品列表页通常会有"新建""编辑""上架""下架"等按钮。前端可能会根据接口返回的 status 控制按钮是否可点击。例如草稿商品显示"上架",已上架商品显示"下架",已下架商品显示"重新上架"。这种按钮控制能让用户少犯错,但它不是安全边界。

看一个前端侧示意代码:

ts 复制代码
// 前端侧示意代码:按钮状态只提升体验,不代表后端可以不校验
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、脚本直接请求:

bash 复制代码
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 条件?如果不满足,必须拒绝请求。

这就像前端表单校验和后端参数校验的关系。前端可以用 requiredmaxlength、表单规则来减少错误输入,但后端仍然要用 @NotBlank@Size@NotNull 做最终校验。因为前端校验可被绕过,后端才是数据可信边界。商品状态机也是同理:前端按钮可被绕过,后端状态机不能被绕过。

还可以把商品状态理解成前端组件状态,但它比组件状态更严肃。前端组件的 loadingvisibleselectedTab 通常只影响当前页面展示;商品的 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[&#34;请求 /api/admin/products&#34;] --> B{&#34;是否登录&#34;} B -- &#34;否&#34; --> B1[&#34;401 UNAUTHORIZED&#34;] B -- &#34;是&#34; --> C{&#34;是否 ADMIN&#34;} C -- &#34;否&#34; --> C1[&#34;403 FORBIDDEN&#34;] C -- &#34;是&#34; --> D[&#34;进入 ProductAdminController&#34;] D --> E{&#34;参数是否合法&#34;} E -- &#34;否&#34; --> E1[&#34;400 VALIDATION_ERROR&#34;] E -- &#34;是&#34; --> F[&#34;ProductFacade 执行业务规则&#34;] F --> G{&#34;状态机和前置条件是否通过&#34;} G -- &#34;否&#34; --> G1[&#34;业务错误 code&#34;] G -- &#34;是&#34; --> H[&#34;写入 MySQL 并删除缓存&#34;]

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 商品生命周期枚举:DRAFTON_SALEOFF_SHELF
backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java 商品数据库服务,提供 saverequireById、公开查询等能力
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_productmall_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 的路径是:

java 复制代码
@RestController
@RequestMapping("/api/admin/products")
@Tag(name = "管理员商品管理")
public class ProductAdminController {

这说明它不是公开商品接口,而是管理后台接口。第 8 篇读过公开接口 /api/products/{id},本篇读的是 /api/admin/products。路径上多了 /admin,权限语义完全不同。

创建商品接口:

java 复制代码
@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 当成功。

状态变更接口:

java 复制代码
@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 个字段:categoryIdtitledescription

java 复制代码
@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;
}

这里故意没有 statuscreatedBycreatedAtupdatedAt。这就是后端契约设计。前端创建商品时不能决定初始状态,也不能决定创建人,更不能自己传创建时间。状态和创建人属于服务端可信字段,必须由后端生成。

前端侧示意请求类型可以这样理解:

ts 复制代码
// 前端侧示意代码:创建商品时只提交后端允许的字段
interface ProductCreatePayload {
  categoryId: number
  title: string
  description?: string | null
}

如果前端偷偷加一个 status: 'ON_SALE',当前后端也不会把它用于创建状态。因为 Java DTO 里没有这个字段,ProductFacade.createProduct 又会固定设置 DRAFT。这就是"请求契约"和"服务端生成字段"的边界。

6.3 状态枚举:ProductStatus

商品状态定义在 contract 模块中:

java 复制代码
public enum ProductStatus {
    /** 草稿:只能由管理员维护,匿名用户不可见。 */
    DRAFT,
    /** 已上架:匿名用户可以查询,且上架时必须存在可售 SKU。 */
    ON_SALE,
    /** 已下架:保留历史数据,但匿名用户不可见。 */
    OFF_SHELF
}

为什么放在 contract 模块?因为状态枚举不仅服务端内部用,Request、Response 也会用到。ProductStatusChangeRequest 接收目标状态,ProductDetailResponse 返回当前状态,前端也会根据这些字符串展示标签和按钮。它是 API 契约的一部分。

前端侧示意类型可以写成:

ts 复制代码
// 前端侧示意代码:根据后端 enum 建立联合类型
export type ProductStatus = 'DRAFT' | 'ON_SALE' | 'OFF_SHELF'

但要注意:前端类型只是帮助你写代码时有提示,不能替代后端校验。后端枚举反序列化和 @NotNull 能保证 targetStatus 是合法枚举且不为空,状态机能保证这个目标状态在当前商品状态下允许到达。

6.4 创建商品业务:createProduct

ProductFacade.createProduct 是创建商品的核心:

java 复制代码
@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

状态变更请求非常小:

java 复制代码
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductStatusChangeRequest {

    @NotNull(message = "目标状态不能为空")
    private ProductStatus targetStatus;
}

小不代表简单。这个 DTO 只表达前端"想去哪里",不表达"能不能去"。能不能去由 ProductFacade.changeStatus 读取当前状态后判断。前端侧示意代码可以是:

ts 复制代码
// 前端侧示意代码:状态按钮提交目标状态
async function changeProductStatus(id: number, targetStatus: ProductStatus) {
  return request.patch(`/api/admin/products/${id}/status`, { targetStatus })
}

这里的 targetStatus 仍然是不可信输入。后端会把它当成"请求意图",而不是"最终事实"。最终事实必须经过状态机、分类、SKU 等规则确认后才能写入 MySQL。

6.6 状态变更业务:changeStatus

核心代码如下:

java 复制代码
@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

状态机代码很短:

java 复制代码
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 待审核,不要随手在前端加按钮就结束。你必须回到后端状态机,明确哪些状态可以进入新状态、哪些状态可以离开新状态、每条转换需要哪些前置条件、会不会影响缓存、测试是否覆盖非法转换。

前端侧示意代码可以用来渲染按钮,但不应该成为唯一规则来源:

ts 复制代码
// 前端侧示意代码:只用于渲染按钮,最终以后端 validateTransition 为准
const nextStatusMap: Record<ProductStatus, ProductStatus[]> = {
  DRAFT: ['ON_SALE'],
  ON_SALE: ['OFF_SHELF'],
  OFF_SHELF: ['ON_SALE'],
}

这段前端映射要和后端规则保持一致,但当两者冲突时,后端说了算。前端可以根据接口返回的错误 code 修正页面提示。

6.8 上架前的 SKU 校验

SkuDbService.requireSaleableSku 的核心逻辑是:

java 复制代码
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 中的商品表有这些关键字段:

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 表中和上架有关的字段是:

sql 复制代码
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 > 0available_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

bash 复制代码
curl -sS -X POST http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"Admin123456"}'

响应里会有 token 字段。为了后续命令方便,可以手动复制出来:

bash 复制代码
export ADMIN_TOKEN='<复制登录响应里的 token>'

如果你拿不到 token,先别排查商品接口,先回到第 6 篇检查登录、JWT、Spring Security 和本地种子用户。

7.2 创建商品草稿

bash 复制代码
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 是否为 SUCCESSdata.title 是否被 trim;data.description 是否被 trim;data.status 是否为 DRAFTtraceId 是否和请求头一致。

如果你看到 401,说明没有带 token 或 token 过期;如果看到 403,说明当前用户不是管理员;如果看到 VALIDATION_ERROR,说明请求体没有通过 DTO 校验;如果看到 CATEGORY_NOT_FOUND,说明分类不存在或停用。

7.3 未登录和普通用户不能创建

未登录请求:

bash 复制代码
curl -i -X POST http://localhost:8080/api/admin/products \
  -H 'Content-Type: application/json' \
  -d '{"categoryId":1,"title":"未登录创建","description":null}'

应该返回 401UNAUTHORIZED。如果你用普通用户 token 请求,则应该返回 403FORBIDDEN。这就是"认证"和"授权"的区别:未登录是不知道你是谁;已登录但不是管理员,是知道你是谁但你没权限。

7.4 验证草稿公开不可见

假设刚创建的商品 ID 是 NEW_ID。请求公开详情:

bash 复制代码
curl -i http://localhost:8080/api/products/$NEW_ID \
  -H 'X-Trace-Id: public-draft-001'

即使数据库有这条商品,也应该公开不可见。原因是公开详情调用 requirePublishedById,只查 ON_SALE。这不是 bug,而是业务规则。

7.5 尝试上架

如果商品还没有可售 SKU,上架可能失败:

bash 复制代码
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。所以可以用它验证上架:

bash 复制代码
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

bash 复制代码
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 下架商品并验证公开不可见

bash 复制代码
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"}'

成功后再请求:

bash 复制代码
curl -i http://localhost:8080/api/products/1

公开详情应该变成 404。注意数据库里的商品没有删除,只是状态变为 OFF_SHELF。管理端仍然可以查到它,公开端不能查到它。

7.8 验证缓存失效

如果 1 号商品之前被公开详情请求过,Redis 里可能有 mall:product:detail:v1:1。下架成功后,后端会调用 evict(1) 删除缓存。你可以用:

bash 复制代码
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 没有用测试保护状态机

状态机规则看起来简单,但一旦多人维护,很容易被无意改坏。shouldAllowDraftToGoOnSaleshouldRejectIllegalStatusTransitionshouldRejectPublishingWithoutSaleableSku 这类测试就是规则护栏。

9. 本章小练习

练习 1:画出状态机

不看本文的 Mermaid 图,自己画出 DRAFTON_SALEOFF_SHELF 的允许转换,并标出哪些转换会返回 INVALID_PRODUCT_STATUS_TRANSITION

练习 2:解释创建商品为什么只能是草稿

阅读 ProductCreateRequestProductFacade.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 渲染按钮。然后在注释里写明:这些按钮只是体验层,后端仍然会用状态机校验。

ts 复制代码
// 前端侧示意代码:按钮渲染不等于业务安全
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,专门负责转换规则;也可以把不同转换建模成命令,例如 PublishProductCommandOffShelfProductCommand。但在当前项目规模下,把 validateTransition 放在 ProductFacade 内部简单、直接、可读。不要为了"架构感"过早抽象。后端工程的一个重要能力是判断复杂度什么时候值得引入,而不是看到状态机三个字就马上引入一套框架。

从前端类比,简单组件里你可以直接写 computed 控制按钮;复杂页面里才会拆 composable、store、状态机库。后端也一样:先用清晰的函数表达规则,等规则变多、复用变多、测试复杂后,再抽独立模块。

11. 下一章预告:SKU、库存与并发

本篇讲的是商品 SPU 的生命周期:创建草稿、上架、下架、重新上架。下一篇会深入 SKU 和库存。你会看到商品为什么要分 SPU 和 SKU,mall_productmall_product_sku 的关系是什么,价格、可售库存、锁定库存、version 字段分别解决什么问题。

库存是前端转后端非常关键的一章。前端可以禁用按钮、防重复点击、在页面上显示"仅剩 1 件",但真正防止超卖必须靠数据库原子更新、条件更新、乐观锁或事务。当前项目里 SkuDbServiceSkuMapper 已经包含价格版本更新、库存调整、锁定库存、释放锁定库存、支付成功确认锁定库存等方法。下一章我们会从最基础的 SKU 概念讲起,再逐步过渡到并发安全。

12. 本篇总结

本篇你需要带走 10 个结论:

  1. 商品状态是持久化业务状态,不是前端页面临时状态;
  2. 当前项目的商品生命周期只有 DRAFTON_SALEOFF_SHELF 三种状态;
  3. 创建商品只能创建 DRAFT,不能由前端直接伪造成已上架;
  4. 状态机采用默认拒绝策略,只允许 DRAFT -> ON_SALEON_SALE -> OFF_SHELFOFF_SHELF -> ON_SALE
  5. 管理端权限和状态机是两层规则:有权限不代表任何状态转换都合法;
  6. 上架前必须校验分类存在且启用,因为上架商品会公开给匿名用户;
  7. 上架前必须至少有一个价格有效且库存大于 0 的 SKU;
  8. 状态变化会影响公开详情可见性,所以数据库更新成功后必须删除 Redis 商品详情缓存;
  9. 201 Created401 UNAUTHORIZED403 FORBIDDENVALIDATION_ERRORINVALID_PRODUCT_STATUS_TRANSITIONPRODUCT_NOT_READY_FOR_SALE 都是前后端联调时要关注的信号;
  10. 测试方法名就是业务规则文档,读测试能帮你更快理解后端代码想保护什么。

如果你能自己解释"为什么 2 号草稿商品可以上架,而 1 号已上架商品再次上架会失败",并能说清楚"上架前为什么要查 SKU,状态变化后为什么要删缓存",说明你已经开始具备后端业务规则建模能力。下一章我们会继续沿着这条线,进入 SKU、库存与并发安全。

相关推荐
taocarts_bidfans3 小时前
Taoify 站点访问地区限制与 IP 管控配置
前端·javascript·tcp/ip·taoify
狂师3 小时前
用自然语言控制手机,一条命令让 AI 帮你操作 Android 和 iOS 设备!
前端·开源·测试
Csvn3 小时前
# 🎨 CSS Container Queries 实战踩坑——从「响应式」到「容器式」
前端
zhangjw343 小时前
第36篇:Spring Boot进阶:Web开发+参数校验+全局异常处理
前端·spring boot·后端
倒流时光三十年3 小时前
第一阶段 02 · Mapping 与数据类型(text vs keyword 是重点)
后端·python·django
老王以为3 小时前
解剖 Claude Code:逆向工程视角下的入口架构分析
前端·ai编程·claude
Csvn3 小时前
💰 JavaScript 浮点数精度问题深度剖析——前端金额计算的「定时炸弹」
前端
Zane19943 小时前
JMM 与 happens-before:一次搞懂 Java 内存模型
java·后端
Csvn3 小时前
structuredClone:原生深拷贝 API 的正确打开方式
前端