
07-DTO参数校验-把脏数据拦在门口系列AI 伙伴AI-Partner——具身智能陪伴机器人 · 数据接口部署与二次开发篇07/12一、为什么校验要放在门口先说个真实场景你在前端页面上提交一条健康记录手一抖type字段空了请求直接打到了 Service 层Service 里判空逻辑没写全于是数据库里多了一条类型为 null的脏数据。后面做趋势查询、做异常告警判定这条数据就是个地雷随时炸。这种问题的正解只有一个数据进门之前先验一遍不合格直接轰出去。在 Spring Boot 的体系里这个门口就是 DTO Bean Validation。先解释几个名词照顾零基础的朋友DTOData Transfer Object数据传输对象。前端传过来的 JSON先反序列化成一个 Java 对象这个对象就叫 DTO。它和数据库的 Entity 不是一回事——Entity 对应表结构DTO 对应接口契约。Bean ValidationJava 的参数校验标准老一辈叫 JSR-303/380Jakarta 时代就是 jakarta.validation 包。你在字段上贴注解框架替你做检查。Valid触发校验的开关。贴在 Controller 入参上进方法之前先校验 DTO。二、项目里的依赖validation starterAI 伙伴AI-Partner后端在 pom 里显式引入了校验依赖项目源码dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-validation/artifactId/dependency这里有个新手常踩的坑要提一句Spring Boot 2.3 之后web starter 里不再自带校验能力。你要是用校验注解却没引这个 starter运行时Valid会假装生效——不报错、也不校验脏数据照进不误。所以这个依赖别漏。三、5 个 DTO 逐个拆字段、注解、文案AI 伙伴AI-Partner的dto包里共有 5 个 DTO全部用 Lombok 的Data校验注解来自jakarta.validation.constraints。下面逐个过以下均为项目源码。3.1 ChatRequest —— 对话请求NotNull(messageuserId 不能为空)privateLonguserId;NotBlank(message消息内容不能为空)privateStringmessage;privateStringsessionTypetext;// text / voiceprivateBooleanneedTtsfalse;// 是否合成语音3.2 UserRequest —— 用户注册/登录NotBlank(messageopenId 不能为空)privateStringopenId;privateStringplatformweb;// wechat / alipay / webprivateStringnickname;3.3 ReminderRequest —— 创建提醒NotNull(messageuserId 不能为空)privateLonguserId;NotBlank(message提醒标题不能为空)privateStringtitle;privateStringcontent;privateLocalDateTimeremindTime;// 触发时间不填则用 cronprivateStringtypecustom;// medication/schedule/water/birthday/customprivateStringcron;// 重复表达式可选privateStringdeviceId;// 目标设备编码可选3.4 EmotionRequest —— 情绪记录NotNull(messageuserId 不能为空)privateLonguserId;NotBlank(message情绪类型不能为空)privateStringemotion;privateIntegerintensity5;// 强度 1-10privateStringcontext;// 触发场景privateStringsourcemanual;// agent / vision / manual3.5 HealthRequest —— 健康数据NotNull(messageuserId 不能为空)privateLonguserId;NotBlank(message记录类型不能为空)privateStringtype;privateStringvalue;privateStringunit;privateStringnote;privateStringdeviceId;// 设备编码可选四、NotNull 与 NotBlank一字之差天壤之别看上面 5 个 DTO只用到了两种注解但选得很有讲究注解对 String 的判定适用场景NotNull只检查不是 null空串能通过数字、对象、枚举 ID 等NotBlank必须非 null 且去掉首尾空格后还有内容一切用户输入的文本为什么userId用NotNull而message用NotBlank因为 userId 是Long类型的数字根本不存在空串概念null 就是唯一违法形态而 message 是文本和 在业务上都等于没说话必须拦。对照表收好这就是项目目前的字段 → 约束 → 错误文案全账DTO字段约束校验失败时的错误文案默认值ChatRequestuserIdNotNulluserId 不能为空—ChatRequestmessageNotBlank消息内容不能为空—ChatRequestsessionType无—textChatRequestneedTts无—falseUserRequestopenIdNotBlankopenId 不能为空—UserRequestplatform无—webUserRequestnickname无——ReminderRequestuserIdNotNulluserId 不能为空—ReminderRequesttitleNotBlank提醒标题不能为空—ReminderRequesttype无—customEmotionRequestuserIdNotNulluserId 不能为空—EmotionRequestemotionNotBlank情绪类型不能为空—EmotionRequestintensity无—5EmotionRequestsource无—manualHealthRequestuserIdNotNulluserId 不能为空—HealthRequesttypeNotBlank记录类型不能为空—看清楚了吗只有必填项做了校验格式校验长度、范围、枚举取值一个都没有。这是项目的现状也是可以改进的空间后面细说。五、Valid 触发时机与异常路径Controller 里是怎么用的以 ChatController 为例项目源码节选PostMappingpublicApiResponseChatService.ChatResultchat(ValidRequestBodyChatRequestrequest){returnApiResponse.ok(chatService.chat(request.getUserId(),request.getMessage(),request.getSessionType(),Boolean.TRUE.equals(request.getNeedTts())));}9 个 Controller 里所有带 DTO 的 POST 接口都挂了Valid RequestBody。执行顺序是请求进来先把 JSON 反序列化成 DTOValid触发按字段上的注解逐个检查全部通过 → 进入方法体任一失败 → 抛出MethodArgumentNotValidException方法体根本不会执行。那异常抛出来谁接项目在common/GlobalExceptionHandler里专门写了一个处理器项目源码ExceptionHandler(MethodArgumentNotValidException.class)publicResponseEntityApiResponseVoidhandleValidation(MethodArgumentNotValidExceptione){FieldErrorfieldErrore.getBindingResult().getFieldError();StringmessagefieldErrornull?参数校验失败:fieldError.getDefaultMessage();returnResponseEntity.badRequest().body(ApiResponse.fail(400,message));}这段代码有三个细节值得品只取第一个字段错误getFieldError()返回的就是一条。当多个字段同时非法时用户只会看到第一条。对陪伴机器人这种 C 端场景一次报一条比一股脑甩十条规定更友好但如果你做管理后台可能希望返回全部错误改成getAllErrors()循环拼接即可。HTTP 状态码 400 业务码 400 双重标识配合统一响应体ApiResponsecode/message/data 三段式。兜底文案“参数校验失败”。万一哪个注解忘了写 message也不至于把英文技术报错直接糊到用户脸上。实际效果演示curl-XPOST http://localhost:8080/api/chat\-HContent-Type: application/json\-d{userId:1,message:}# 响应{code:400,message:消息内容不能为空,data:null}注意校验失败时data是 null前端只需要盯code ! 0就行。六、进阶玩法示意嵌套、分组、自定义注解项目目前没用这些但零基础的朋友迟早会需要这里给出思路。嵌套对象校验DTO 里套 DTO 时在外层字段上加Valid才能让校验钻进去publicclassOrderRequest{NotNull(messageuserId 不能为空)privateLonguserId;Valid// 没有它里面的注解全部失效NotNull(message地址信息不能为空)privateAddressRequestaddress;// AddressRequest 内部再贴 NotBlank 等}分组校验同一个 DTO新增时不校验 id更新时必须校验 id。用groups区分publicclassUserSaveRequest{Null(groupsCreate.class,message新增时不能传 id)NotNull(groupsUpdate.class,message更新时 id 不能为空)privateLongid;}// Controller 上Validated(Create.class) 或 Validated(Update.class)自定义校验注解比如想让EmotionRequest.intensity强制 1~10。思路是定义一个注解IntensityRange再写一个实现ConstraintValidatorIntensityRange, Integer的校验器isValid里写判断逻辑最后在字段上贴注解。这是把魔法值判断从 Service 收编到 DTO 的标准姿势。当然最省事的现成方案是直接用Min(1) Max(10)——jakarta 自带何必手搓。七、边界讨论校验放 DTO 还是 Service这是老争论了我的观点两层都要但干不同的活。层职责例子失败表现DTO Valid格式与形式类型、必填、长度、范围、正则message 非空、intensity 在 1~10HTTP 400统一文案Service业务规则查库后才能判断的东西userId 是否存在、设备是否绑定、openId 是否重复BusinessException带业务语义AI 伙伴AI-Partner正是这么分的DTO 层拦必填Service 层管业务比如MemoryService.save里校验 importance 在 [1,5]、EmotionService.record里校验 intensity 在 [1,10]都是查上下文后的业务校验。一句话总结DTO 校验是门卫Service 校验是安检机缺一个都会漏人。八、现状盘点与改进清单如实说AI 伙伴AI-Partner的校验层目前只做了必填这一档结合前面那张对照表可以列出这些待改进点缺格式约束type、emotion、platform这类枚举语义字段是自由字符串传个abc也能进库。可以用Pattern(regexp ...)或自定义注解收口。缺长度约束title数据库限 128 字符、content限 512DTO 层没有Size超长内容会直接被 MySQL 报错变成一坨 500。缺数值范围intensity的 1~10 校验在 ServiceDTO 层加Min Max更早拦截。错误聚合目前只返回第一条错误多字段同时出错时前端体验一般。校验这东西做得早是挡脏数据的门做得晚是擦脏数据的墙。项目这条链路DTO 注解 → Valid → MethodArgumentNotValidException → 统一 400 响应已经把骨架搭好了剩下的就是往注解里填约束的体力活。动手吧让每一条进门的 JSON 都干干净净。