本篇面向已经完成前 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 表返回"这么简单。当前项目为了保证公开可见性、性能和统一错误处理,至少经过这些步骤:
- 请求进入 Spring Boot,先经过
TraceIdFilter生成或透传X-Trace-Id; - 请求经过 Spring Security,因为
/api/products/**在白名单中,所以匿名用户也能访问; ProductController.detail通过@PathVariable Long id绑定路径参数;- Controller 调用
IProductFacade.getPublishedProduct(id); ProductFacade先调用ProductDetailCacheService.lookup(id)查询 Redis;- 如果缓存
HIT,直接返回ProductDetailResponse; - 如果缓存
NULL_HIT,恢复业务 404; - 如果缓存
MISS、DEGRADED或BYPASS,回源 MySQL; ProductDbService.requirePublishedById强制商品状态必须是ON_SALE;CategoryDbService.requireEnabledCategory强制分类必须启用;- Facade 把
ProductEntity和CategoryEntity组装成ProductDetailResponse; - 查询成功后把响应 JSON 回填 Redis;
- 如果商品不存在、未上架、分类不存在或停用,抛
BusinessException并写短 TTL 空值缓存; - Controller 用
ApiResponse.success包装成功响应; 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 注释中写得很清楚:MISS、DEGRADED 和 BYPASS 都回源 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 |
定义 DRAFT、ON_SALE、OFF_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_category、mall_product 表和索引 |
| 种子数据 | sql/02_seed.sql |
提供 ON_SALE、DRAFT、OFF_SHELF 演示商品 |
| 测试 | ProductControllerTest.java、ProductFacadeCacheTest.java、ProductMapperTest.java |
验证公开可见性、缓存分支和数据库映射 |
这张表本身就是你以后读后端接口的路线图:先找 Controller,再找 Facade,再找 DbService 和缓存服务,再找 DTO、Entity、SQL、测试。不要一上来全局搜索所有同名字段,那样很容易迷路。
5. Mermaid 图:把完整链路画出来
5.1 成功请求全链路
5.2 缓存分支决策图
5.3 公开详情和管理详情的权限差异
公开详情不是"谁都能看所有商品",而是"匿名也能看已上架商品"。后台接口才允许管理员查看草稿、下架等运营状态。
5.4 商品详情数据组装图
前端拿到的 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。这里要区分两个概念:
- 访问接口是否需要登录;
- 接口内部返回哪些业务数据。
公开商品详情不需要登录,但业务层仍然只返回 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转成 JavaLong;- 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_FOUND 和 CATEGORY_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。
这段代码体现了后端缓存的工程质量:
- 状态明确,不让调用方猜;
- Redis 故障可降级;
- 坏缓存会被清理;
- 空值缓存和正常缓存区分;
- 缓存开关可配置;
- 日志中记录
HIT、MISS、PUT、DEGRADED等事件。
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 OLED 是 DRAFT,3 号商品 Java 核心技术卷 I 是 OFF_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 个转换:
category.getName()变成响应里的categoryName;status_code字符串通过ProductStatus.valueOf转成枚举;created_at、updated_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。无论哪种方式,都要保留错误时的 code 和 traceId,方便排查。
6.11 GlobalExceptionHandler:业务失败如何变成 JSON
如果商品不存在,ProductDbService 抛 BusinessException(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 null 或 return 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=SUCCESS,data.title 类似 九成新 iPhone 15,data.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,日志应出现 MISS 和 PUT;第二次应出现 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_FOUND 或 CATEGORY_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/** 是匿名白名单,所以没有登录也能访问公开商品。如果你拿到的是 401 或 403,优先检查请求路径是否进入了 /api/admin/**,或者安全配置是否被改动。这个思路和前端路由守卫很像:先判断页面是不是被错误地归到"需要登录"的路由组里。
第三步看响应体的 code 和 traceId。如果 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 组装。接口能返回但字段缺失时,要对照 ProductDetailResponse 和 toDetailResponse,确认这个字段是否本来就没有暴露、是否来自商品表、是否来自分类表、是否需要格式转换。不要只看 Entity,因为 Entity 是持久化模型,不等于 API 契约。
下面这张图可以作为你排查商品详情问题时的顺序表:
这个清单的价值不只是解决商品详情问题。以后你排查订单创建、支付回调、购物车异常,也可以沿用同一种后端思维:先入口,再权限,再业务 code,再缓存,再数据库,再 DTO,再日志。前端排查常从组件状态出发,后端排查常从请求链路出发。两者的共同点是不要凭感觉跳步,要让每一步都有证据。
8.12 用测试反向理解链路规则
学习后端源码时,除了顺着 Controller 往下读,还有一个非常有效的方法:先读测试,再回到实现。测试通常会把"这个接口到底承诺了什么"表达得更直接。比如 ProductControllerTest.shouldExposeOnlyOnSaleProductsToAnonymousUsers 不是在测试某个具体 SQL 写法,而是在测试公开列表只暴露已上架商品;ProductControllerTest.shouldHideDraftProductDetailFromAnonymousUsers 不是在测试数据库里有没有 2 号商品,而是在测试草稿商品即使存在,也不能被匿名用户通过公开详情拿到。
从前端角度类比,测试就像你给组件写的交互用例:用户点击按钮后是否出现弹窗,输入非法内容是否出现错误提示。后端测试关注的是另一类交互:匿名请求某个 URL 后,状态码、业务 code、响应 data、数据库变化、缓存变化是否符合规则。你不用一开始就会写很复杂的测试,但要学会通过测试识别业务边界。
以商品详情为例,可以把测试分成三层理解。第一层是 Controller 层测试,证明 HTTP 契约没有变:路径、方法、状态码、JSON 字段、匿名访问规则都符合预期。第二层是 Facade 层测试,证明业务编排没有变:缓存命中时不查库,缓存未命中时查库并写缓存,空值命中时转成 404。第三层是 CacheService 层测试,证明 Redis key、TTL、空值标记和降级状态没有变。
这种分层测试的好处是定位问题快。假设某次改动后公开详情把草稿商品返回给匿名用户了,如果 Controller 测试失败,说明对外契约被破坏;如果 Facade 测试失败,说明业务状态过滤或缓存分支被破坏;如果 CacheService 测试失败,说明缓存读写格式或异常降级被破坏。测试名字本身就是文档,读懂这些名字,你就能反推项目作者希望保护哪些规则。
作为前端转后端的学习者,你可以先不追求覆盖率指标,而是养成两个习惯:第一,读源码前先找同名测试,看看测试描述了哪些行为;第二,改任何业务规则前先想"应该新增或修改哪条测试来保护这个规则"。这样你会更快从"会写接口"进入"会维护接口契约"的阶段。
8.13 为什么本章没有直接让你写新接口
很多教程会在讲完 Controller 后马上让你新增一个接口,但本系列第 8 篇选择先完整阅读商品详情链路,是因为后端学习最重要的不是记住某个注解,而是建立链路感。没有链路感时,你可能知道 @GetMapping、@PathVariable、lambdaQuery、RedisTemplate 分别怎么用,却不知道它们在一个真实请求中如何协作;也可能能写出一个能跑的接口,但不知道权限、缓存、异常、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。注意 createdAt 和 updatedAt 在 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 两次,观察日志里的 MISS、PUT、HIT。然后删除 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;商品状态只能按允许的状态机转换;状态变化成功后还要删除商品详情缓存。
下一章你会看到,后端状态机和前端按钮状态不是一回事。前端可以禁用按钮来提升体验,但真正防止非法状态流转的必须是后端。我们会继续基于 ProductAdminController、ProductFacade.changeStatus、ProductStatus、SkuDbService.requireSaleableSku 和缓存失效逻辑逐段阅读。
12. 本篇总结
本篇你需要带走 10 个核心结论:
- 一个后端接口不是 Controller 一个方法,而是 Filter、Security、Controller、Facade、缓存、数据库、异常处理、JSON 序列化组成的链路;
/api/products/{id}允许匿名访问,是因为SecurityConfig明确对白名单/api/products/**做了permitAll;- 匿名访问不等于返回所有商品,公开详情由
ProductDbService.requirePublishedById强制限定ON_SALE; - 商品详情响应不是单表结果,
categoryName来自分类表,必须通过CategoryDbService.requireEnabledCategory组装; - Redis 是性能层,
HIT可直接返回,MISS、DEGRADED、BYPASS都要回源 MySQL; NULL_HIT是短 TTL 空值缓存,用于把稳定 404 快速恢复为业务异常;ProductDetailResponse是面向前端的 DTO,不应该直接把ProductEntity暴露出去;- 成功响应由
ApiResponse.success统一包装,包含code、message、data、traceId、timestamp; - 业务异常由
GlobalExceptionHandler统一转成错误 JSON,前端应关注稳定code和traceId; - 排查商品详情问题时,要按链路逐层看:URL 和权限、路径参数、缓存状态、商品状态、分类状态、DTO 组装、异常响应。
如果你能不用看文档,自己从 /api/products/1 追到 mall_product、mall_category 和 Redis key mall:product:detail:v1:1,再能解释 /api/products/2 为什么 404,就说明你已经具备阅读真实后端请求链路的基本能力。