GitHub仓库 :github.com/2530622506/...
01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?
02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?
03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
11|(前端转全栈)购物车不能只存在前端:用户维度数据如何在后端落库
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
13|(前端转全栈)支付成功不等于结束:回调、幂等、超时关单和状态竞争
14|(前端转全栈)商品详情高频访问怎么扛?Redis Cache Aside 实战
本篇面向前端工程师,基于当前
fullstack-mall后端真实工程讲一个完整小需求:管理员修改商品副标题。文中的前端代码只作为"前端侧示意代码",用于类比表单提交和接口调用。对应参考文档是docs/admin-update-product-subtitle-guide.md,对应真实代码包括ProductSubtitleUpdateRequest、ProductAdminController.updateSubtitle、ProductFacade.updateSubtitle、ProductDbService.updateSubtitle、ProductDetailResponse、ProductEntity、SecurityConfig、sql/01_schema.sql和缓存失效逻辑。
1. 这篇解决什么问题
很多前端转后端的同学第一次接后端需求时,会低估"加一个字段"的复杂度。前端页面里加副标题可能只是:表单多一个 input、列表多一列、详情页多一行展示。但在后端,一个字段要经过数据库、持久化对象、请求 DTO、响应 DTO、业务契约、业务实现、HTTP 入口、权限控制、参数校验、缓存失效、测试数据、接口验证和调试流程。
本篇用当前工程里的"管理员修改商品副标题"作为实战案例。这个功能的业务要求是:
- 只有管理员可以修改;
- 副标题必填,最多 100 个字符;
- 商品不存在返回明确业务错误;
- 修改成功后公开详情能看到新值;
- Redis 故障不能让已经成功的 MySQL 修改回滚;
- 修改成功后旧商品详情缓存要失效。
如果从前端视角看,需求像这样:
ts
// 前端侧示意代码:后台商品编辑页提交副标题,不是本仓库真实前端源码。
async function submitSubtitle(productId: number, subtitle: string) {
await request.patch(`/api/admin/products/${productId}/subtitle`, { subtitle })
showToast('修改成功')
}
但后端不能只看这一行请求。后端必须回答:这个接口放在哪个 Controller?普通用户能不能调?空字符串谁拦?超长谁拦?数据库字段有没有?Entity 有没有?Response 有没有?修改后缓存是否删除?如果 Redis 删除失败怎么办?这些问题串起来,就是完整后端开发流程。
2. 用前端知识类比
你可以把这个需求类比成前端从接口契约到页面状态的一次完整变更:
| 前端变更点 | 后端对应变更点 | 本项目文件 |
|---|---|---|
表单字段 subtitle |
请求 DTO 字段 | ProductSubtitleUpdateRequest |
| TypeScript interface 增加字段 | Response DTO 增加字段 | ProductDetailResponse |
| 表单校验 required / maxLength | @NotBlank / @Size |
ProductSubtitleUpdateRequest |
| API client 增加方法 | Facade 契约增加方法 | IProductFacade.updateSubtitle |
| 请求发到管理端路由 | Controller 增加 PATCH 接口 | ProductAdminController |
| 页面只允许管理员入口 | Security 对 /api/admin/** 要求 ADMIN |
SecurityConfig |
| mutation 成功后 invalidate query | MySQL 成功后删除 Redis 缓存 | ProductFacade.updateSubtitle |
| 页面重新拉取详情 | 下一次公开详情回源 MySQL | ProductDetailCacheService |
前端同学要特别注意:后端的"接口契约"比前端组件 props 更稳定。前端组件字段改错,影响一个页面;后端 Response 字段改错,所有调用方都会受到影响。后端数据库字段更是长期资产,一旦上线就不能随意删除或改类型。因此本篇不是只讲 @PatchMapping,而是讲一个字段在所有层里的流动。
3. 后端核心概念讲解
3.1 数据库字段是事实来源
副标题最终要保存在哪里?答案不是 Java 字段,也不是 Redis,更不是前端状态,而是 mall_product.subtitle。本项目在正式 SQL 和测试 SQL 中都增加了这个字段:
sql
subtitle VARCHAR(100) NOT NULL DEFAULT '' COMMENT '商品副标题'
为什么是 NOT NULL DEFAULT ''?因为老数据或旧创建逻辑可能没有副标题。如果直接加 NOT NULL 但没有默认值,插入商品时可能失败。默认空字符串是数据库兜底;而"管理员修改副标题接口"本身通过 @NotBlank 保证用户提交时不能为空。数据库兜底和接口校验不是互相替代,而是不同边界的保护。
前端可以把数据库默认值理解成接口返回字段的后备值,但不要把它当成用户输入校验。输入校验应该在请求入口做,数据库约束负责最后防线。
3.2 Request DTO 负责入口校验
ProductSubtitleUpdateRequest 很小:
java
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProductSubtitleUpdateRequest {
@NotBlank(message = "商品副标题不能为空")
@Size(max=100,message = "商品副标题不能超过100个字符")
private String subtitle;
}
它做了两件事:第一,告诉 Controller JSON 请求体应该是什么形状;第二,通过 Bean Validation 声明字段规则。@NotBlank 会拒绝 null、空字符串和只有空格的字符串;@Size(max=100) 会拒绝超过 100 个字符的字符串。
前端侧示意代码里你也可能写:
ts
// 前端侧示意代码:前端校验可以提升体验,但不能替代后端校验。
if (!subtitle.trim()) {
throw new Error('商品副标题不能为空')
}
if (subtitle.length > 100) {
throw new Error('商品副标题不能超过 100 个字符')
}
但是后端不能相信前端校验一定执行。用户可以用 curl、Postman、脚本、被篡改的页面直接请求接口。因此 @Valid @RequestBody ProductSubtitleUpdateRequest request 是后端入口必须有的防线。
3.3 Controller 只做 HTTP 入口,不写业务细节
ProductAdminController 里新增的接口是:
java
@PatchMapping("/{id}/subtitle")
@Operation(summary = "修改商品副标题")
public ApiResponse<ProductDetailResponse> updateSubtitle(
@PathVariable Long id,
@Valid @RequestBody ProductSubtitleUpdateRequest request,
HttpServletRequest servletRequest
) {
ProductDetailResponse response = productFacade.updateSubtitle(id, request);
return ApiResponse.success(response, TraceIdContext.get(servletRequest));
}
这段代码体现了项目风格:Controller 处理 URL、HTTP 方法、路径参数、请求体校验和统一响应;真正业务交给 Facade。Controller 不直接写 MyBatis-Plus,不直接操作 Redis,也不自己判断商品是否存在。前端可以把 Controller 类比成路由入口,而 Facade 才是业务组合函数。
3.4 Facade 是业务编排层
ProductFacade.updateSubtitle 做的事很清楚:
String subtitle = request.getSubtitle().trim();去掉前后空格;productDbService.updateSubtitle(productId, subtitle);修改数据库;productDetailCacheService.evict(productId);删除公开详情缓存;- 查询分类并组装
ProductDetailResponse返回。
为什么 trim 放在 Facade,而不是 Controller?因为 trim 是业务输入标准化,不是 HTTP 协议本身。Controller 应该薄一点,Facade 可以承载"保存前如何处理用户输入"的业务规则。
为什么修改成功后要 evict?因为公开商品详情缓存里也包含 subtitle。如果不删缓存,后台修改成功后,匿名用户访问 /api/products/1 仍可能读到旧副标题。这和前端 React Query mutation 成功后要 invalidateQueries(['product', id]) 是同一类思想。
3.5 DbService 封装数据库更新
ProductDbService.updateSubtitle 使用 LambdaUpdateWrapper:
java
LambdaUpdateWrapper<ProductEntity> wrapper=Wrappers.lambdaUpdate(ProductEntity.class)
.eq(ProductEntity::getId,productId)
.set(ProductEntity::getSubtitle,subtitle)
.set(ProductEntity::getUpdatedAt,now);
int affectedRows = baseMapper.update(null, wrapper);
if (affectedRows == 0) {
throw productNotFound();
}
return requireById(productId);
这里有 3 个关键点。第一,更新条件是 id = productId,只修改目标商品。第二,同时更新 updatedAt,让前端和排查人员能看到数据变化时间。第三,看 affectedRows 判断商品是否存在。影响行数为 0,说明没有这个 id,于是返回 PRODUCT_NOT_FOUND。
前端同学容易把"修改接口成功"理解成"没有异常就是成功"。后端更严谨:数据库 UPDATE 的影响行数也是业务判断依据。特别是订单、支付、库存里,影响行数能告诉你是否抢到了状态流转权;在副标题这个简单需求里,它告诉你商品是否存在。
3.6 权限来自路径规则
这个接口放在 /api/admin/products/{id}/subtitle 下,因此命中 SecurityConfig 中的规则:
java
.requestMatchers("/api/users/**", "/api/admin/**").hasRole("ADMIN")
这意味着:未登录用户访问会得到 401,普通用户访问会得到 403,管理员访问才进入 Controller。这里不需要在 Controller 方法上再手写"如果不是管理员就拒绝",因为路径级规则已经覆盖了 /api/admin/**。
4. 在本项目中对应哪些文件
| 层 | 文件 | 作用 |
|---|---|---|
| 数据库结构 | sql/01_schema.sql、测试 schema |
增加 mall_product.subtitle |
| 种子数据 | sql/02_seed.sql、测试 data |
给演示商品准备副标题 |
| Entity | ProductEntity.java |
Java 持久化对象增加 subtitle |
| Request | ProductSubtitleUpdateRequest.java |
接收并校验管理员提交的新副标题 |
| Response | ProductDetailResponse.java |
公开详情返回 subtitle |
| Facade 契约 | IProductFacade.java |
声明 updateSubtitle 用例 |
| HTTP 入口 | ProductAdminController.java |
暴露 PATCH /api/admin/products/{id}/subtitle |
| 业务编排 | ProductFacade.java |
trim、更新 DB、删除缓存、组装 Response |
| 数据访问 | ProductDbService.java |
执行 UPDATE mall_product SET subtitle |
| 权限 | SecurityConfig.java |
/api/admin/** 需要 ADMIN |
| 缓存 | ProductDetailCacheService.java |
修改成功后删除公开详情缓存 |
| 文档 | docs/admin-update-product-subtitle-guide.md |
更细的手把手开发指南 |
完整调用图:
5. 按真实开发顺序理解这个需求
5.1 第一步:确认数据表
后端开发遇到字段需求,第一步不是写 Controller,而是确认这个字段是不是需要持久化。如果副标题只是临时展示,可以不落库;但商品副标题是商品长期属性,所以必须进 mall_product。正式 schema、测试 schema、种子数据都要同步,否则本地运行和测试环境会不一致。
这和前端只改一个 mock 数据不同。后端有多个运行环境:本地 MySQL、H2 测试、MySQL Testcontainers、SQL 初始化脚本。字段只改一个地方,另一个环境就可能测试失败或启动失败。
5.2 第二步:Entity 和 Response 同步
ProductEntity 对应数据库表。MyBatis-Plus 默认会把下划线字段映射为驼峰字段,本项目开启了 map-underscore-to-camel-case,所以 subtitle 字段可以直接写成 private String subtitle;。
ProductDetailResponse 是对外 JSON 契约。数据库有字段不代表前端能看到,必须在 Response DTO 和 toDetailResponse 中传出去。很多"接口改好了但页面没数据"的问题,就是因为只改了 Entity,忘了改 Response 或组装方法。
5.3 第三步:Request DTO 和校验
更新副标题不是直接接收 Map<String,Object>,也不是在 Controller 里接收 String subtitle。项目风格是定义 Request DTO:ProductSubtitleUpdateRequest。这样 Swagger 文档清楚,校验规则清楚,错误返回也能统一。
校验失败不会进入 Facade。GlobalExceptionHandler.handleValidation 会把 MethodArgumentNotValidException 转成统一响应,业务码为 VALIDATION_ERROR,并返回字段错误列表。前端收到这个错误时,应该根据 fieldErrors 定位到 subtitle 表单项,而不是解析中文 message。
5.4 第四步:Facade 契约先行
backend/contract 中的 IProductFacade 是模块对外的用例契约。新增 updateSubtitle 以后,ProductFacade 实现它,Controller 注入接口而不是直接依赖实现类。这个设计和前端里"页面依赖 API client 类型,而不是到处拼 URL"有点像。
契约先行的好处是边界清楚:Controller 知道有一个"修改副标题"的用例,但不知道这个用例内部怎么查数据库、怎么删缓存。以后内部实现变化,Controller 不需要跟着改。
5.5 第五步:Controller 选对路径
本需求是管理员操作,所以接口放在 ProductAdminController,路径为 /api/admin/products/{id}/subtitle。不要把它放到公开 ProductController。公开 Controller 对匿名用户开放,如果不小心把写接口放进去,就会变成安全漏洞。
路径是权限模型的一部分。前端同学平时会用路由守卫保护后台页面,但后端不能只相信页面没有入口。即使前端不显示按钮,恶意用户仍然可以直接请求 API。后端必须通过 SecurityConfig 对 URL 做真正授权。
5.6 第六步:缓存失效
副标题会出现在公开详情缓存中,所以写成功后必须删除 mall:product:detail:v1:{id}。本项目的注释写得很清楚:MySQL 修改成功后再删除缓存;Redis 删除失败时 ProductDetailCacheService 内部会吞掉异常并记录日志。
为什么删除失败不回滚?因为 Redis 是性能层,不能把一个已经成功的数据库写入变成业务失败。最坏情况下,旧缓存还会存在到 TTL 结束;更好的排查方式是看日志里的 product_detail_cache event=DEGRADED operation=EVICT,然后手动删除对应 key。
6. 本地运行和 curl 验证
6.1 启动服务并登录管理员
bash
cd backend
mvn -Dmaven.repo.local=$PWD/.m2-repository -pl service -am spring-boot:run
登录:
bash
curl -sS -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"Admin123456"}'
ADMIN_TOKEN='粘贴 accessToken'
6.2 查看修改前公开详情
bash
curl -sS http://localhost:8080/api/products/1
关注响应中的 data.subtitle。如果你想观察缓存,可以先请求两次,再查看 Redis:
bash
docker exec fullstack-mall-redis redis-cli GET mall:product:detail:v1:1
6.3 管理员修改副标题
bash
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":" 新副标题 "}'
预期:HTTP 200,code=SUCCESS,返回的 data.subtitle 是 trim 后的 新副标题。
6.4 再查公开详情
bash
curl -sS http://localhost:8080/api/products/1
如果缓存失效正确,公开详情应该能看到新副标题。你也可以直接查 MySQL:
bash
docker exec fullstack-mall-mysql mysql -umall -pmall123 fullstack_mall \
-e "SELECT id, title, subtitle FROM mall_product WHERE id = 1;"
6.5 验证校验失败
空副标题:
bash
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":" "}'
预期:HTTP 400,code=VALIDATION_ERROR,字段错误里有 subtitle。
超长副标题:
bash
LONG_SUBTITLE=$(python3 - <<'PY'
print('很' * 101)
PY
)
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"subtitle\":\"$LONG_SUBTITLE\"}"
预期同样是 VALIDATION_ERROR。
6.6 验证权限
未登录:
bash
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H 'Content-Type: application/json' \
-d '{"subtitle":"未登录尝试"}'
普通用户登录后调用:
bash
USER_TOKEN='普通用户 accessToken'
curl -i -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $USER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":"普通用户尝试"}'
预期分别是 401 和 403。
6.7 验证不存在商品
bash
curl -i -X PATCH http://localhost:8080/api/admin/products/999999/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":"新副标题"}'
预期:HTTP 404,业务码 PRODUCT_NOT_FOUND。原因是 ProductDbService.updateSubtitle 的 affectedRows 为 0。
7. 常见错误
7.1 只改 Controller,不改数据库
Controller 能接收请求,不代表数据能保存。如果 mall_product 没有 subtitle 字段,Entity 和 SQL 都不完整,最终要么启动失败,要么更新无效。
7.2 只改 Entity,不改 Response
数据库确实有了副标题,但接口 JSON 没有。前端会以为后端没改好。检查 ProductDetailResponse 和 ProductFacade.toDetailResponse。
7.3 把管理员接口放到公开 Controller
这是权限错误。写操作应放在 /api/admin/** 下,借助 SecurityConfig 的管理员规则保护。
7.4 前端校验替代后端校验
前端校验只能提升体验,不能提供安全保证。后端必须有 @NotBlank 和 @Size。
7.5 忘记 @Valid
Request DTO 上有注解还不够,Controller 参数必须写 @Valid。否则 Bean Validation 不会拦截请求体。
7.6 修改成功后不删缓存
这会导致 MySQL 是新值,公开详情仍返回旧值。排查时先查 MySQL,再查 Redis key。
7.7 Redis 删除失败就回滚业务
不要让缓存删除失败回滚已经成功的商品修改。本项目选择记录日志并依赖 TTL 兜底。
7.8 忘记更新测试数据和测试 schema
正式 SQL 改了,测试 SQL 没改,单元测试或集成测试会失败。字段需求要同步所有初始化脚本。
7.9 使用 Map 接收请求
Map 会让 Swagger、校验、字段名、类型都变模糊。项目风格是每个业务请求都有明确 Request 类。
8. 调试这个需求的推荐顺序
后端调试不要只盯着 Controller。你应该按链路逐层看:请求是否进 Controller、Request 是否通过校验、Facade 是否 trim、DbService 影响行数是否为 1、MySQL 是否新值、Redis key 是否删除、Response 是否包含 subtitle、公开详情是否回源。
9. 本章小练习
- 画出
subtitle从 JSON 请求体到 MySQL 再到 Response JSON 的完整路径。 - 解释为什么
ProductSubtitleUpdateRequest要同时使用@NotBlank和@Size。 - 找到
SecurityConfig,说明普通用户为什么会得到 403。 - 修改副标题后,手动用 Redis CLI 检查详情缓存是否被删除。
- 如果接口返回 200,但公开详情仍是旧副标题,请按本篇调试图写出排查步骤。
- 对比前端 React Query 的
invalidateQueries和后端productDetailCacheService.evict,说明二者相同点和不同点。 - 思考如果要新增"商品主图 URL",需要修改哪些层。
- 阅读
docs/admin-update-product-subtitle-guide.md,把其中的手动验证命令整理成自己的 checklist。
10. 下一章预告:前端转后端调试方法和常见坑
到这里,我们已经从基础概念、商品、SKU、购物车、订单、支付、Redis 缓存和一个真实字段需求走了一圈。下一篇会做一次方法论总结:前端工程师如何把浏览器 DevTools 的排查习惯迁移到后端?如何区分 401、403、404、409、500?如何从 traceId、统一响应、日志、curl、MySQL、Redis 和测试中定位问题?如何避免"我觉得代码没问题"的盲调?
11. 本篇总结
一个"管理员修改商品副标题"的小需求,实际穿过了数据库、Entity、Request、Response、Facade 契约、Controller、Security、DbService、缓存、日志和验证命令。前端同学转后端时,不要只盯着某个注解,而要建立"字段流动"的整体视角:字段从哪里来,在哪里校验,保存到哪里,如何返回,谁有权限修改,修改后哪些缓存要失效,失败时返回什么错误码。
如果你能独立说出为什么接口放在 /api/admin/products/{id}/subtitle、为什么 @Valid 必须存在、为什么修改成功后要 evict、为什么 Redis 删除失败不回滚 MySQL,说明你已经具备开发后端小需求的基本思维。下一篇我们会把这些思维整理成调试和排坑方法论。
附录 A:把"一个字段"拆成后端工程 checklist
为了让你以后能独立接类似需求,这里把副标题需求抽象成一套 checklist。第一问:这个字段是否需要持久化?如果只是临时计算结果,可以只放 Response;如果是业务长期属性,就必须进数据库。副标题显然属于长期属性,所以要改 mall_product。
第二问:这个字段由谁填写?如果由前端用户填写,就需要 Request DTO 和 Bean Validation;如果由服务端生成,例如 createdAt、createdBy、订单号,就不应该让前端传。副标题由管理员填写,所以需要 ProductSubtitleUpdateRequest;商品创建人由当前登录态决定,所以不能让前端传 createdBy。
第三问:这个字段对谁可见?如果公开商品详情要展示,就要改 ProductDetailResponse;如果只给管理端展示,可能要区分 admin response 和 public response。当前项目公开详情也展示副标题,所以 ProductDetailResponse 带 subtitle。
第四问:谁可以修改?副标题只能管理员改,所以接口放在 /api/admin/**。如果未来普通卖家也能修改自己的商品,权限模型就不能只靠 ADMIN,而要增加"商品归属"校验。
第五问:修改会影响哪些缓存?只要这个字段出现在某个缓存 Response 中,修改后就要失效对应 key。副标题出现在商品详情缓存,所以要 evict(productId)。
第六问:失败如何返回?字段为空是 400 VALIDATION_ERROR,商品不存在是 404 PRODUCT_NOT_FOUND,未登录是 401,普通用户是 403,Redis 删除失败不改变主业务成功结果。后端接口不是只返回 true 或 false,错误码是前后端协作契约。
这套 checklist 可以复用到商品主图、商品成色、卖家备注、分类排序、用户昵称等很多需求。你每做一次,都要从数据、契约、权限、缓存、错误、测试 6 个角度扫一遍。
附录 B:如何给副标题需求补自动化测试
当前系列主要生成文档,但作为后端工程师,你应该知道这个需求应该怎么测。最少可以补 6 类测试。
第一类是管理员成功修改。准备管理员 token,调用 PATCH /api/admin/products/1/subtitle,断言 HTTP 200、code=SUCCESS、data.subtitle 等于 trim 后的新值,再查 ProductDbService.getById(1L) 确认数据库已更新。
第二类是未登录和普通用户。未登录调用应返回 401 UNAUTHORIZED;普通用户 token 调用应返回 403 FORBIDDEN。这个测试证明接口路径确实被 /api/admin/** 保护。
第三类是参数校验。空字符串、只有空格、超过 100 个字符都应返回 400 VALIDATION_ERROR,并且字段错误包含 subtitle。这个测试证明 @Valid 和 DTO 注解生效。
第四类是不存在商品。管理员调用 /api/admin/products/999999/subtitle 应返回 404 PRODUCT_NOT_FOUND。这个测试证明 ProductDbService.updateSubtitle 正确检查 UPDATE 影响行数。
第五类是缓存失效。先让商品详情缓存命中,再修改副标题,断言 productDetailCacheService.evict 被调用,或者在更高层测试中确认下一次公开详情返回新值。这个测试证明写后失效没有遗漏。
第六类是 Redis 删除失败不影响主业务。可以 mock productDetailCacheService.evict 抛异常,或者在缓存服务内部验证 delete 异常被吞掉并记录日志。由于当前 ProductDetailCacheService.evict 自己捕获异常,Facade 不需要 catch。测试目的不是让 Redis 真挂,而是证明业务语义:MySQL 成功后,缓存删除失败不把接口变成失败。
测试命名可以这样写:
text
adminCanUpdateProductSubtitleAndEvictsDetailCache
anonymousUserCannotUpdateProductSubtitle
normalUserCannotUpdateProductSubtitle
rejectsBlankOrTooLongSubtitle
updateSubtitleReturns404WhenProductDoesNotExist
redisEvictFailureDoesNotRollbackSubtitleUpdate
测试名要像业务规则,而不是像实现细节。testPatchSubtitle 信息太少,半年后你看不懂它保护什么。
附录 C:代码评审时应该怎么看这个需求
如果你作为 Reviewer 看别人提交的副标题功能,不要只看接口能不能跑。你要按层检查。
看 SQL:字段类型是否合理,长度是否和校验一致,是否有默认值,正式 schema 和测试 schema 是否同步,种子数据是否同步。
看 DTO:Request 是否有明确字段,是否用了 @NotBlank 和 @Size,错误信息是否面向用户,是否避免使用 Map;Response 是否包含前端需要的字段,字段名是否稳定。
看 Controller:路径是否放在 admin 下,HTTP 方法是否符合语义,是否有 @Valid,是否返回统一 ApiResponse,是否没有写业务细节。
看 Facade:是否做输入标准化,是否调用正确 DbService,是否在 MySQL 成功后删除缓存,是否没有吞业务异常,是否返回完整 Response。
看 DbService:更新条件是否正确,是否更新 updatedAt,是否检查影响行数,是否把不存在转换成项目统一业务异常。
看权限:SecurityConfig 是否已经覆盖该路径,测试是否证明普通用户不能调用。
看缓存:这个字段是否出现在缓存中,修改后是否失效,删除失败是否符合项目 fail-open 设计。
看验证:有没有 curl 文档或自动化测试覆盖成功、校验失败、未登录、权限不足、不存在商品和缓存场景。
这个评审清单体现后端思维:改一个字段不是局部文本替换,而是维护一条跨层契约。
附录 D:从前端表单到后端校验的边界划分
前端表单校验的目标是体验:用户还没提交就知道副标题不能为空,按钮可以禁用,输入框可以显示剩余字数。后端校验的目标是安全和一致:无论请求来自真实页面、curl、脚本还是恶意调用,都必须执行同一套规则。
所以不要问"前端已经校验了,后端还要不要校验"。答案永远是要。更准确的问题是:哪些校验前后端都做,哪些只后端做?像必填、长度、格式,前后端都可以做;像当前用户身份、是否管理员、商品是否存在、状态是否允许修改、库存是否足够,必须以后端为准。
副标题需求中,前端可以检查空值和长度,后端通过 @NotBlank 和 @Size 再检查一次。前端不能决定"这个用户是管理员",只能根据后端返回的用户信息决定是否显示按钮;真正授权由 Security 执行。前端不能决定"商品一定存在",因为商品可能在页面打开后被删除或下架;后端更新时必须根据数据库影响行数判断。
附录 E:如果要继续扩展这个需求
假设产品后来提出:副标题修改需要记录操作日志,需要返回修改人,需要支持批量修改,需要普通运营只能改自己负责分类下的商品。你会发现架构要继续演进。
操作日志意味着新增表,例如 mall_product_audit_log,Facade 更新商品后还要插入日志,最好放在同一个事务里。返回修改人意味着 mall_product 可能要增加 updated_by,并由 CurrentUserService.requireCurrentUserId 填充。批量修改意味着 Controller Request 可能变成多个商品 ID,DbService 要考虑批量 UPDATE 和部分失败策略。分类权限意味着不能只靠 /api/admin/**,还要在业务层校验当前管理员可管理的分类范围。
这些扩展说明:简单需求适合用清晰分层打基础,复杂需求会在分层上增加事务、审计、权限模型和批处理策略。前端同学转后端时,不必一开始就设计到最复杂,但要知道每个变化会影响哪些层。
附录 F:把"改一个字段"拆成后端任务卡
真实团队里,产品同学可能只会说一句:"后台商品列表支持修改副标题,前台商品详情展示最新副标题。"如果你是前端,可能会自然拆成表单输入框、接口调用、成功提示、刷新列表。但作为后端,你要把它拆成更靠近系统事实的任务卡。第一张卡是数据层:mall_product 是否已经有 subtitle 字段,长度、默认值、是否允许为空、历史数据如何处理。第二张卡是契约层:请求体字段叫什么,最大长度是多少,响应是否复用商品详情 Response。第三张卡是权限层:谁能调用,未登录和非管理员分别返回什么。第四张卡是业务层:商品不存在怎么办,空字符串怎么办,前后空格是否保留。第五张卡是缓存层:修改成功后哪些缓存要失效。第六张卡是验证层:单元测试、集成测试、curl 手工验证各覆盖什么。
这个拆法看起来比"加一个字段"慢,但它能避免返工。比如如果你先写 Controller,再发现 SQL 没字段,接口肯定跑不通;如果你只改数据库和 Entity,不改 Response,前端永远拿不到新值;如果你忘记权限,普通用户可能调用管理接口;如果你忘记缓存,公开详情会显示旧副标题。后端开发的价值就在于把这些隐藏影响一次性识别出来。
把这个需求写进任务卡时,可以用这样的顺序:先确认 sql/01_schema.sql 与 ProductEntity;再确认 ProductDetailResponse 是否包含 subtitle;再增加或检查 ProductSubtitleUpdateRequest 的校验;再在 IProductFacade 暴露方法;然后实现 ProductDbService.updateSubtitle;最后接入 ProductAdminController 并补测试。这个顺序和本篇代码阅读顺序一致,因为它从稳定的事实来源走到外部入口,减少中途猜测。
附录 G:后端字段命名和前端字段命名如何对齐
前端习惯使用 camelCase,例如 subtitle、categoryId、createdAt;数据库习惯使用 snake_case,例如 category_id、created_at。本项目通过 MyBatis-Plus 和实体字段把两者连接起来:Java 里是 subtitle,SQL 里是 subtitle;Java 里是 categoryId,SQL 里是 category_id。这种映射看似简单,但字段一多就容易出错。
前端同学做全栈时要养成一个习惯:每加一个字段,都沿着"数据库列名 → Entity 属性名 → Response 字段名 → JSON 字段名 → 前端变量名"检查一遍。如果某一层故意不一致,要写清楚原因。比如数据库里可能叫 status_code,Java 枚举字段叫 status,前端展示成"上架 / 下架"。这是合理的,因为不同层的语义不同。但如果数据库叫 sub_title,Java 叫 subtitle,Response 又叫 productSubTitle,前端叫 descTitle,团队以后排查就会很痛苦。
本项目副标题字段是很好的正例:表字段 subtitle,Entity 字段 subtitle,Request 字段 subtitle,Response 字段 subtitle,前端侧示意代码也提交 { subtitle }。这让需求从前端到后端几乎没有翻译成本。你以后设计接口时,不要为了"显得后端化"创造晦涩字段名。字段名越稳定,越能降低跨端协作成本。
附录 H:如何判断一个字段应该放在哪个 Response 里
修改副标题时还有一个常见问题:是不是所有商品相关接口都要返回 subtitle?答案不是绝对的。后端 Response 应该服务具体场景,而不是把数据库整行直接暴露出去。公开商品详情需要副标题,因为页面要展示;后台编辑页需要副标题,因为管理员要查看和修改;订单快照是否需要副标题,要看订单详情页是否要求保留下单当时的营销文案。购物车列表是否需要副标题,也要看页面展示需求。
前端同学可以类比组件 props。一个商品卡片组件只需要标题、价格、图片,不一定需要完整描述;详情页需要更多字段;后台编辑表单又需要状态、分类、创建人等管理字段。如果所有接口都返回完整商品对象,会造成响应变大、字段含义混乱,也容易让前端误用不该依赖的字段。后端的 DTO 设计,本质上就是给不同页面和业务动作设计不同的 props 契约。
当前项目中 ProductDetailResponse 包含 subtitle 是合理的,因为它描述公开详情。管理员修改成功后返回同类或相关 Response,也能帮助前端立刻刷新视图。但如果未来新增商品列表接口,你要重新判断列表是否需要副标题;如果只是后台表格展示,可以返回;如果是移动端首页瀑布流,为了性能可能不返回。不要把"数据库有这个字段"自动等同于"所有接口都要返回这个字段"。
附录 I:本章可以补的测试设计
如果继续完善这个需求,我建议至少补 5 类测试。第一类是请求校验测试:subtitle 缺失、空字符串、全空格、超过 100 字符,都应该得到清晰的 400 响应。第二类是权限测试:未登录访问返回 401,普通用户访问返回 403,管理员访问才成功。第三类是业务测试:商品不存在时返回 PRODUCT_NOT_FOUND,存在时更新成功,并且数据库字段被修改。第四类是缓存测试:先制造商品详情缓存,再更新副标题,确认缓存被删除或下次详情返回新值。第五类是 trim 规则测试:输入前后带空格时,最终保存的是去掉外层空格后的值。
这些测试并不是为了追求覆盖率数字,而是把"一个字段穿过系统"的关键契约固定下来。前端改组件时会写快照或交互测试,后端改接口也要写契约测试和业务测试。尤其是权限、缓存、事务这些前端页面不容易直接覆盖的点,更应该交给后端测试保护。
你可以把测试命名写得像业务说明书,例如 updateSubtitleShouldRequireAdminRole、updateSubtitleShouldEvictProductDetailCache、updateSubtitleShouldRejectBlankSubtitle。好的测试名能让后来的人不用读完整实现,也知道这个需求为什么这样设计。等你能为一个小字段写出这些测试,说明你已经开始从"会调接口的前端"转向"能维护系统规则的全栈工程师"。
附录 J:为什么小需求也要写验收标准
"修改商品副标题"这种需求如果没有验收标准,很容易出现每个人理解不同。产品可能认为空副标题不允许,前端可能认为空字符串可以清空,后端可能按 @NotBlank 拒绝;产品可能认为修改后前台立即生效,后端如果忘记删除 Redis,就只能等 TTL;测试可能只验证管理员成功,不验证普通用户是否被拒绝。验收标准就是把这些隐含分歧提前写出来。
本需求的验收标准可以写成:管理员携带有效 JWT 调用 PATCH /api/admin/products/{id}/subtitle,传入 1 到 100 个字符时更新成功;未登录返回 401;非管理员返回 403;空字符串或超过长度返回 400;商品不存在返回业务 404;更新成功后公开详情接口返回新副标题;响应体继续使用统一 ApiResponse,包含 code、message、data、traceId 和 timestamp。这些标准一旦明确,前端、后端、测试就能围绕同一份契约工作。
前端同学做全栈时尤其要重视验收标准,因为它能帮你从"我把代码写完了"升级为"我把需求交付了"。代码只是实现,验收标准才说明实现是否满足业务。
附录 K:需求上线前的最后 10 分钟检查
提交这个需求前,可以用 10 分钟做一次快速检查。先看接口路径是否符合 REST 语义:修改副标题使用 PATCH,而不是新增一个含糊的 POST /updateSubtitle。再看请求体是否只包含本次动作需要的字段,避免前端传一整个商品对象导致误更新。接着看校验注解是否覆盖空值和长度,业务异常是否能被 GlobalExceptionHandler 转成统一响应。然后看权限:管理端路径是否在 /api/admin/** 下,并且只有 ADMIN 能访问。最后看缓存:更新成功后是否删除公开详情缓存。
这 10 分钟还要打开测试或 curl 脚本,至少跑一遍成功、校验失败、未登录、非管理员、商品不存在 5 条路径。很多线上问题不是复杂算法导致的,而是某条普通异常路径没人验证。小需求越简单,越容易被低估;越容易被低估,越需要 checklist 防止遗漏。
最后,再把本需求和前端发布联系起来:前端按钮可以隐藏,表单可以禁用,但真正的权限、校验和数据一致性必须由后端保证。只有当前后端都按同一份契约实现,用户操作才会稳定。