在实际的生产开发过程中代码往往不是一遍就编写完成的往往需要经过下面多个过程需求分析和设计接口和数据库代码开发功能测试代码完善注本项目会持续更新在Nebula-g/-需要自取本节任务新增员工员工分页查询启用禁用员工账号编辑员工导入分类模块功能代码前置知识后端开发最常用的HTTP 请求方法是以下 5 种对应数据的「增删改查」操作方法核心语义典型场景苍穹外卖项目特点GET获取、查询资源员工分页查询、根据 ID 查员工、查询菜品列表参数拼在 URL 上可被缓存、可收藏不修改数据POST新增、创建资源新增员工、新增菜品、用户下单参数在 Body 中无长度限制会产生新数据PUT全量更新资源编辑员工信息、修改菜品完整信息用提交的完整数据替换原有资源是修改操作DELETE删除资源删除员工、删除菜品删除服务器上指定的数据记录PATCH部分更新资源仅修改员工启用 / 禁用状态只更新资源的个别字段不用提交完整对象项目中用得较少在接口设计阶段按照HTTP 方法语义 RESTful 接口规范做出选择一、新增员工1.1 需求分析和设计需求分析产品经理给出的产品原型如下分析后可以得出开发需要注意账号必须唯一手机号为合法的11位号码性别只有男女身份证号为合法的18位号码这时候我们会发现这个界面没有密码其实可以这样设定来解决密码默认123456员工登录后可以自行修改接口设计到了这里我们或许又会产生一个疑问为什么之前都是直接/xxx访问而这里多了一层路径呢这是因为本项目分为了管理和用户两个客户端增加一层路径可以便于后端分析请求来源数据库设计employee表注记得去数据库修改类型哦1.2 代码初步开发注当前端提交的数据和实体类中对应的属性差别较大时建议使用数控v传输类DTO来封装数据DTO常用于前端传参VO用于返回给前端的数据结果DTO设计如下通过apifox可以看到设计好的接口参数记得用文档模式查看接着开始代码编写打开EmployeeController类新增save方法返回值类型为Result参数是前端传来的注册信息我们选择封装到EmployeeDTO类中PostMapping ApiOperation(新增员工) public Result save(RequestBody EmployeeDTO employeeDTO){ //打印日志 log.info(新增员工{}, employeeDTO.toString()); //调用业务层api employeeService.save(employeeDTO); return null; }RequestBody是Spring MVC 提供的注解标注在 Controller 方法的参数上负责把前端请求体Body中的 JSON 数据自动解析并封装成一个 Java 对象由于请求方式是POST所以要添加注解PostMapping由于当前类已经添加了RequestMapping/Admin/employee请求路径所以这里无需重复添加字符串数据中的{}是一种占位符当程序读取到其时会动态地将后面参数动态填充到括号内由于这里我们并未在接口中定义抽象方法也没有在实现类中写业务逻辑具体实现所以下面需要补全void save(EmployeeDTO employeeDTO);实现/** * 新增员工 * * param employeeDTO */ Override public void save(EmployeeDTO employeeDTO) { Employee employee new Employee(); //对象属性拷贝 BeanUtils.copyProperties(employeeDTO, employee); //设置帐号状态默认正常状态 employee.setStatus(StatusConstant.ENABLE); //设置默认密码默认密码123456 employee.setPassword(DigestUtils.md5DigestAsHex(PasswordConstant.DEFAULT_PASSWORD.getBytes())); //设置创建时间 employee.setCreateTime(LocalDateTime.now()); //设置更新时间 employee.setUpdateTime(LocalDateTime.now()); //设置创建人TODO获取当前登录用户id(课程里说后面会学到新的获取id方法) employee.setCreateUser(employeeDTO.getId()); //设置更新人 employee.setUpdateUser(employeeDTO.getId()); //保存员工信息到数据库持久层mapper employeeMapper.insert(employee); }接着把传入的employee对象存入数据库即可调用的是mapper包下的mapper服务类insert方法由于这是单表的增删改查操作sql语句较短所以不用再xml配置文件中编写直接用Insertvalue注解即可/** * 插入员工数据 * param employee */ Insert(insert into employee(name, username, password, phone, sex, id_number, create_time, update_time, create_user, update_user, status) values(#{name}, #{username}, #{password}, #{phone}, #{sex}, #{idNumber}, #{createTime}, #{updateTime}, #{createUser}, #{updateUser}, #{status})) void insert(Employee employee);注意格式换行要用引号括起来第一行是数据库中的表头键第二行是对应的DTO中的属性值二者命名方式有所区别容易写错Java习惯采取驼峰命名法而MySQL则是下划线格式或许是因为sql一般不区分大小写的缘故tips.问题未解决这边我遇到了一个问题教学中填写完Insert(insert into employee())后会出现许多选项可以选择填入的属性值奇怪的是我这里并没有一开始我以为是idea未连接数据库的原因连接后发现问题仍然存在后面我也尝试了在设置中更改项目包括全局的SQL方言无法解决问题暂时判定为idea版本不同具体原因日后考究到这里有点基础的同学或许会有个疑问我看资源配置文件包里 application.yml 不是写了驼峰自动转化了吗如下为什么insert注释里还要手动区分呢这是因为这个自动转化配置只能读不能写也就是只能将数据库中返回的下划线命名自动转换为驼峰命名所以还是需要注意区分格式问题1.3功能测试理论上来讲功能测试的方式有两种通过接口文档测试通过前后端联调测试但在实际的工作生产当中由于开发阶段前端和后端是并行开发的后端完成某个功能后前端对应的功能可能还没有开发完成导致无法进行前后端联调测试。所以在开发阶段往往以接口文档测试为主接口文档测试访问云禾外卖项目接口文档填写示例数据并提交多了id就去DTO里注释掉这里我们会发现返回界面空白并显示响应码为401这是为什么呢这是因为在我们的项目server代码中有一个拦截器Intercepter类JwtTokenAdminInterceptor.class如果请求数据不含jwt令牌便无法识别身份catch异常并返回401通过断点调试可以跟踪到异常类其实解决方法很简单只需要给接口文档添加一个统一的jwt令牌首先打开login的测试接口文档发送测试数据获取到返回令牌接着打开全局参数设置点击添加参数输入参数值并保存即可然后再次打开新增员工界面会发现请求头部选项处多了一个红色的一代表配置完成1.4 代码完善目前代码仍存在问题录入的用户名已存在抛出异常后没有处理新增员工时创建人id和修改人id设置为了固定值首先解决第一个问题业务层handler包中有一个GlobalExceptionHandler类作用是捕获抛出的异常并进行相关操作这里我们重载exceptionHandler方法将参数类型设为SQLIntegrityConstraintViolationException判断并截取用户名随后返回异常信息/** * 捕获SQL完整性约束异常 * param ex * return */ ExceptionHandler public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){ // Duplicate entry zhangsan for key employee.idx_username String message ex.getMessage(); if (message.contains(Duplicate entry)) { String userName message.split( )[2]; String msg userName MessageConstant.ALREADY_EXISTS; //上面的constant常量源代码里没写要自行添加“已存在” return Result.error(msg); } else { return Result.error(MessageConstant.UNKNOWN_ERROR); } }一、return 的错误信息在哪里显示return Result.error(msg)会被 Spring 自动序列化成 JSON 格式放在 HTTP 响应体中返回给调用方显示位置分两种场景1. 接口调试工具Apifox / Knife4j / Postman调用新增员工接口传入重复的用户名时在响应结果区域就能看到结构化的错误信息{ code: 0, msg: zhangsan已存在, data: null }msg字段就是你代码里 return 的错误信息。2. 前端管理页面前端的 Axios 响应拦截器会统一接收这个错误结果调用 Element UI 的Message消息组件在页面右上角弹出红色的错误提示框直接展示msg里的文字运营人员就能直观看到 “用户名已存在” 的提示。二、ExceptionHandler注解的作用这是 Spring MVC 提供的异常捕获注解和类上的RestControllerAdvice配合组成全局异常处理器是企业级项目的标准配置。核心作用统一拦截所有 Controller 层抛出的异常集中处理后返回统一格式的错误结果不用在每个接口方法里都写重复的try-catch。接着看第二个问题已知用户访问流程如下我们需要想一种方式来动态获取当前用户的id已知jwt令牌包含用户信息所以很明显应当在拦截器这一部分获取信息转到controller类我们会发现令牌存储了claims集合集合内存储了id回到拦截器类得知empId解析了令牌中存储的idjwtProperties.getAdminTokenName() 只是读取配置里的一个字符串header 的 key 名不是用来存 token 的tokenJWT令牌字符串请求头长这样token: eyJhbGciOiJIUzI1NiJ9.xxxxxxx ↑key ↑valueJWT令牌yml 配置application.ymlsky: jwt: admin-token-name: token # 这就是 adminTokenName它的值固定是 token admin-secret-key: abc123 admin-ttl: 7200000从请求头中获取令牌的过程如下jwtProperties.getAdminTokenName()→ 从配置拿到字符串tokenheader 的 keyrequest.getHeader(token)→ 从本次请求的请求头里取出 key 为token对应的JWT 字符串赋值给局部变量token接着校验令牌parseJWT方法会用管理端密钥admin-secret-key去解密 token。只要能正常解析出Claims载荷就说明 token 是合法的、没被篡改、没过期接下来我们应该思考如何将信息传递给Service的save方法我们分别在拦截器以及controller和service实现类对应的save方法头部填写打印语句测试System.out.println(当前线程的id Thread.currentThread().getId());//反射接着分别发出两次新增员工的请求我们会发现每次独立的请求线程id都是一样的发出新请求后id会改变这表明客户端一次请求就是一个线程这里我们便可以采用ThreadLocal来传参在一个线程的整个生命周期内都可以共享这个存储空间三、ThreadLocalThreadLocal 的特点✅线程隔离每个请求是单独线程A 用户的 empId 不会跑到 B 用户的请求里不会串号⚠️必须清理请求结束一定要 remove不然线程池复用线程会发生数据错乱内存泄漏、串用户 项目的拦截器最后会写BaseContext.removeCurrentId()好处只放一次整条请求链路随便取拦截器只存一次后面 Controller、Service、MetaObjectHandler 都能拿到业务代码完全不用管填充字段写dishService.save(dish)不需要手动 setCreateUser自动填充代码干净统一收口取登录人 id 只有一处后续改逻辑只改一处适配 MyBatis-Plus 自动填充这种回调组件—— 它没有办法接收方法入参只能从线程上下文拿数据。在common工程下context包的BaseContext类中便封装了id接着回到jwt拦截器处将id存入thread local内try { log.info(jwt校验:{}, token); Claims claims JwtUtil.parseJWT(jwtProperties.getAdminSecretKey(), token); Long empId Long.valueOf(claims.get(JwtClaimsConstant.EMP_ID).toString()); log.info(当前员工id, empId); BaseContext.setCurrentId(empId);//保存到线程变量中 //3、通过放行 return true; } catch (Exception ex) { //4、不通过响应401状态码 response.setStatus(401); return false; }然后将service实现类save方法中的保存更新人两处代码参数作修改即可//设置创建人 employee.setCreateUser(BaseContext.getCurrentId()); //设置更新人 employee.setUpdateUser(BaseContext.getCurrentId());二、员工分页查询1.1 需求分析和设计1.2 代码初步开发根据需求里的分页查询接口先设计对应的DTO封装前端传来的参数后面所有的分页查询统一都封装成PackageResult对象/** * 封装分页查询结果 */ Data AllArgsConstructor NoArgsConstructor public class PageResult implements Serializable { private long total; //总记录数 private List records; //当前页数据集合 }注意这里的PR是为了分页查询这个功能单独设置的封装类后端返回给前端的信息有一个统一的封装类ResultT/** * 后端统一返回结果 * param T */ Data public class ResultT implements Serializable { private Integer code; //编码1成功0和其它数字为失败 private String msg; //错误信息 private T data; //数据 public static T ResultT success() { ResultT result new ResultT(); result.code 1; return result; } public static T ResultT success(T object) { ResultT result new ResultT(); result.data object; result.code 1; return result; } public static T ResultT error(String msg) { Result result new Result(); result.msg msg; result.code 0; return result; } }单独封装分页查询返回结果的原因1. 分页必须同时返回数据和总数缺一不可前端分页组件Element UI 的 el-pagination要算出总页数87 条 / 每页 10 条 9 页。如果只给它当前页的 10 条数据它不知道该显示多少个页码。所以total和records是绑在一起的必须打包成一个整体。2.Result的data是泛型理论上能装任何东西但装分页会很难看你可能会想直接返回ResultListEmployee不就行了不行——列表里只有当前页的数据total没地方放。那能不能强行塞进Result比如给Result加个total字段也不行因为登录、新增、删除这些接口根本用不到total加了就变成所有接口都带一个永远为 null 的多余字段很脏。3. 复用所有分页接口统一形状项目里员工分页、菜品分页、订单分页、套餐分页……返回结构完全一样都是{total, records}。前端的表单页面只要写一次res.data.records和res.data.total所有分页接口通用。如果不封装每个接口各写各的字段有的叫totalCount、有的叫count前端每个页面都得单独适配。最终员工信息分页查询后端返回的对象类型为ResultPackageResult首先写controller入参为封装的前端数据DTO返回Result格式数据由于这里是查询数据所以注释要用GetMapping/** * 分页查询员工 * * return */ GetMapping(/page) ApiOperation(分页查询员工)//因为不是json格式的query入参不用加requestbody注解 public ResultPageResult page(EmployeePageQueryDTO employeePageQueryDTO){ log.info(分页查询员工参数为{}, employeePageQueryDTO); PageResult pageResult employeeService.pageQuery(employeePageQueryDTO); return Result.success(pageResult); }一个容易踩的坑注解加错不会报错只会静默失败前端发了 JSON却没加RequestBody→ DTO 里所有字段都是null不报任何错查出来的结果莫名其妙比如分页查成全表前端发的是 URL 参数却加了RequestBody→ 请求体是空的直接报400 Bad Request或415所以写 Controller 前先去前端源码或 F12确认这个接口是data还是params三种判断方法从快到慢① 看前端 axios 写的到底是params还是data—— 项目员工相关接口全在app.jsurl:/employee/login, method:post, data:e // ← data请求体 JSON url:/employee, method:post, data:i // ← data请求体 JSON url:/employee, method:put, data:i // ← data请求体 JSON url:/employee/page, method:get, params:e // ← paramsURL 参数 url:/employee/logout, method:post, params:e // ← paramsURL 参数data:→ JSON 请求体 → 后端加RequestBodyparams:→ URL 参数 → 不加。② 看浏览器开发者工具F12 → Network → 点请求 → Payload 标签显示Request Payload且是{name:张三}这种 → JSON显示Query String Parameters列出page: 2 / pageSize: 10→ 不是 JSON这是最权威的判断因为看的是真实发出的报文。③ 看 HTTP 方法的惯例GET请求按规范没有请求体数据只能挂 URL 上 → 一定不加注解POST / PUT常常带请求体 → 大概率要加接着更新service接口并在实现类中实现方法/** * 分页查询 * param employeePageQueryDTO * return */ PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO);/** * 员工分页查询 * * param employeePageQueryDTO * return */ Override public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) { //开始分页查询 PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize()); PageEmployee page employeeMapper.pageQuery(employeePageQueryDTO); //待更新 return null;//待修改 }为了方便实现分类查询的效果我们这里选用了PageHelper依赖下的startPage方法拦截器后端sql动态拼接limit并动态计算参数依赖jar包也早在项目初始工程pom.yml配置文件中导入注意由于这里使用了helper所以返回值也要用其规范的Page类型有了pagehelper后mapper只需要写查询语句即可其他计算和获取都是自动完成下面来更新数据库相关操作Mapper持久层由于这里入参是动态的动态sql用注解写sql需要用到动态标签很麻烦也容易出错所以对于像删除数据动态查询这种尽量采用配置的方法/** * 员工分页查询 * param employeePageQueryDTO * return */ PageEmployee pageQuery(EmployeePageQueryDTO employeePageQueryDTO);//由于这里是动态sql所以注解方式编写会很麻烦会用到动态标签打开server项目资源包下的mapper包的xml配置文件会发现已经导入了mapper类地址那凭什么执行mapper时会读取该配置文件呢很简单在当前子工程application.yml配置文件中早已配置了索引接下来在xml路径下接着写sql查询语句即可mapper namespacecom.sky.mapper.EmployeeMapper select idpageQuery resultTypecom.sky.entity.Employee select * from employee where if testname ! null and name! and name like concat(%, #{name}, %) /if /where order by create_time desc /select /mapper最后补全实现类方法即可已知方法返回值为PageResult而我们使用的是依赖包中的Page类型接取的返回值其本质又是一个list所以可以用result所需的两条属性分别获取page中的total和result封装到result中返回即可/** * 员工分页查询 * * param employeePageQueryDTO * return */ Override public PageResult pageQuery(EmployeePageQueryDTO employeePageQueryDTO) { //开始分页查询 PageHelper.startPage(employeePageQueryDTO.getPage(), employeePageQueryDTO.getPageSize()); PageEmployee page employeeMapper.pageQuery(employeePageQueryDTO);//新增 return new PageResult(page.getTotal(), page.getResult());//新增 }1.3 功能测试首先通过swagger发送测试请求结果会发现括号里的数据是以数组的形式返回这种形式的数据返回给前端渲染后效果是一串无分隔的数字可以通过前后端联调的方式查看效果1.4 代码完善上面我们发现了日期格式问题下面来解决这个问题解决方式有两种属性注解WMC扩展Spring MVC消息转换器方式一在属性上加注解对日期进行格式化简单但一次只能处理一个属性JsonFormat(patternyyyy-MM-dd HH:mm:ss) private LocalDateTime createTime;方式二在WebMvcConfiguration中扩展Spring MVC的消息转换器统一对日期类型进行格式化处理在程序启动时便会被调用到/** * 注册消息转换器(Spring MVC) * param converters */ Override protected void extendMessageConverters(ListHttpMessageConverter? converters) { log.info(开始注册消息转换器...); //创建一个消息转换器对象 MappingJackson2HttpMessageConverter converter new MappingJackson2HttpMessageConverter(); //需要为消息转换器设置一个对象转换器对象转换器可以将Java对象序列化为json数据 converter.setObjectMapper(new JacksonObjectMapper()); //将自己的消息转换器添加到转换器列表中(有顺序默认排到最后,需要加顺序) converters.add(0,converter); }三、启用禁用员工账号1.1 需求分析和设计产品原型接口设计1.2 代码初步开发首先先写controllerpostmapping花括号代表路径参数可以通过PathVariavle(路径参数名)读取id是Query类型不需要转译当对象名和路径参数名一致时可以省去括号及里面的内容/** * 启用或禁用员工 * * param status * param id * return */ PostMapping(/status/{status}) ApiOperation(启用或禁用员工) public Result startOrStop(PathVariable Integer status,long id){ log.info(启用或禁用员工账号{}状态{}, id, status); employeeService.startOrStop(status, id); return Result.success(); }接着写接口和实体类/** * 启用禁用 * param status * param id */ void startOrStop(Integer status, long id);由于Employee类加了builder注解所以这里可以使用builder建造者模式添加数据/** * 根据id修改员工状态 * * param status * param id */ Override public void startOrStop(Integer status, long id) { // Employee employee new Employee(); // employee.setStatus(status); // employee.setId(id); Employee employee Employee.builder() .status(status) .id(id) .updateTime(LocalDateTime.now()) .updateUser(BaseContext.getCurrentId()) .build(); //提高复用 employeeMapper.update(employee); }这里选择用update命名以及传封装对象而不是具体参数 是为了提高代码复用后面所有修改类操作都不需要增添新的方法了直接调用update即可接着在mapper类中增添新的方法/** * 修改员工信息 * param employee */ void update(Employee employee);由于这里是动态sql入参不固定且有判断所以在xml配置文件中写sql!-- 子工程 application.yml 中配置了 type-aliases-package: com.sky.entity所以 parameterType 可简写为 Employee -- update idupdate parameterTypeEmployee set if testname ! null name #{name}, /if if testusername ! null username #{username}, /if if testpassword ! null password #{password}, /if if testphone ! null phone #{phone}, /if if testsex ! null sex #{sex}, /if if testidNumber ! null id_number #{idNumber}, /if if teststatus ! null status #{status}, /if /set where id #{id} /update1.3 功能测试1.4 代码完善四、编辑员工1.1 需求分析和设计产品原型本功能涉及两个接口根据id查询员工信息编辑员工信息1.2 代码初步开发先写第一个接口文档-根据id查员工首先写controller参数类型GET/** * 根据id查询员工 * * param id * return */ GetMapping(/{id}) ApiOperation(根据id查询员工) public ResultEmployee getById(PathVariable Long id){ log.info(根据id查询员工{}, id); Employee employee employeeService.getById(id); return Result.success(employee); }接着更新接口并重写方法/** * 根据id查询员工 * param id * return */ Employee getById(Long id);/** * 根据id查询员工 * param id * return */ Override public Employee getById(Long id) { Employee employee employeeMapper.getById(id); employee.setPassword(******);//返回给前端的信息加密 return employee; }因为要调用持久层来查询所以还需要写mapper/** * 根据id查询员工 * param id * return */ Select(select * from employee where id #{id}) Employee getById(Long id);接着写第二个-编辑员工信息老规矩先写controller/** * 修改员工 * * param employeeDTO * return */ PutMapping ApiOperation(编辑员工信息) public Result update(RequestBody EmployeeDTO employeeDTO){ log.info(编辑员工信息{}, employeeDTO); employeeService.update(employeeDTO); return Result.success(); }接着写接口和实体类/** * 修改员工 * param employeeDTO */ void update(EmployeeDTO employeeDTO);/** * 根据id修改员工 * param employeeDTO */ Override public void update(EmployeeDTO employeeDTO) { Employee employee new Employee(); BeanUtils.copyProperties(employeeDTO, employee); employee.setUpdateTime(LocalDateTime.now()); employee.setUpdateUser(BaseContext.getCurrentId()); employeeMapper.update(employee); }持久层修改操作复用上面写 启用禁用员工账号 时的更新状态代码 update 即可1.3 功能测试注意上面两个接口对应的方法在后端是完全独立的所以需要分别测试在前端整合1.4 代码完善五、导入分类管理功能1.1 需求分析和设计产品原型业务规则1.2 代码开发/导入这里和前面四步开发过程和代码类似可以当练手自行编写完整代码在资源包内1.3 测试问题1Result.success()还是Result.success(数据)判断标准前端需不需要拿到数据看EmployeeController里七个方法的实际分布// 有参前端要数据 login() → return Result.success(employeeLoginVO); // 要 token否则没法登录 getById() → return Result.success(employee); // 要员工信息填表单 page() → return Result.success(pageResult); // 要列表 总数渲染表格 // 无参前端只要知道成没成 save() → return Result.success(); // 新增 update() → return Result.success(); // 修改 startOrStop() → return Result.success(); // 启禁用 logout() → return Result.success(); // 退出口诀查GET一般有参增删改POST/PUT/DELETE一般无参。问题2到底加不加RequestBody第一招F12 看真实报文最可靠100% 准打开页面按 F12 → 切到Network标签在页面上触发一次那个操作点保存、翻页、点启禁用左侧列表里找到那条请求一般叫list、page、status之类点它右侧点Payload标签然后看显示的是什么标题写着Query String Parameters下面列着page: 1、pageSize: 10→不加注解标题写着Request Payload下面是一坨{name:川菜}→加RequestBody顺带在Headers标签里能看到请求方法是 GET 还是 POST以及Content-Type是不是application/json。第二招查前端源码里的接口定义写代码时用现在的前端是打包产物nginx/html/sky/js/*.js全是压缩过的但接口定义还是能 grep 出来的bashcd C:/Users/Nebula/Desktop/YunHe/nginx-1.20.2/html/sky/js grep -oE url:/category[^]*,method:[a-z]*,(params|data):[a-zA-Z]* *.js会输出类似url:/category/list,method:get,params:t ← params → 不加 url:/category,method:post,data:e ← data → 加 url:/category/page,method:get,params:t ← params → 不加看到params就不加看到data就加这是铁律。如果有 Vue 源码工程直接看src/api/*.ts更清楚。第三招来不及查时的速判特征结论方法是GET一定不加。GET 没有请求体加了必 400参数是一个散装值id、status、type不加。这种从来都是 query 或路径参数参数是整个表单对象员工信息、菜品信息5-10 个字段大概率要加参数是分页查询条件不加。分页向来用 query项目里唯一的例外是退出登录——POST /employee/logout但用的是params所以不加。这就是为什么速判只能当参考第一招才是准的。兜底加错了怎么办最阴险的错误是前端发了 JSON你却没加RequestBody——不报错但 DTO 所有字段都是null。验证方法很简单Controller 里已经有log.info了直接看控制台PostMapping public Result save(RequestBody CategoryDTO categoryDTO){ log.info(新增分类{}, categoryDTO); // ← 看这行打印出来什么 ... }打印出CategoryDTO(name川菜, type1, ...)→对了打印出CategoryDTO(namenull, typenull, ...)→注解加错了赶紧补上反过来如果加了RequestBody但前端发的是 query会直接400一眼就看出来反而好排查