08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON

本篇面向已经完成前 7 篇基础学习的前端同学。我们会基于当前 fullstack-mall 后端工程真实源码,完整拆解公开商品详情接口 GET /api/products/{id}:请求如何进入 Controller,为什么这个接口允许匿名访问,Facade 如何先查 Redis 缓存,缓存命中、未命中、空值命中、Redis 故障分别怎么处理,MyBatis-Plus 如何查询 MySQL,商品表和分类表如何组装成响应 DTO,业务异常如何变成统一 JSON。文中的 Vue / Axios / TypeScript 代码都只是"前端侧示意代码",用于类比理解,当前仓库没有真实前端源码。

1. 这篇解决什么问题

前面 7 篇分别学了工程地图、Spring Boot 启动、Controller / Request / Response、MySQL、MyBatis-Plus、JWT / Spring Security、Redis 缓存。到这里,你已经认识了很多独立概念,但真实后端开发最重要的能力不是背概念,而是能把一个请求从入口一路追到出口,知道每一层为什么存在、处理了什么、失败时会怎样返回。

本篇选择公开商品详情接口作为第一条完整链路:

http 复制代码
GET /api/products/{id}

这个接口看起来非常简单,前端商品详情页只需要拿到商品标题、副标题、描述、分类名、状态、创建时间和更新时间。但后端真实链路并不是"查 mall_product 表返回"这么简单。当前项目为了保证公开可见性、性能和统一错误处理,至少经过这些步骤:

  1. 请求进入 Spring Boot,先经过 TraceIdFilter 生成或透传 X-Trace-Id
  2. 请求经过 Spring Security,因为 /api/products/** 在白名单中,所以匿名用户也能访问;
  3. ProductController.detail 通过 @PathVariable Long id 绑定路径参数;
  4. Controller 调用 IProductFacade.getPublishedProduct(id)
  5. ProductFacade 先调用 ProductDetailCacheService.lookup(id) 查询 Redis;
  6. 如果缓存 HIT,直接返回 ProductDetailResponse
  7. 如果缓存 NULL_HIT,恢复业务 404;
  8. 如果缓存 MISSDEGRADEDBYPASS,回源 MySQL;
  9. ProductDbService.requirePublishedById 强制商品状态必须是 ON_SALE
  10. CategoryDbService.requireEnabledCategory 强制分类必须启用;
  11. Facade 把 ProductEntityCategoryEntity 组装成 ProductDetailResponse
  12. 查询成功后把响应 JSON 回填 Redis;
  13. 如果商品不存在、未上架、分类不存在或停用,抛 BusinessException 并写短 TTL 空值缓存;
  14. Controller 用 ApiResponse.success 包装成功响应;
  15. GlobalExceptionHandler 把业务异常包装成统一错误 JSON。

这条链路非常适合前端转后端学习,因为它包含后端接口开发最常见的 6 类问题:HTTP 入口、权限边界、缓存策略、数据库查询、DTO 组装、异常响应。学完本篇,你应该能做到:拿到一个接口路径,知道去哪里找 Controller;看到 Controller,知道它调用哪个 Facade;看到 Facade,知道缓存和数据库怎么编排;看到 DbService,知道对应 SQL 条件;看到 Response DTO,知道前端真正能拿到什么字段;看到 404 / 500 / 旧数据问题,知道从哪几层开始排查。

2. 用前端知识类比:商品详情页的一次接口请求

前端商品详情页通常会根据路由参数发请求:

ts 复制代码
// 前端侧示意代码:商品详情页加载,不代表仓库里存在这个文件
async function loadProductDetail(route: RouteLocationNormalizedLoaded) {
  const id = Number(route.params.id)
  const response = await http.get<ApiResponse<ProductDetail>>(`/api/products/${id}`)
  productDetail.value = response.data.data
}

从前端视角看,这只是一次 GET 请求。你关心的问题通常是:路由参数是不是数字,接口是否返回 200,响应字段能不能渲染,404 时显示"商品不存在",加载中和错误态怎么处理。

但后端视角会把这一次请求拆得更细:

前端看到的动作 后端实际关注点 当前项目代码
进入商品详情页 请求路径是否匹配 Controller @RequestMapping("/api/products") + @GetMapping("/{id}")
从路由拿 id 路径变量能否转成 Long @PathVariable Long id
发送 GET 请求 该 URL 是否允许匿名 SecurityConfig/api/products/**.permitAll()
等接口响应 是否命中 Redis ProductDetailCacheService.lookup
渲染商品标题 Response DTO 是否包含字段 ProductDetailResponse.title
显示分类名 商品表没有分类名,需要查分类表组装 CategoryDbService.requireEnabledCategory
显示 404 页面 业务异常如何转统一 JSON BusinessException + GlobalExceptionHandler
再次访问更快 第二次可能走 Redis HIT ProductDetailCacheStatus.HIT

前端同学还会习惯在页面层做缓存。例如同一个商品重复进入时先从 Pinia 或 Map 读取:

ts 复制代码
// 前端侧示意代码:页面级缓存,只用于类比
const productDetailCache = new Map<number, ProductDetail>()

async function getProductDetail(id: number) {
  const cached = productDetailCache.get(id)
  if (cached) {
    return cached
  }
  const response = await http.get(`/api/products/${id}`)
  productDetailCache.set(id, response.data.data)
  return response.data.data
}

当前项目后端做的 Redis Cache Aside 和这个思路有点像:先查缓存,没有再查真实来源,查到后回填缓存。但两者影响范围不同。前端缓存只影响当前浏览器;Redis 缓存影响所有用户。前端缓存错了,通常刷新即可;后端 Redis 缓存错了,可能所有用户都看到旧数据。所以后端链路必须额外处理 TTL、删除缓存、空值缓存、Redis 故障降级和坏缓存删除。

3. 后端核心概念讲解

3.1 一个接口不是一个方法,而是一条链路

初学后端时,很容易把接口理解成 Controller 里的一个方法。比如看到:

java 复制代码
@GetMapping("/{id}")
public ApiResponse<ProductDetailResponse> detail(@PathVariable Long id, HttpServletRequest servletRequest) {
    ProductDetailResponse response = productFacade.getPublishedProduct(id);
    return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}

你可能觉得"这个接口就是这几行"。但在真实运行中,它前面有 Filter、Security、参数绑定;后面有 Facade、缓存、DbService、MyBatis-Plus、MySQL、异常处理、JSON 序列化。Controller 只是 HTTP 入口,不应该承载全部业务。

可以把后端接口想象成前端一次页面渲染链路:路由进入页面组件,页面调用 composable,composable 调 API client,API client 走 Axios interceptor,响应回来后进 store,再触发视图更新。后端也是层层分工,只是每一层的职责不同。

3.2 公开接口也要有安全规则

商品详情是匿名接口,但"匿名"不等于"没有安全设计"。当前项目在 SecurityConfig 里明确写了:

java 复制代码
.requestMatchers(
    "/api/products",
    "/api/products/**"
).permitAll()

这表示公开商品列表和详情允许匿名访问。为什么要写规则?因为项目同时还有 /api/admin/** 管理接口和其他 /api/** 登录接口。如果没有明确白名单,公开商品详情可能被 /api/**.authenticated() 拦住,导致未登录用户看不了商品。

前端转后端要建立一个习惯:每新增一个 API,都要问它的访问边界是什么。公开商品详情可以匿名;购物车详情必须登录;管理员商品编辑必须 ADMIN;支付回调可能不是用户登录,而是共享密钥或签名校验。不要只看 URL 能不能跑通,要看它应该被谁访问。

3.3 公开商品详情只允许 ON_SALE

商品表里有 3 种状态:

java 复制代码
public enum ProductStatus {
    DRAFT,
    ON_SALE,
    OFF_SHELF
}

公开商品详情不能把草稿和下架商品暴露给匿名用户。当前项目不是让前端传 status=ON_SALE,而是在后端 ProductDbService.requirePublishedById 里强制查询:

java 复制代码
.eq(ProductEntity::getId, productId)
.eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())

这点非常重要。前端请求参数不可信,公开接口的可见性必须由服务端强制。即使有人直接访问 /api/products/2,而 2 号商品是 DRAFT,后端也会按 PRODUCT_NOT_FOUND 返回 404。

3.4 商品详情需要商品表和分类表共同组装

mall_product 表有 category_id,但没有 categoryName。这是数据库设计中的常见做法:商品表保存分类 ID,分类名称在 mall_category 表里。接口响应需要展示分类名,所以 Facade 要先查商品,再根据商品的 categoryId 查分类,并且分类必须启用。

ProductDetailResponse 中有:

java 复制代码
private Long categoryId;
private String categoryName;
private String title;
private String subtitle;
private String description;
private ProductStatus status;

其中 categoryName 是组装出来的,不是商品表冗余字段。前端看到一个扁平 JSON,但后端可能查了多张表并做了业务校验。

3.5 Redis 是性能层,不是真实数据源

商品详情接口先查 Redis,是为了加速热点读取。但 Redis 不能成为真实来源。当前项目在 ProductFacade.getPublishedProduct 注释中写得很清楚:MISSDEGRADEDBYPASS 都回源 MySQL;Redis 不是商品的真实数据来源。

这句话要牢记。缓存命中时可以直接返回,是因为缓存是从 MySQL 真实数据构建出来的副本;缓存没有时不能说商品不存在,要查 MySQL;Redis 故障时也不能让商品详情完全不可用,而是尽量查 MySQL。

3.6 业务异常和 HTTP 状态码不是一回事,但要对应

当前项目使用 BusinessException 携带 ApiCode 和 HTTP 状态。例如商品不存在时:

java 复制代码
new BusinessException(
    ApiCode.PRODUCT_NOT_FOUND,
    ApiCode.PRODUCT_NOT_FOUND.defaultMessage(),
    HttpStatus.NOT_FOUND
)

GlobalExceptionHandler 捕获 BusinessException 后,会返回统一 ApiResponse.error(...),HTTP 状态码是异常里的 httpStatus。所以前端既可以看 HTTP 404,也可以看响应体 code=PRODUCT_NOT_FOUND。业务逻辑建议优先依赖稳定 code,展示文案可以用 message

3.7 traceId 是前后端共同排查的编号

TraceIdFilter 会为每次请求生成或透传 X-Trace-Id,并写入 request attribute 和 response header。Controller 返回成功时通过 TraceIdContext.get(servletRequest) 把 traceId 放进 ApiResponse

前端遇到线上问题时,如果能把 traceId 发给后端,后端就能更容易在日志中定位对应请求。你可以把 traceId 理解成一次请求的快递单号:从入口、缓存、数据库、异常处理都能围绕它排查。

4. 在本项目中对应哪些文件

本篇主要涉及这些真实文件:

层级 文件 作用
安全配置 backend/service/src/main/java/com/example/fullstackmall/service/config/SecurityConfig.java 允许 /api/products/api/products/** 匿名访问
Trace backend/service/src/main/java/com/example/fullstackmall/service/common/trace/TraceIdFilter.java 为每次请求创建或透传 X-Trace-Id
Trace backend/service/src/main/java/com/example/fullstackmall/service/common/trace/TraceIdContext.java Controller 和异常处理器读取当前 traceId
HTTP 入口 backend/service/src/main/java/com/example/fullstackmall/service/product/ProductController.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 编排缓存、商品查询、分类查询、DTO 组装、异常和回填
商品 DB backend/service/src/main/java/com/example/fullstackmall/service/product/service/ProductDbService.java MyBatis-Plus 查询商品表,公开详情强制 ON_SALE
分类 DB backend/service/src/main/java/com/example/fullstackmall/service/category/service/CategoryDbService.java 查询分类,并要求分类启用
商品 Entity backend/service/src/main/java/com/example/fullstackmall/service/product/entity/ProductEntity.java 映射 mall_product
商品 Mapper backend/service/src/main/java/com/example/fullstackmall/service/product/mapper/ProductMapper.java 继承 MyBatis-Plus BaseMapper
缓存服务 backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java 商品详情缓存 key、JSON、TTL、空值、降级、删除
缓存结果 backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheLookup.java 表达缓存查询状态和结果
响应 DTO backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductDetailResponse.java 定义前端最终拿到的商品详情字段
状态枚举 backend/contract/src/main/java/com/example/fullstackmall/contract/product/ProductStatus.java 定义 DRAFTON_SALEOFF_SHELF
统一响应 backend/contract/src/main/java/com/example/fullstackmall/contract/common/ApiResponse.java 包装成功和失败响应
异常处理 backend/service/src/main/java/com/example/fullstackmall/service/common/exception/GlobalExceptionHandler.java BusinessException 等异常转成统一 JSON
数据库结构 sql/01_schema.sql 定义 mall_categorymall_product 表和索引
种子数据 sql/02_seed.sql 提供 ON_SALEDRAFTOFF_SHELF 演示商品
测试 ProductControllerTest.javaProductFacadeCacheTest.javaProductMapperTest.java 验证公开可见性、缓存分支和数据库映射

这张表本身就是你以后读后端接口的路线图:先找 Controller,再找 Facade,再找 DbService 和缓存服务,再找 DTO、Entity、SQL、测试。不要一上来全局搜索所有同名字段,那样很容易迷路。

5. Mermaid 图:把完整链路画出来

5.1 成功请求全链路

sequenceDiagram autonumber participant Browser as 前端浏览器 participant Trace as TraceIdFilter participant Security as Spring Security participant Controller as ProductController participant Facade as ProductFacade participant Cache as ProductDetailCacheService participant Redis as Redis participant ProductDb as ProductDbService participant CategoryDb as CategoryDbService participant MySQL as MySQL Browser->>Trace: GET /api/products/1 Trace->>Security: 写入 X-Trace-Id Security->>Controller: /api/products/** permitAll Controller->>Facade: getPublishedProduct(1) Facade->>Cache: lookup(1) Cache->>Redis: GET mall:product:detail:v1:1 alt HIT Redis-->>Cache: ProductDetailResponse JSON Cache-->>Facade: HIT(response) Facade-->>Controller: response else MISS / DEGRADED / BYPASS Cache-->>Facade: MISS/DEGRADED/BYPASS Facade->>ProductDb: requirePublishedById(1) ProductDb->>MySQL: select where id=1 and status_code='ON_SALE' MySQL-->>ProductDb: ProductEntity Facade->>CategoryDb: requireEnabledCategory(categoryId) CategoryDb->>MySQL: select category where id=? and status=1 MySQL-->>CategoryDb: CategoryEntity Facade->>Cache: put(1, response) Cache->>Redis: SET key JSON TTL Facade-->>Controller: response end Controller-->>Browser: ApiResponse

5.2 缓存分支决策图

flowchart TD A[进入 ProductFacade.getPublishedProduct] --> B[lookup productId] B --> C{ProductDetailCacheStatus} C -->|HIT| D[直接返回缓存 response] C -->|NULL_HIT| E[按 nullApiCode 抛 404] C -->|MISS| F[回源 MySQL] C -->|DEGRADED| F C -->|BYPASS| F F --> G[requirePublishedById: id + ON_SALE] G --> H[requireEnabledCategory: status=1] H --> I[toDetailResponse] I --> J[put 正常详情缓存] J --> K[返回成功] G -->|PRODUCT_NOT_FOUND| L[putNull 短 TTL] H -->|CATEGORY_NOT_FOUND| L L --> M[GlobalExceptionHandler 返回 404 JSON]

5.3 公开详情和管理详情的权限差异

flowchart LR A[&#34;匿名用户&#34;] --> B[&#34;GET /api/products/1&#34;] B --> C[&#34;/api/products/** permitAll&#34;] C --> D[只能看到 ON_SALE 商品] E[&#34;普通用户&#34;] --> F[&#34;GET /api/admin/products&#34;] F --> G[&#34;/api/admin/** hasRole ADMIN&#34;] G --> H[403 Forbidden] I[&#34;管理员&#34;] --> J[&#34;GET /api/admin/products&#34;] J --> G G --> K[可查询 DRAFT / ON_SALE / OFF_SHELF]

公开详情不是"谁都能看所有商品",而是"匿名也能看已上架商品"。后台接口才允许管理员查看草稿、下架等运营状态。

5.4 商品详情数据组装图

flowchart TB A[mall_product] -->|ProductEntity| C[ProductFacade.toDetailResponse] B[mall_category] -->|CategoryEntity| C C --> D[ProductDetailResponse] D --> E[ApiResponse.success] E --> F[前端商品详情页] A -.字段.-> A1[id/category_id/title/subtitle/description/status_code/created_by/created_at/updated_at] B -.字段.-> B1[id/name/status/sort_order] D -.字段.-> D1[id/categoryId/categoryName/title/subtitle/description/status/createdBy/createdAt/updatedAt]

前端拿到的 categoryName 来自分类表,status 是由商品表里的 status_code 转成 ProductStatus 枚举,响应 DTO 不是数据库表的简单复制。

6. 逐段读源码

6.1 SecurityConfig:为什么匿名用户能访问商品详情

在第 6 篇我们讲过 Spring Security 位于 Controller 之前。商品详情接口虽然不需要登录,但仍然经过 Security。当前项目在白名单里配置了:

java 复制代码
.requestMatchers(
    "/api/products",
    "/api/products/**"
).permitAll()

所以 GET /api/products/1 不带 token 也能进入 Controller。这里要区分两个概念:

  1. 访问接口是否需要登录;
  2. 接口内部返回哪些业务数据。

公开商品详情不需要登录,但业务层仍然只返回 ON_SALE 商品。如果商品是草稿或下架,即使 URL 允许匿名访问,DbService 也会按不存在处理。这就是"安全访问规则"和"业务可见性规则"的配合。

6.2 TraceIdFilter:请求一进来先有 traceId

TraceIdFilter 使用 @Order(Ordered.HIGHEST_PRECEDENCE),说明它非常早执行。它会读取请求头 X-Trace-Id,如果合法就透传,如果不合法或没有,就生成一个 UUID 去掉横杠后的字符串。

java 复制代码
request.setAttribute(TRACE_ID_ATTRIBUTE, traceId);
response.setHeader(TRACE_ID_HEADER, traceId);

这两行让同一个 traceId 同时出现在 request attribute 和 response header。后续 Controller 可以通过 TraceIdContext.get(request) 把它写进 ApiResponse;异常处理器也可以用同一个 traceId 返回错误响应。

前端侧可以这样传:

ts 复制代码
// 前端侧示意代码:主动传 traceId,方便前后端联调定位
http.get('/api/products/1', {
  headers: {
    'X-Trace-Id': 'product001trace'
  }
})

当前项目要求 traceId 只包含字母、数字、下划线、短横线,长度 8~64。非法值会被后端替换,避免外部传入任意字符串污染日志。

6.3 ProductController:公开商品 HTTP 入口

ProductController 上有:

java 复制代码
@RestController
@RequestMapping("/api/products")
@Tag(name = "公开商品")
public class ProductController

这表示它负责公开商品接口。详情方法是:

java 复制代码
@GetMapping("/{id}")
@Operation(summary = "查询已上架商品详情")
public ApiResponse<ProductDetailResponse> detail(
        @PathVariable Long id,
        HttpServletRequest servletRequest
) {
    ProductDetailResponse response = productFacade.getPublishedProduct(id);
    return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}

这里有几个点:

  • @GetMapping("/{id}") 匹配 /api/products/1
  • @PathVariable Long id 把路径里的 1 转成 Java Long
  • Controller 不直接查 Redis,也不直接查 MySQL;
  • Controller 调用的是接口 IProductFacade,不是具体实现类;
  • 成功响应统一包装成 ApiResponse<ProductDetailResponse>
  • traceId 从 request 中读取,随响应体返回。

如果前端传 /api/products/abc,路径变量无法转成 Long,请求可能在参数绑定阶段失败;如果传 /api/products/999999,参数能绑定成功,但业务层查不到商品,会返回业务 404。这两个错误位置不同,排查时要区分。

6.4 IProductFacade:Controller 依赖的是用例契约

IProductFacade 定义了商品模块对外提供的用例,包括创建商品、变更状态、查询后台列表、查询公开列表、查询公开详情、修改副标题等。公开详情方法的注释写着:

java 复制代码
/**
 * 查询公开商品详情;商品未上架或分类停用时按不存在处理。
 */
ProductDetailResponse getPublishedProduct(Long productId);

这句注释非常关键:它把业务语义写在契约上。公开详情不是"按 ID 查任何商品",而是"查询公开商品详情"。商品未上架、分类停用,对匿名用户来说都按不存在处理。这种契约注释能帮助前后端对齐预期:前端不要因为后台能看到草稿,就期待公开详情也能看到草稿。

6.5 ProductFacade.getPublishedProduct:链路的大脑

核心代码如下:

java 复制代码
ProductDetailCacheLookup cacheLookup = productDetailCacheService.lookup(productId);
if (cacheLookup.getStatus() == ProductDetailCacheStatus.HIT) {
    return cacheLookup.getResponse();
}
if (cacheLookup.getStatus() == ProductDetailCacheStatus.NULL_HIT) {
    ApiCode apiCode = cacheLookup.getNullApiCode();
    throw new BusinessException(apiCode, apiCode.defaultMessage(), HttpStatus.NOT_FOUND);
}

第一步先查缓存。命中正常详情就直接返回;命中空值就恢复原来的 404 业务异常。这意味着 Redis 里不仅能保存成功数据,也能保存"这个 ID 当前不存在或不可见"的短 TTL 结论。

接着:

java 复制代码
// MISS、DEGRADED 和 BYPASS 都回源 MySQL;Redis 不是商品的真实数据来源。
try {
    ProductEntity product = productDbService.requirePublishedById(productId);
    CategoryEntity category = categoryDbService.requireEnabledCategory(product.getCategoryId());
    ProductDetailResponse response = toDetailResponse(product, category);
    productDetailCacheService.put(productId, response);
    return response;
} catch (BusinessException exception) {
    if (exception.getCode() == ApiCode.PRODUCT_NOT_FOUND
            || exception.getCode() == ApiCode.CATEGORY_NOT_FOUND) {
        productDetailCacheService.putNull(productId, exception.getCode());
    }
    throw exception;
}

这里是完整 Cache Aside:MISS 说明 Redis 没有;DEGRADED 说明 Redis 异常;BYPASS 说明配置关闭缓存。三者都不应该直接失败,而是查 MySQL。查到后回填缓存,查不到稳定 404 时写空值缓存,然后继续把原业务异常抛出去。

注意 Facade 没有在 catch 里把异常吞掉,也没有把所有异常都缓存成空值。它只缓存 PRODUCT_NOT_FOUNDCATEGORY_NOT_FOUND。其他业务冲突或系统错误不能被伪装成"商品不存在"。

6.6 ProductDetailCacheService.lookup:Redis 分支如何产生

ProductDetailCacheService 的 key 前缀是:

java 复制代码
private static final String KEY_PREFIX = "mall:product:detail:v1:";

商品 ID 为 1 的 key 是:

text 复制代码
mall:product:detail:v1:1

lookup 先判断缓存是否启用。未启用返回 BYPASS。启用时通过 RedisOperatorClient.get(key) 读取 Redis。读取异常返回 DEGRADED。读取结果为 null 返回 MISS。如果 value 以 __NULL__: 开头,就解析业务码,合法返回 NULL_HIT,非法删除坏缓存并返回 MISS。如果是普通 JSON,就反序列化成 ProductDetailResponse,成功返回 HIT,失败删除坏缓存并返回 MISS

这段代码体现了后端缓存的工程质量:

  1. 状态明确,不让调用方猜;
  2. Redis 故障可降级;
  3. 坏缓存会被清理;
  4. 空值缓存和正常缓存区分;
  5. 缓存开关可配置;
  6. 日志中记录 HITMISSPUTDEGRADED 等事件。

6.7 ProductDbService.requirePublishedById:公开可见性由后端强制

数据库访问方法是:

java 复制代码
public ProductEntity requirePublishedById(Long productId) {
    ProductEntity product = lambdaQuery()
            .eq(ProductEntity::getId, productId)
            .eq(ProductEntity::getStatusCode, ProductStatus.ON_SALE.name())
            .one();
    if (product == null) {
        throw productNotFound();
    }
    return product;
}

这大致对应 SQL:

sql 复制代码
SELECT *
FROM mall_product
WHERE id = ?
  AND status_code = 'ON_SALE'
LIMIT 1;

所以种子数据里的 2 号商品 Nintendo Switch OLEDDRAFT,3 号商品 Java 核心技术卷 IOFF_SHELF,匿名访问 /api/products/2/api/products/3 都应该返回 PRODUCT_NOT_FOUND。这不是因为数据库没有这些行,而是公开详情不允许看。

6.8 CategoryDbService.requireEnabledCategory:分类停用也按不存在处理

商品属于某个分类。即使商品本身是 ON_SALE,如果分类被停用,公开详情也不应该展示。分类查询逻辑是:

java 复制代码
public CategoryEntity requireEnabledCategory(Long categoryId) {
    CategoryEntity category = requireCategory(categoryId);
    if (category.getStatus() != ENABLED) {
        throw categoryNotFound();
    }
    return category;
}

ENABLED 是 1。分类不存在或状态不是 1,都抛 CATEGORY_NOT_FOUND,HTTP 状态 404。对前端来说,这种处理很友好:公开详情页不需要知道"商品不存在"和"分类停用"的内部差异,可以统一展示"商品不可访问"或"商品不存在"。但响应 code 仍然保留更具体的业务码,便于调试。

6.9 toDetailResponse:从 Entity 组装响应 DTO

Facade 最终调用 toDetailResponse(product, category)。它把商品表和分类表字段组装成:

java 复制代码
new ProductDetailResponse(
    product.getId(),
    product.getCategoryId(),
    category.getName(),
    product.getTitle(),
    product.getSubtitle(),
    product.getDescription(),
    ProductStatus.valueOf(product.getStatusCode()),
    product.getCreatedBy(),
    product.getCreatedAt(),
    product.getUpdatedAt()
)

这里要注意 3 个转换:

  1. category.getName() 变成响应里的 categoryName
  2. status_code 字符串通过 ProductStatus.valueOf 转成枚举;
  3. created_atupdated_at 保持为 LocalDateTime,由 Spring Boot 的 JSON 配置序列化。

前端拿到的 ProductDetailResponse 是后端面向页面设计的 DTO,不是数据库 Entity。DTO 可以包含组合字段,也可以隐藏内部字段。不要把"数据库表结构"和"接口响应结构"强行等同。

6.10 ApiResponse.success:统一成功响应

Controller 最后返回:

java 复制代码
ApiResponse.success(response, TraceIdContext.get(servletRequest))

成功响应结构来自 ApiResponse

java 复制代码
private String code;
private String message;
private T data;
private String traceId;
private Instant timestamp;

所以前端不会直接收到商品对象,而是收到一个统一信封:

json 复制代码
{
  "code": "SUCCESS",
  "message": "操作成功",
  "data": {
    "id": 1,
    "categoryId": 1,
    "categoryName": "手机数码",
    "title": "九成新 iPhone 15"
  },
  "traceId": "...",
  "timestamp": "..."
}

前端封装 API client 时,应该明确是返回整个 ApiResponse,还是在拦截器里拆出 data.data。无论哪种方式,都要保留错误时的 codetraceId,方便排查。

6.11 GlobalExceptionHandler:业务失败如何变成 JSON

如果商品不存在,ProductDbServiceBusinessException(PRODUCT_NOT_FOUND, 404)。这个异常不会在 Controller 里手动 catch,而是由 GlobalExceptionHandler 统一处理:

java 复制代码
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(...) {
    ApiResponse<Void> response = ApiResponse.error(
            exception.getCode(),
            exception.getMessage(),
            null,
            TraceIdContext.get(request)
    );
    return ResponseEntity.status(exception.getHttpStatus()).body(response);
}

这就是为什么业务层只需要抛异常,前端仍然能拿到统一 JSON。它也解释了为什么后端项目不应该到处 return nullreturn false 表示失败:异常和统一处理器能让失败路径更清晰。

7. 本地运行 / curl / Redis 验证

下面假设你已经启动 MySQL、Redis 和后端服务:

bash 复制代码
docker compose -f docker-compose.dev.yml up -d mysql redis
mvn -pl service -am spring-boot:run

7.1 请求已上架商品详情

种子数据里,1 号商品是 ON_SALE

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

预期 HTTP 200,响应体 code=SUCCESSdata.title 类似 九成新 iPhone 15data.categoryName 类似 手机数码。因为 /api/products/** 允许匿名访问,这个请求不需要 token。

7.2 第二次请求观察缓存命中

连续请求两次:

bash 复制代码
curl -sS 'http://localhost:8080/api/products/1' > /tmp/product-1-first.json
curl -sS 'http://localhost:8080/api/products/1' > /tmp/product-1-second.json

第一次如果 Redis 没有 key,日志应出现 MISSPUT;第二次应出现 HIT。你也可以用 Redis 查看:

bash 复制代码
docker exec -it fullstack-mall-redis redis-cli get mall:product:detail:v1:1

如果返回 JSON 字符串,说明商品详情缓存已经写入。

7.3 查看 TTL

bash 复制代码
docker exec -it fullstack-mall-redis redis-cli ttl mall:product:detail:v1:1

正常商品详情默认 TTL 是 10 分钟,所以你会看到一个小于等于 600 的秒数。每过一秒,这个值会减少。TTL 到期后,下次请求会重新 MISS 并回源 MySQL。

7.4 请求草稿商品,应该返回 404

种子数据里 2 号商品是 DRAFT

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

预期返回 404,响应体 code=PRODUCT_NOT_FOUND。注意:数据库里确实有 2 号商品,但公开详情强制 ON_SALE,所以按不存在处理。

7.5 请求下架商品,应该返回 404

种子数据里 3 号商品是 OFF_SHELF

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

预期同样是 404。这能证明公开详情接口不是按 ID 无条件查询,而是有可见性规则。

7.6 请求不存在商品,观察空值缓存

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

第一次会查 MySQL 并写入短 TTL 空值。查看 Redis:

bash 复制代码
docker exec -it fullstack-mall-redis redis-cli get mall:product:detail:v1:999999

可能看到:

text 复制代码
__NULL__:PRODUCT_NOT_FOUND

再请求一次 /api/products/999999,应命中 NULL_HIT,不再重复查 MySQL。

7.7 手动删除缓存再请求

bash 复制代码
docker exec -it fullstack-mall-redis redis-cli del mall:product:detail:v1:1
curl -i 'http://localhost:8080/api/products/1'

删除后再次请求会重新构建缓存。这和管理员写操作成功后调用 productDetailCacheService.evict(productId) 是同一个思路。

7.8 带 traceId 请求,观察响应头和响应体

bash 复制代码
curl -i 'http://localhost:8080/api/products/1' \
  -H 'X-Trace-Id: product001trace'

你应该能在响应头 X-Trace-Id 和响应体 traceId 里看到同一个值。前后端联调时,这个值可以帮助定位日志。

7.9 Redis 停掉后验证降级

bash 复制代码
docker stop fullstack-mall-redis
curl -i 'http://localhost:8080/api/products/1'
docker start fullstack-mall-redis

如果 MySQL 正常,商品详情应尽量还能返回,只是日志里会出现 DEGRADED。这证明 Redis 对该接口是性能层,不是硬依赖。

8. 常见错误

8.1 以为 /api/products/{id} 会返回任何状态商品

公开详情只返回 ON_SALE。草稿和下架商品对匿名用户按不存在处理。如果你在数据库里看到商品存在,但接口 404,先检查 status_code 是否为 ON_SALE

8.2 忽略分类状态

商品是 ON_SALE 不代表一定能公开展示。如果分类不存在或停用,CategoryDbService.requireEnabledCategory 会抛 CATEGORY_NOT_FOUND。排查公开详情 404 时,要同时查商品和分类。

8.3 把 Redis MISS 当成商品不存在

MISS 只表示 Redis 没有缓存,不表示 MySQL 没有数据。当前项目 MISS 后会回源 MySQL。只有数据库也查不到,或缓存命中合法空值标记时,才返回业务 404。

8.4 Redis 出错时直接返回 500

商品详情缓存是性能层,Redis 读失败时应该降级查 MySQL。当前项目返回 DEGRADED 并继续回源。如果你以后改代码时把 Redis 异常直接抛出去,商品详情接口稳定性会变差。

8.5 修改商品后忘记删除缓存

如果后台修改了标题、副标题、描述、分类或状态,却没有删除 mall:product:detail:v1:{id},用户可能继续看到旧详情。后续第 15 篇做"管理员修改商品副标题"时,会专门把写后缓存失效串起来。

8.6 Controller 里堆太多逻辑

Controller 应该接参数、调用 Facade、包装响应。不要把 Redis 查询、MySQL 查询、分类校验、DTO 组装都写在 Controller 里。否则接口很快变得难测、难复用、难排查。

8.7 前端只看 HTTP 状态,不看业务 code

HTTP 404 能告诉你资源不可访问,code=PRODUCT_NOT_FOUNDCATEGORY_NOT_FOUND 能告诉你更细原因。前端逻辑应优先依赖稳定业务 code,不要解析 message 文案。

8.8 忘记保留 traceId

线上排查时,用户只说"商品详情打不开"通常不够。前端应在错误日志或反馈中保留 traceId,后端才能快速定位对应请求日志。

8.9 把 Entity 直接返回给前端

当前项目返回 ProductDetailResponse,不是直接返回 ProductEntity。DTO 能隐藏内部字段、组合分类名、转换枚举,是接口契约的一部分。直接返回 Entity 会让数据库结构泄露到前端,后续改表也更困难。

8.10 用前端参数决定公开状态

公开商品列表里,即使前端传 status=OFF_SHELF,后端也会强制只查 ON_SALE。公开详情同理,状态由后端查询条件决定。不要把业务可见性建立在前端自觉传参上。

8.11 调试清单:商品详情打不开时按什么顺序查

前端同学刚开始排查后端问题时,最容易犯的错是直接盯着某一段代码看。例如看到页面提示"商品不存在",马上去数据库查 mall_product 是否有这条记录;看到页面展示旧标题,马上怀疑接口没有重新部署;看到偶发失败,马上怀疑 Redis 不稳定。真实项目里更高效的方式是按请求链路从外到内排查,每一层只回答一个很小的问题。

第一步先确认请求有没有到正确接口。浏览器 Network 面板或 curl 里要看清楚 URL 是 /api/products/1,不是 /api/admin/products/1,也不是少了网关前缀的 /products/1。公开详情走的是 ProductController.detail,管理详情才走管理端 Controller。路径错了,后面所有数据库和缓存分析都没有意义。

第二步看权限。当前项目中 /api/products/api/products/** 是匿名白名单,所以没有登录也能访问公开商品。如果你拿到的是 401403,优先检查请求路径是否进入了 /api/admin/**,或者安全配置是否被改动。这个思路和前端路由守卫很像:先判断页面是不是被错误地归到"需要登录"的路由组里。

第三步看响应体的 codetraceId。如果 HTTP 状态是 404,但业务 code 是 PRODUCT_NOT_FOUND,说明后端认为"公开可见商品不存在";如果是 CATEGORY_NOT_FOUND,说明商品本身可能查到了,但关联分类不存在或被禁用。traceId 则用于把前端看到的一次失败和后端日志里的同一次请求对应起来。

第四步看 Redis。若商品详情展示旧数据,先查 mall:product:detail:v1:{id} 是否存在。如果存在旧 JSON,说明读链路命中了缓存,接下来要查写链路是否在商品更新、状态变更后执行了 evict。如果 Redis 没有 key,却仍然旧,才继续怀疑数据库种子、事务提交、连接环境等问题。

第五步看 MySQL 查询条件。mall_product 里有记录不代表公开详情可见,requirePublishedById 还要求 status_code = ON_SALE。同理,category_id 对应分类存在也不够,还要求分类启用。很多"数据库明明有数据"的疑问,本质上是业务条件没有满足。

第六步看 DTO 组装。接口能返回但字段缺失时,要对照 ProductDetailResponsetoDetailResponse,确认这个字段是否本来就没有暴露、是否来自商品表、是否来自分类表、是否需要格式转换。不要只看 Entity,因为 Entity 是持久化模型,不等于 API 契约。

下面这张图可以作为你排查商品详情问题时的顺序表:

flowchart TD A[页面提示详情异常] --> B{URL 是否是公开详情路径} B -- 否 --> B1[修正前端请求地址或网关前缀] B -- 是 --> C{HTTP 是否 401/403} C -- 是 --> C1[检查 SecurityConfig 白名单和请求路径] C -- 否 --> D[记录 code 与 traceId] D --> E{是否怀疑旧数据} E -- 是 --> E1[检查 Redis key 和写后 evict] E -- 否 --> F[检查 MySQL 商品状态 ON_SALE] F --> G[检查分类是否存在且启用] G --> H[检查 DTO 组装和字段契约] H --> I[用 traceId 回到日志确认完整链路]

这个清单的价值不只是解决商品详情问题。以后你排查订单创建、支付回调、购物车异常,也可以沿用同一种后端思维:先入口,再权限,再业务 code,再缓存,再数据库,再 DTO,再日志。前端排查常从组件状态出发,后端排查常从请求链路出发。两者的共同点是不要凭感觉跳步,要让每一步都有证据。

8.12 用测试反向理解链路规则

学习后端源码时,除了顺着 Controller 往下读,还有一个非常有效的方法:先读测试,再回到实现。测试通常会把"这个接口到底承诺了什么"表达得更直接。比如 ProductControllerTest.shouldExposeOnlyOnSaleProductsToAnonymousUsers 不是在测试某个具体 SQL 写法,而是在测试公开列表只暴露已上架商品;ProductControllerTest.shouldHideDraftProductDetailFromAnonymousUsers 不是在测试数据库里有没有 2 号商品,而是在测试草稿商品即使存在,也不能被匿名用户通过公开详情拿到。

从前端角度类比,测试就像你给组件写的交互用例:用户点击按钮后是否出现弹窗,输入非法内容是否出现错误提示。后端测试关注的是另一类交互:匿名请求某个 URL 后,状态码、业务 code、响应 data、数据库变化、缓存变化是否符合规则。你不用一开始就会写很复杂的测试,但要学会通过测试识别业务边界。

以商品详情为例,可以把测试分成三层理解。第一层是 Controller 层测试,证明 HTTP 契约没有变:路径、方法、状态码、JSON 字段、匿名访问规则都符合预期。第二层是 Facade 层测试,证明业务编排没有变:缓存命中时不查库,缓存未命中时查库并写缓存,空值命中时转成 404。第三层是 CacheService 层测试,证明 Redis key、TTL、空值标记和降级状态没有变。

flowchart LR A[ControllerTest 验证 HTTP 契约] --> B[ProductFacadeCacheTest 验证业务编排] B --> C[ProductDetailCacheServiceTest 验证 Redis 行为] C --> D[RedisContainerTest 验证真实 Redis 连接]

这种分层测试的好处是定位问题快。假设某次改动后公开详情把草稿商品返回给匿名用户了,如果 Controller 测试失败,说明对外契约被破坏;如果 Facade 测试失败,说明业务状态过滤或缓存分支被破坏;如果 CacheService 测试失败,说明缓存读写格式或异常降级被破坏。测试名字本身就是文档,读懂这些名字,你就能反推项目作者希望保护哪些规则。

作为前端转后端的学习者,你可以先不追求覆盖率指标,而是养成两个习惯:第一,读源码前先找同名测试,看看测试描述了哪些行为;第二,改任何业务规则前先想"应该新增或修改哪条测试来保护这个规则"。这样你会更快从"会写接口"进入"会维护接口契约"的阶段。

8.13 为什么本章没有直接让你写新接口

很多教程会在讲完 Controller 后马上让你新增一个接口,但本系列第 8 篇选择先完整阅读商品详情链路,是因为后端学习最重要的不是记住某个注解,而是建立链路感。没有链路感时,你可能知道 @GetMapping@PathVariablelambdaQueryRedisTemplate 分别怎么用,却不知道它们在一个真实请求中如何协作;也可能能写出一个能跑的接口,但不知道权限、缓存、异常、DTO、日志应该放在哪一层。

本项目的商品详情接口足够小,也足够完整。它没有订单支付那样复杂的事务,也没有库存扣减那样高的并发风险,但已经包含后端入门必须理解的大部分元素:公开接口、路径参数、统一响应、业务异常、枚举状态、MySQL 查询、关联表校验、Redis 缓存、空值缓存、降级回源、DTO 转换和 traceId。把这一条链路读熟,再去看新增商品、上架下架、购物车和订单,就不会觉得每个类都是孤立的。

你可以把本章当成一次"源码走读训练"。真正掌握的标准不是能背出每个方法名,而是给你一个现象时,你知道应该从哪里开始查;给你一个需求时,你知道应该改 Controller、Facade、Service、Mapper、DTO、异常码还是测试。下一章开始进入写操作后,这种判断会更重要,因为写操作一旦处理不好,就不只是页面显示错,而可能造成脏数据、缓存不一致或越权修改。

9. 本章小练习

练习 1:手动画出 /api/products/1 成功链路

TraceIdFilter 开始,依次画出 Security、Controller、Facade、CacheService、Redis、ProductDbService、CategoryDbService、MySQL、DTO、ApiResponse。要求标出哪些步骤可能跳过 MySQL。

练习 2:解释 2 号商品为什么公开详情 404

阅读 sql/02_seed.sql 中 2 号商品的 status_code,再阅读 ProductDbService.requirePublishedById。用自己的话解释:为什么数据库有这条记录,但 /api/products/2 仍然返回 PRODUCT_NOT_FOUND

练习 3:对照 DTO 写前端类型

根据 ProductDetailResponse.java,写一个"前端侧示意代码"的 TypeScript interface。注意 createdAtupdatedAt 在 JSON 中通常是字符串,不是前端 Date 对象。

ts 复制代码
// 前端侧示意代码:根据后端 DTO 写类型
interface ProductDetail {
  id: number
  categoryId: number
  categoryName: string
  title: string
  subtitle: string
  description: string | null
  status: 'DRAFT' | 'ON_SALE' | 'OFF_SHELF'
  createdBy: number
  createdAt: string
  updatedAt: string
}

练习 4:验证缓存状态

请求 /api/products/1 两次,观察日志里的 MISSPUTHIT。然后删除 Redis key,再请求一次,确认又回到 MISS。

练习 5:验证空值缓存

请求 /api/products/999999 两次,并用 redis-cli get mall:product:detail:v1:999999 查看空值标记。解释为什么空值缓存 TTL 比正常详情 TTL 短。

练习 6:从响应 traceId 找后端日志

用 curl 带上 X-Trace-Id: product001trace 请求商品详情。然后在后端日志中搜索这个 traceId 或缓存事件,练习一次前后端联调排查。

练习 7:找测试证明链路规则

阅读 ProductControllerTest.shouldHideDraftProductDetailFromAnonymousUsers,说明它验证了哪条业务规则。再阅读 ProductFacadeCacheTest.shouldQueryDatabaseAndFillCacheOnMiss,说明它验证了哪条缓存规则。

10. 再深入一点:列表接口和详情接口为什么不完全一样

同样在 ProductController 里,公开列表接口是:

java 复制代码
@GetMapping
public ApiResponse<PageResponse<ProductSummaryResponse>> queryProducts(...)

它调用 productFacade.queryPublishedProducts(request)。详情接口是:

java 复制代码
@GetMapping("/{id}")
public ApiResponse<ProductDetailResponse> detail(...)

它调用 productFacade.getPublishedProduct(id)。两者都只面向公开已上架商品,但缓存策略不同。当前项目只给详情接口加了 Redis 缓存,没有给列表接口加缓存。为什么?因为列表查询条件更多:页码、每页数量、关键字、分类都可能变化;列表还涉及排序和分页,总 key 设计会更复杂。详情接口只有一个稳定 ID,更适合作为入门缓存案例。

列表接口中还有一个值得学习的点:toPageResponse 会先从商品分页结果里收集所有 categoryId,再用 categoryDbService.findCategoryMap(categoryIds) 一次性批量查询分类,避免每个商品单独查一次分类造成 N+1 查询。详情接口只查一个商品,所以直接查询一次分类即可。

这说明后端性能优化不是只有 Redis。批量查询、索引、分页、避免 N+1、减少不必要字段、合理 DTO 组装,都是性能优化的一部分。Redis 是很重要的工具,但不是所有接口都应该第一时间加缓存。

11. 下一章预告:商品新增、上架、下架与状态机

本篇完整读完了公开商品详情的查询链路。下一篇我们会转向管理端写操作:商品新增、上架、下架与状态机。

写操作和读操作最大的不同是:读操作主要关心"如何查得快、查得准、错误怎么返回";写操作要额外关心"谁能写、能写成什么状态、写之前要校验什么、写成功后要影响哪些缓存、失败时能不能部分成功"。当前项目里,管理员创建商品只能创建 DRAFT,不能由前端直接伪造成 ON_SALE;商品从草稿上架前必须校验分类和可售 SKU;商品状态只能按允许的状态机转换;状态变化成功后还要删除商品详情缓存。

下一章你会看到,后端状态机和前端按钮状态不是一回事。前端可以禁用按钮来提升体验,但真正防止非法状态流转的必须是后端。我们会继续基于 ProductAdminControllerProductFacade.changeStatusProductStatusSkuDbService.requireSaleableSku 和缓存失效逻辑逐段阅读。

12. 本篇总结

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

  1. 一个后端接口不是 Controller 一个方法,而是 Filter、Security、Controller、Facade、缓存、数据库、异常处理、JSON 序列化组成的链路;
  2. /api/products/{id} 允许匿名访问,是因为 SecurityConfig 明确对白名单 /api/products/** 做了 permitAll
  3. 匿名访问不等于返回所有商品,公开详情由 ProductDbService.requirePublishedById 强制限定 ON_SALE
  4. 商品详情响应不是单表结果,categoryName 来自分类表,必须通过 CategoryDbService.requireEnabledCategory 组装;
  5. Redis 是性能层,HIT 可直接返回,MISSDEGRADEDBYPASS 都要回源 MySQL;
  6. NULL_HIT 是短 TTL 空值缓存,用于把稳定 404 快速恢复为业务异常;
  7. ProductDetailResponse 是面向前端的 DTO,不应该直接把 ProductEntity 暴露出去;
  8. 成功响应由 ApiResponse.success 统一包装,包含 codemessagedatatraceIdtimestamp
  9. 业务异常由 GlobalExceptionHandler 统一转成错误 JSON,前端应关注稳定 codetraceId
  10. 排查商品详情问题时,要按链路逐层看:URL 和权限、路径参数、缓存状态、商品状态、分类状态、DTO 组装、异常响应。

如果你能不用看文档,自己从 /api/products/1 追到 mall_productmall_category 和 Redis key mall:product:detail:v1:1,再能解释 /api/products/2 为什么 404,就说明你已经具备阅读真实后端请求链路的基本能力。

相关推荐
phltxy1 小时前
LangChain_v1_Agent快速开发和更新说明
前端·javascript·langchain
网易云信1 小时前
企业级 IM,不是功能更多,而是场景更对
人工智能·后端
Hyyy1 小时前
Electron多进程
前端
AmazingEgg1 小时前
Eggblog博客部署文档
后端
程序员天天困1 小时前
Arthas mc + retransform 实战:线上改完代码不用重新发版
jvm·后端
光影少年2 小时前
RN原生交互 & 桥接
前端·javascript·react native·react.js·前端框架
东风破_2 小时前
React 状态更新为什么不是立即赋值?从 useState、状态快照到更新队列
前端
洛卡卡了2 小时前
从 vibe coding 到 spec coding:我用 Trellis 的实践总结
人工智能·后端·agent
GuWenyue2 小时前
踩坑无数!吃透useState 3个核心技巧,彻底解决状态旧值、性能卡顿问题
前端·javascript·react.js