1. 现实问题:校验写在 Controller 里仍可能写入坏数据
创建草稿需要标题、正文和作者,标题不能为空且长度有限,作者必须有权限,slug 还要唯一。@NotBlank 能拦住空标题,却不能判断作者是否存在、slug 是否冲突。输入校验、业务校验、数据库约束分别位于不同边界,少一层都可能留下竞态漏洞。
这篇把一个写接口完整串起来:请求 JSON 先绑定 CreateDraftRequest,校验通过进入 Service,Service 开事务调用 Mapper,数据库约束失败由统一异常处理转成稳定响应。
2. 最小可运行示例:DTO、Service 与 Mapper
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
record CreateDraftRequest(
@NotBlank @Size(max = 160) String title,
@NotBlank String body) {}
@RestController
class DraftController {
private final DraftService service;
DraftController(DraftService service) { this.service = service; }
@PostMapping("/api/drafts")
@ResponseStatus(HttpStatus.CREATED)
DraftView create(@Valid @RequestBody CreateDraftRequest request) {
return service.create(request);
}
}
Service 伪代码应保持语义一致:
@Transactional
DraftView create(CreateDraftRequest request) {
String slug = slugger.from(request.title());
if (mapper.countBySlug(slug) > 0) throw new SlugConflictException(slug);
mapper.insert(new DraftRecord(slug, request.title(), request.body()));
return mapper.findViewBySlug(slug);
}
count 只是用户体验上的早提示,最终仍依赖数据库唯一索引防止并发重复;Controller 不直接拼 SQL,也不把数据库 record 直接暴露为 JSON。
3. 调用链与对象变化
HTTP JSON 字节被消息转换器读成 CreateDraftRequest,Jakarta Validation 读取注解并对字段生成 ConstraintViolation;失败时不会进入 Service。通过校验后,Controller 把同一个不可变 DTO 引用交给 Service,Service 生成 slug 和 DraftRecord,Mapper 将字段绑定到 INSERT 参数。
事务代理在 Service 外层开启连接事务,insert 成功后再查询 View;全部成功 commit,唯一键冲突或其他异常 rollback。返回的 DraftView 经消息转换器编码为 JSON 和 201。每一层都可以用日志记录 request id、slug hash、数据库耗时,但避免记录正文全文。
4. 为什么这样设计
DTO 是外部输入契约,领域对象承载业务规则,Record 是持久化映射,分开后字段扩展不会直接泄漏。Bean Validation 适合结构性约束,Service 负责跨字段和外部资源,数据库负责最终唯一性和非空。校验重复不是坏事,它们的证据和并发边界不同。
事务包住“生成/写入/返回所需读取”的一致性动作,但不包住邮件、对象存储等外部副作用。@Transactional 只有在方法经过代理、事务管理器配置正确且异常从边界抛出时才会按预期工作。接口测试应验证 HTTP,集成测试验证真实 MyBatis SQL 和约束。
5. 项目落点:把创建草稿落到博客系统
草稿创建后默认 status=DRAFT、deleted_at=null、created_at 由服务器时钟产生;作者从认证上下文获取,不接受前端传 userId。Mapper 方法参数写清名称,动态字段走 #{}。统一错误响应区分 validation_failed、slug_conflict、not_found 和 internal_error。
练习:增加 @Size(max=200000) 正文限制和标题/正文跨字段校验;写 MockMvc 测试 400,Service 测试 slug 冲突,MySQL 集成测试唯一键竞态。并发执行两个同 slug 请求,确认只能一个 201,另一个得到可理解的冲突结果。
6. 易错排查
@Valid不生效:检查依赖、参数注解位置和异常处理器是否读取 MethodArgumentNotValidException。- 事务不回滚:查看代理、自调用、异常是否被包装或吞掉,确认 DataSourceTransactionManager。
- 唯一键先查后写仍重复:count 不是锁,必须依赖数据库唯一约束并翻译冲突。
- DTO 允许客户端传 status/authorId:只定义允许输入字段,服务器生成受保护字段。
7. 一页复习
写接口链是 JSON → DTO 绑定/结构校验 → Service 业务校验 → Mapper 参数化 SQL → 事务提交 → View JSON。校验是分层的,数据库约束是最终防线,事务代理是调用链的一部分。每次声称“接口成功”都要验证状态码、数据库行和错误路径。
创建请求的对象状态可按边界记录:原始 JSON 只有 title/body,绑定后形成 CreateDraftRequest,Bean Validation 生成 violations 或放行;Service 从认证上下文得到 authorId,生成 slug 和 server time;Mapper 把 DraftRecord 绑定到 INSERT;commit 后查询 ArticleView。客户端传入的 status、authorId 和 createdAt 从头到尾都不应该成为受信字段。
项目文件可以把 request/response DTO 放 web/article, 状态迁移和 slug 规则放 application/article, Mapper/XML 放 persistence/article, 约束迁移放 db/migration。Controller 校验结构,Service 查权限和重复,数据库唯一键兜底;统一 Advice 将三层错误分别转为 validation_failed、conflict 和 internal_error。事务代理包住真正 Service 调用,不能在同类内部 self-invocation 后期待它生效。
排错时校验没触发查 @Valid、依赖和参数位置;Mapper 参数找不到查 @Param/XML 名称;重复 slug 仍产生两行查唯一索引而不是 count;发布失败后草稿已变更查事务代理、异常传播和 Connection。接口返回 201 但数据库没有行时,同时核对提交结果和读库连接,不能只相信响应。
练习是并发提交相同标题的两个请求,记录两条请求的 traceId、受影响行数和唯一键异常;再让 audit insert 故意失败,断言 article 状态回到 DRAFT。为同一个字段写前端提示、Bean Validation 错误和数据库错误三份输出,比较它们各自负责的边界。
写接口的输入输出可以分成四层:JSON 只有客户端允许的 title/body,绑定后形成 request record,校验后进入 Service,Service 生成 actor、slug、status 和 server time,再变成 persistence record;数据库返回生成 id,Assembler 变成 ArticleView,Controller 返回 201。客户端传入的 authorId/status/createdAt 从契约上就不存在,避免越权字段穿透。
事务调用链需要确认方法经过代理且异常仍然向外传播。slug 先查只是用户体验提示,真正并发安全来自数据库唯一索引;两个请求都查到不存在时,只有一个 insert 成功,另一个收到唯一冲突并翻译为 409。审计插入失败时,article 行也必须回滚;若 cache/event 放在提交后,失败补偿另行处理。
项目文件可按 feature 放 web/article/dto、application/article、domain/article、persistence/article,校验注解放 request,状态迁移放 domain/service,MyBatis XML 放 resources/mapper。Advice 将字段错误转换成 fieldErrors,业务冲突带 resource id,未知错误只返回 traceId。Controller 不直接调用 Mapper,Mapper 不决定 HTTP 状态。
排错时 400 先看原始 JSON、Content-Type 和约束路径,Mapper 绑定失败查参数名/扫描,重复 slug 查唯一索引和异常翻译,状态未回滚查事务代理/连接/异常,返回 201 但查不到查读写数据库和 commit。日志带 actor/article/affected rows,不记录正文。练习是并发提交相同 slug、故意让 audit 失败、模拟数据库断连,比较三种最终状态。
验证清单:对创建草稿发送合法 title/body、缺 title、超长 body、未知字段、客户端伪造 authorId/status、重复 slug 六组请求,记录 JSON、DTO、violations、Service actor、Mapper 参数、受影响行数、commit 和响应状态。合法路径应只由服务端生成 actor/status/time,Bean Validation 在 Controller 边界拦截结构错误,Service 再做权限和状态规则,数据库唯一索引处理并发冲突。故意让审计写入失败,确认文章行回滚;故意让第二个并发请求撞唯一键,确认它返回 409 而不是 500 或第二行。
调用链复盘要保留对象变化:JSON 字节绑定成请求 record,校验器产生字段错误或放行,Service 生成包含 actor、slug、status 和 server time 的命令,Mapper 将持久化 record 绑定到参数化 INSERT,事务代理提交后由查询映射成 ArticleView。客户端传入的受保护字段不能在任何中间对象里成为可信值。Advice 把 validation_failed、conflict、internal_error 映射到响应,不能让 Mapper 决定 HTTP 状态。
项目验收再做一次真实读写:启动 Boot 应用,创建草稿,查库确认行和唯一索引,再发布/修改并检查状态迁移;同时保存 traceId、actor、affected rows 和事务结果,不记录正文。若返回 201 但读库没有行,先检查 commit、读写数据源和查询条件;若校验消息为空,先查参数注解和异常处理器,不要把所有问题归为 MyBatis。
再做一次提交前验收:把相同 title 的两个请求放在同一时刻开始,保存两个 DTO、两个 actor、两个 slug、唯一键结果和最终数据库行;一个成功、一个冲突且只有一条 audit 才符合契约。把未知字段、越权字段和非法分页分别送入 Controller,确认错误在绑定/校验边界返回,不能进入 Service。最后用查询接口读回 View,核对 status、author 和 createdAt 来自服务端事实,说明“返回 201”之外的对象变化也被验证。
进阶附录:分组校验与幂等键
不同操作可用 validation groups,但组越多越要维护文档。客户端重试创建接口时,可以要求 Idempotency-Key,并在同一事务中保存请求指纹和结果;幂等键的过期、冲突和并发插入都需要数据库约束和测试。
本课按「Spring Boot 4.1、Jakarta Validation、MyBatis 与事务整合」的学习范围组织,正文与示例均为本站原创整理。