Java 项目实战: 交友项目 - 个人资料管理的更新接口实现与坑

概述

GET /users 解决"看",PUT /users 解决"改"。这条更新链路一共只碰四个类,但里面埋了三个坑:id 必须拿 token 覆写、updateById 只更新非 null 字段、需求篇里那张"字段白名单"在代码里其实没落地。这篇把链路逐层拆开,重点放在实现细节和可复现的验证实验上。

纲要

  • 要改哪四个文件:Controller / Service / Dubbo 契约 / Dubbo 实现,各属哪一层
  • 完整调用链时序图:请求头里的 token 从哪里变成 UserHolder 里的 id
  • Controller 实现:@PutMapping + @RequestBody UserInfo + setId 覆写
  • 为什么是 PUT 不是 POST:REST 语义、实际项目里的取舍
  • 安全要点:userInfo.setId(UserHolder.getUserId()) 这一行删掉会出什么事
  • 为什么直接收实体而不是 DTO:本项目的简化,以及更严谨的替代方案
  • Service 层为什么只有一行转发
  • Dubbo 契约 UserInfoApi 与实现 UserInfoApiImpl.update → updateById
  • 实体与主键策略:@TableId(IdType.INPUT) 与全局 id-type: auto 谁说了算
  • updateById 的动态 SQL 语义:它到底是增量更新还是全量覆盖
  • 三个衍生坑:改不动 null、updated 不刷新、字段策略随版本变
  • 白名单的落地现状:当前实现的安全隐患与两种修法
  • 坑位汇总表 + 验证三段实验(含"传白名单外字段"这个探针)
  • 常见误解澄清

这一节要改哪些文件

对比一下其他章节就会发现,更新这个功能的改动面小得反常------数据库那一侧一行都不用动 。原因是首登保存资料时(POST /user/loginReginfo)已经把 save 和对应 Mapper 都写好了,更新复用的就是同一张 tb_user_info 表、同一个 BaseMapper。所以这次只加"从 HTTP 进来"到"调 Dubbo"这半段。

文件 所属模块 本次改动
UsersControler.java mf-app-server 新增 updateUserInfo 方法
UserInfoService.java mf-app-server 新增 update(UserInfo) 方法
UserInfoApi.java mf-dubbo-interface 契约里已经有 update(首登章节加的),本次不改
UserInfoApiImpl.java mf-dubbo-db 实现已经有(首登章节加的),本次不改
UserInfo.java mf-model 不改,但要看懂它的两个注解
UserInfoVo.java mf-model 不改(更新接口不返回 VO)

工程结构(本章终态快照 代码02,此处只列与更新链路相关的部分):

tree 复制代码
mf-test
├── mf-app-server                                  # 应用层:对客户端暴露 HTTP
│   └── src/main/java/com/mf/server
│       ├── controller/UsersControler.java             # PUT /users 在这里(类名少一个 l,工程真实拼写)
│       ├── service/UserInfoService.java               # update 只做一层转发
│       └── interceptor
│           ├── TokenInterceptor.java                  # 验签 + 把 User 塞进 ThreadLocal
│           ├── UserHolder.java                        # getUserId() 取当前登录用户
│           └── WebConfig.java                         # 注册拦截器,/** 全拦
├── mf-dubbo
│   ├── mf-dubbo-interface                         # 应用层与 DB 层共享的契约
│   │   └── src/main/java/com/mf/dubbo/api/UserInfoApi.java
│   └── mf-dubbo-db                                # 真正的读写方
│       └── src/main/java/com/mf/dubbo
│           ├── api/UserInfoApiImpl.java               # @DubboService,update → updateById
│           ├── mappers/UserInfoMapper.java            # extends BaseMapper<UserInfo>
│           └── handler/MyMetaObjectHandler.java       # 自动填充(本次用不上,见后文)
└── mf-model
    └── src/main/java/com/mf/model
        ├── domain/UserInfo.java                       # 数据库实体,@TableId(IdType.INPUT)
        ├── domain/User.java                           # 注意:它 extends BasePojo
        ├── domain/BasePojo.java                       # created/updated + @TableField(fill=...)
        └── vo/UserInfoVo.java                         # 响应视图对象(更新接口不用它)

有一个细节值得先说,因为它影响你怎么读后面的代码。讲义 2.2 给的控制器版本是自己验签的 ------方法里写 JwtUtils.verifyToken(token)、claims.get("id") 拿 id。而本章终态快照里,这套逻辑已经抽到 TokenInterceptor 了,控制器只剩一句 UserHolder.getUserId()。两者功能等价,后者是重构后的形态。下面以工程快照为准,讲义的写法放到 Service 一节末尾做个对照。

调用链:一次 PUT 请求走了哪些地方

MySQL UserInfoApiImpl UserInfoService UsersControler UserHolder TokenInterceptor 客户端 MySQL UserInfoApiImpl UserInfoService UsersControler UserHolder TokenInterceptor 客户端 #mermaid-svg-4S8BceYpcSISyz9R{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4S8BceYpcSISyz9R .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4S8BceYpcSISyz9R .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4S8BceYpcSISyz9R .error-icon{fill:#552222;}#mermaid-svg-4S8BceYpcSISyz9R .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-4S8BceYpcSISyz9R .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4S8BceYpcSISyz9R .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4S8BceYpcSISyz9R .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4S8BceYpcSISyz9R .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4S8BceYpcSISyz9R .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4S8BceYpcSISyz9R .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4S8BceYpcSISyz9R .marker{fill:#333333;stroke:#333333;}#mermaid-svg-4S8BceYpcSISyz9R .marker.cross{stroke:#333333;}#mermaid-svg-4S8BceYpcSISyz9R svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4S8BceYpcSISyz9R p{margin:0;}#mermaid-svg-4S8BceYpcSISyz9R .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4S8BceYpcSISyz9R text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-4S8BceYpcSISyz9R .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4S8BceYpcSISyz9R .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-4S8BceYpcSISyz9R .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-4S8BceYpcSISyz9R .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-4S8BceYpcSISyz9R #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-4S8BceYpcSISyz9R .sequenceNumber{fill:white;}#mermaid-svg-4S8BceYpcSISyz9R #sequencenumber{fill:#333;}#mermaid-svg-4S8BceYpcSISyz9R #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-4S8BceYpcSISyz9R .messageText{fill:#333;stroke:none;}#mermaid-svg-4S8BceYpcSISyz9R .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4S8BceYpcSISyz9R .labelText,#mermaid-svg-4S8BceYpcSISyz9R .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-4S8BceYpcSISyz9R .loopText,#mermaid-svg-4S8BceYpcSISyz9R .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-4S8BceYpcSISyz9R .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-4S8BceYpcSISyz9R .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-4S8BceYpcSISyz9R .noteText,#mermaid-svg-4S8BceYpcSISyz9R .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-4S8BceYpcSISyz9R .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4S8BceYpcSISyz9R .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4S8BceYpcSISyz9R .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-4S8BceYpcSISyz9R .actorPopupMenu{position:absolute;}#mermaid-svg-4S8BceYpcSISyz9R .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-4S8BceYpcSISyz9R .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-4S8BceYpcSISyz9R .actor-man circle,#mermaid-svg-4S8BceYpcSISyz9R line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-4S8BceYpcSISyz9R :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt token 缺失或非法 token 合法 afterCompletion 里 UserHolder.remove(),防线程复用串号 PUT /users + Authorization 头 + JSON body JwtUtils.verifyToken(token) 401,请求就此终止,控制器不执行 claims 里取 id 和 mobile UserHolder.set(user) 放行 preHandle 返回 true UserHolder.getUserId() 当前登录用户 id userInfo.setId(id) 覆写请求体里的 id update(userInfo) userInfoApi.update(userInfo) Dubbo 跨进程 userInfoMapper.updateById(userInfo) UPDATE tb_user_info SET 非空字段 WHERE id = ? 影响行数 void void 200 + 空 body

注意那条 401 分支的位置:它在拦截器里,不在控制器里 。也就是说 token 不合法时 updateUserInfo 方法根本不会被执行,方法体里不需要再判一次。这也是"统一身份鉴权"这个重构的价值------不用每个接口自己抄一遍验签。

Controller:更新接口

实读到的完整代码(mf-app-server/.../controller/UsersControler.java):

java 复制代码
package com.mf.server.controller;

import com.mf.model.domain.UserInfo;
import com.mf.model.vo.UserInfoVo;
import com.mf.server.interceptor.UserHolder;
import com.mf.server.service.UserInfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UsersControler {

    @Autowired
    private UserInfoService userInfoService;

    /**
     * 查询用户资料
     *    参数:token
     */
    @GetMapping
    public ResponseEntity users(@RequestHeader("Authorization") String token,Long userID) {
        if(userID == null) {
            userID = UserHolder.getUserId();
        }
        UserInfoVo userInfo = userInfoService.findById(userID);
        return ResponseEntity.ok(userInfo);
    }

    /**
     * 更新用户资料
     */
    @PutMapping
    public ResponseEntity updateUserInfo(@RequestBody UserInfo userInfo,@RequestHeader("Authorization") String token) {
        userInfo.setId(UserHolder.getUserId());
        userInfoService.update(userInfo);
        return ResponseEntity.ok(null);
    }
}

方法体就三行,但每一行都有话说。

为什么是 @PutMapping 而不是 @PostMapping

REST 里方法动词是有语义分工的:

方法 语义 幂等 本项目用法
GET 读取资源 是 GET /users 查资料
POST 创建子资源 / 执行非幂等动作 否 POST /user/loginReginfo 首次保存资料
PUT 全量替换一个已知资源 是 PUT /users 更新当前用户资料
PATCH 局部修改一个资源 否 本项目没用

PUT /users 的意思接近"把 id 为 {当前用户} 的这条资料替换成我提交的内容"。因为路径里已经确定了资源是谁(由 token 决定),所以后面不用再带 id,这就是请求体里没有 id 的原因。

但这里得给个实在的取舍,别写成教条:实际项目里很多人用 POST 也能跑通,路由照样匹配,前端照样调得动,只是语义不规范。 真正会咬人的场景是两类------一是接了网关或 CDN 做缓存/重试策略时,PUT 可重试而 POST 不该被自动重试;二是团队里有人照着接口文档写客户端,看到 POST /users 会以为这是"新建一个用户"。功能上没差,约定上差一截。至于纯局部修改,更严格的做法是 PATCH,本工程没走到那一步,统一用 PUT 也不影响正确性------因为 updateById 本身是增量的(后面细讲),语义上其实介于 PUT 和 PATCH 之间,这是个不小的偏差,但项目里大家都这么用。

setId 覆写:这一行是防越权,不是可有可无

userInfo.setId(UserHolder.getUserId()); 这一行删掉,就是一个越权漏洞。

请求体最终会被 Jackson 反序列化成 UserInfo。如果请求体里带了 "id": 999,那么 userInfo.getId() 就是 999。紧接着 updateById 会拼出 UPDATE tb_user_info SET ... WHERE id = 999------改的是别人的资料。攻击成本低到什么程度?只要拿到一个合法 token 登录自己账号,把请求体里的 id 换成别人的,匿名就能批量改。

setId 的作用就是无条件用当前登录用户的 id 覆盖掉请求体里的一切 。注意是覆盖,不是"如果为空才设置"------不能写 if (userInfo.getId() == null),那等于给攻击者留了后门:你不传它就帮你不传,你传了它就听你的。正确姿势是"以服务端会话为准,客端传什么都丢掉"。

这个原则和参数校验里常见的"不信任任何客户端输入"是同一条。区别是这里丢的不是格式,是身份。身份这种东西只能有一个来源。

后面几个章节陆续补了统一异常处理、ErrorResult 错误码体系,但 setId 这一行从头到尾没变过。它不依赖任何框架特性,删了就出事。

为什么直接收 UserInfo 而不是 DTO

@RequestBody UserInfo userInfo 意味着把数据库实体当入参对象用。这是本项目图省事的做法,能跑,但代价很明确:

  • 实体里的每个字段都成了可传参数,包括 created、updated、tags、coverPic、avatar。前端传什么就有什么进 userInfo,updateById 再照着非 null 的都写进库。
  • 实体是跟着表走的,表加一列,接口的隐式入参就多一个。哪天 DBA 加个 user_status 列并放进实体,这个接口就凭空获得了改状态位的能力------而且没人会去 review 那行 SQL。

更严谨的写法是定义一个请求专用的 DTO,只声明允许改的字段:

java 复制代码
package com.mf.server.dto;

import lombok.Data;

import java.io.Serializable;

/**
 * 更新个人资料的请求对象
 * 只声明允许用户修改的字段,白名单直接体现在类型定义上
 */
@Data
public class UserInfoUpdateDto implements Serializable {
    private String nickname;
    private String gender;
    private String birthday;
    private String city;
    private String income;
    private String education;
    private String profession;
    private Integer marriage;
    private String tags;
}

控制器收 DTO,再显式往实体里搬:

java 复制代码
package com.mf.server.controller;

import com.mf.model.domain.UserInfo;
import com.mf.server.dto.UserInfoUpdateDto;
import com.mf.server.interceptor.UserHolder;
import com.mf.server.service.UserInfoService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/users")
public class UserInfoUpdateController {

    @Autowired
    private UserInfoService userInfoService;

    @PutMapping
    public ResponseEntity updateUserInfo(@RequestBody UserInfoUpdateDto dto) {
        UserInfo userInfo = new UserInfo();
        userInfo.setId(UserHolder.getUserId());   // 身份仍然只认 token
        userInfo.setNickname(dto.getNickname());
        userInfo.setGender(dto.getGender());
        userInfo.setBirthday(dto.getBirthday());
        userInfo.setCity(dto.getCity());
        userInfo.setIncome(dto.getIncome());
        userInfo.setEducation(dto.getEducation());
        userInfo.setProfession(dto.getProfession());
        userInfo.setMarriage(dto.getMarriage());
        userInfo.setTags(dto.getTags());
        userInfoService.update(userInfo);
        return ResponseEntity.ok(null);
    }
}

写法啰嗦,但换来两个确定的好处:白名单由类型系统保证(DTO 里没声明的字段 Jackson 直接忽略,连进来的机会都没有),以及接口入参和表结构彻底解耦。BeanUtils.copyProperties(dto, userInfo) 也能省掉那串 set,代价是"哪些字段会被拷"又变成隐式的了------拷了不该拷的,或者以后 DTO 加字段忘了排除,都会静默出问题。这种地方显式比简洁值钱。

工程实现里那个没用上的 token 参数

回头看工程真实的那份代码:@RequestHeader("Authorization") String token 这个参数在方法体里完全没被用到 ------验签是拦截器干的。它留着纯粹是历史遗留(讲义版本在这里用 JwtUtils.getClaims(token) 抠 id,改成拦截器后忘了删),另外在 Swagger/YAPI 生成的接口文档里能让"需要 Authorization 头"这件事显示出来。

不疼不痒的一行,但确实容易让新人误以为"这里还得自己验一次"。上面那个 DTO 版本就没再带这个参数。真正生效的是拦截器。

Service:只有一行转发

java 复制代码
package com.mf.server.service;

import com.mf.autoconfig.template.AipFaceTemplate;
import com.mf.autoconfig.template.OssTemplate;
import com.mf.dubbo.api.UserInfoApi;
import com.mf.model.domain.UserInfo;
import com.mf.model.vo.ErrorResult;
import com.mf.model.vo.UserInfoVo;
import com.mf.server.exception.BusinessException;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@Service
public class UserInfoService {

    @DubboReference
    private UserInfoApi userInfoApi;

    @Autowired
    private OssTemplate ossTemplate;

    @Autowired
    private AipFaceTemplate aipFaceTemplate;

    public void save(UserInfo userInfo) {
        userInfoApi.save(userInfo);
    }

    //更新用户头像
    public void updateHead(MultipartFile headPhoto, Long id) throws IOException {
        //1、将图片上传到阿里云oss
        String imageUrl = ossTemplate.upload(headPhoto.getOriginalFilename(), headPhoto.getInputStream());
        //2、调用百度云判断是否包含人脸
        boolean detect = aipFaceTemplate.detect(imageUrl);
        //2.1 如果不包含人脸,抛出异常
        if(!detect) {
            throw new BusinessException(ErrorResult.faceError());
        }else{
            //2.2 包含人脸,调用API更新
            UserInfo userInfo = new UserInfo();
            userInfo.setId(id);
            userInfo.setAvatar(imageUrl);
            userInfoApi.update(userInfo);
        }
    }

    //根据id查寻
    public UserInfoVo findById(Long id) {
        UserInfo userInfo = userInfoApi.findById(id);

        UserInfoVo vo = new UserInfoVo();

        BeanUtils.copyProperties(userInfo,vo); //copy同名同类型的属性

        if(userInfo.getAge() != null) {
            vo.setAge(userInfo.getAge().toString());
        }

        return vo;
    }

    //更新
    public void update(UserInfo userInfo) {
        userInfoApi.update(userInfo);
    }
}

update 方法只有一行 Dubbo 调用,这不是偷懒,是分层决定的。应用层的职责是"面向客户端的编排"------验身份、转 VO、转发;真正的读写归属 mf-dubbo-db。把 SQL 相关的判断写到这里,等于让应用层越过契约去关心存储细节,以后换个存储实现就得连应用层一起改。

对比一下同一个类里的 updateHead(改头像)和 update(改资料),能看出区别在哪:

updateHead update
入参 MultipartFile + id UserInfo
外部依赖 OSS 上传 + 百度人脸检测 无
业务判断 有人脸校验分支,失败抛 BusinessException 无判断,直接转发
涉及的字段 只 avatar 一个 请求体里所有非 null 字段

updateHead 就是"为什么头像不该混进 PUT /users"的活证据------它要处理二进制文件、要调两个外部服务、要抛出业务异常,塞进纯 JSON 的资料接口只会把接口职责搅浑。设计边界的原因需求分析篇已经讲过了,这里只看实现落点。

再回到讲义的版本对照。讲义里 Service 也是同样一行转发,区别全在 Controller------讲义在 Controller 里自己验签、自己从 claims 里抠 id。两种写法对 Service 毫无影响,也正因为如此,那次重构(抽出拦截器)才做得这么便宜:改动被挡在应用层入口,没往 Service 和 Dubbo 渗透。这也是分层清晰的回报之一。

Dubbo 契约与实现

契约(mf-dubbo-interface/.../api/UserInfoApi.java),三个方法各司其职:

java 复制代码
package com.mf.dubbo.api;

import com.mf.model.domain.UserInfo;

public interface UserInfoApi {

    public void save(UserInfo userInfo);

    public void update(UserInfo userInfo);

    //根据id查询
    UserInfo findById(Long id);
}

实现(mf-dubbo-db/.../api/UserInfoApiImpl.java):

java 复制代码
package com.mf.dubbo.api;

import com.mf.dubbo.mappers.UserInfoMapper;
import com.mf.model.domain.UserInfo;
import org.apache.dubbo.config.annotation.DubboService;
import org.springframework.beans.factory.annotation.Autowired;

@DubboService
public class UserInfoApiImpl implements  UserInfoApi {

    @Autowired
    private UserInfoMapper userInfoMapper;

    @Override
    public void save(UserInfo userInfo) {
        userInfoMapper.insert(userInfo);
    }

    @Override
    public void update(UserInfo userInfo) {
        userInfoMapper.updateById(userInfo);
    }

    @Override
    public UserInfo findById(Long id) {
        return userInfoMapper.selectById(id);
    }
}

Mapper 空得离谱,但这正是 MyBatis-Plus 的用法------继承 BaseMapper<T> 就拿到一整套餐式 CRUD:

java 复制代码
package com.mf.dubbo.mappers;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.mf.model.domain.UserInfo;

public interface UserInfoMapper extends BaseMapper<UserInfo> {
}

有两点必须拎清楚,否则后面看坑会看不懂。

第一,Service 和 ApiImpl 是两个不同的东西。 UserInfoService 带 @Service,是 Spring 的本地 Bean,跑在 mf-app-server 进程里;UserInfoApiImpl 带 @DubboService,跑在 mf-dubbo-db 进程(端口 18081)里。应用层的 @DubboReference private UserInfoApi userInfoApi 是一层代理,调用时序列化参数、走网络、到另一个 JVM 里执行。所以 UserInfo 必须实现 Serializable------这不是可选的,不实现直接抛序列化异常。

第二,update 的返回值是 void。 影响行数被吞掉了。想判断"到底改没改到"就没法从返回值看,只能靠"id 存在就是改到了"。这是个可以商量的设计,但先记住它:拿不到影响行数。

实体与主键策略

java 复制代码
package com.mf.model.domain;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.io.Serializable;
import java.util.Date;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfo implements Serializable {

    /**
     * 由于userinfo表和user表之间是一对一关系
     *   userInfo的id来源于user表的id
     */
    @TableId(type= IdType.INPUT)
    private Long id; //用户id
    private String nickname; //昵称
    private String avatar; //用户头像
    private String birthday; //生日
    private String gender; //性别
    private Integer age; //年龄
    private String city; //城市
    private String income; //收入
    private String education; //学历
    private String profession; //行业
    private Integer marriage; //婚姻状态
    private String tags; //用户标签:多个用逗号分隔
    private String coverPic; // 封面图片
    private Date created;
    private Date updated;
}

@TableId(type = IdType.INPUT) 为什么要显式写

IdType.INPUT 的意思是主键由程序指定,MyBatis-Plus 不插手:insert 时你给什么写什么,不给你生成。

而工程的全局配置里写的是:

yaml 复制代码
mybatis-plus:
  global-config:
    db-config:
      table-prefix: tb_   # 表名前缀
      id-type: auto   # id策略为自增长

全局是 auto(数据库自增)。那到底听谁的?注解优先级高于全局配置 ,实体上标了 INPUT,这个实体就按 INPUT 走。

为什么非要用 INPUT?因为 tb_user 和 tb_user_info 是主键关联的一对一 ------user 表 id=1,这个人的资料表 id 也必须是 1。资料表的主键不可能是自己自增出来的(那样两边对不上)。所以首登保存资料时,id 是从 token 里取出来、手动 setId 进去的;更新时同理,必须带上 id。这也解释了 setId 那一行为什么重要:id 是这次 UPDATE 里唯一的定位条件,也是唯一的安全边界。

created / updated 没有自动填充

同一个 mf-model 里有 BasePojo:

java 复制代码
package com.mf.model.domain;

import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import lombok.Data;

import java.io.Serializable;
import java.util.Date;

@Data
public abstract class BasePojo implements Serializable {

    @TableField(fill = FieldFill.INSERT) //自动填充
    private Date created;
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private Date updated;

}

User 继承了它:

java 复制代码
package com.mf.model.domain;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor  //满参构造方法
@NoArgsConstructor   //无参构造方法
public class User extends BasePojo {

    private Long id;
    private String mobile;
    private String password;
}

UserInfo 没有 。它把 created、updated 当普通列自己声明了一遍,既不带 @TableField(fill = ...),也没继承 BasePojo。这意味着 MyMetaObjectHandler 那套自动填充逻辑对它是失效的。

结果就是:更新 tb_user_info 时 updated 不会自己刷新 。你传什么写什么,不传就不动。这是本篇要讲的第二个坑,下面和 updateById 的语义一起说。

updateById 到底更新了什么

UserInfoApiImpl.update 调的是 userInfoMapper.updateById(userInfo)。这个方法的语义是**"按主键更新实体里非 null 的字段"**,背后是 MyBatis-Plus 的动态 SQL。

假设前端只提交了昵称和城市,userInfo 里其它字段都是 null:

json 复制代码
{ "nickname": "小美在杭州", "city": "杭州" }

Controller 会把 id 补上,最终 updateById 生成的 SQL 大致是这样:

sql 复制代码
UPDATE tb_user_info
SET nickname = '小美在杭州',
    city = '杭州'
WHERE id = 1

注意 SET 子句里只有两个字段 ------birthday、gender 这些虽然实体里有,但值是 null,直接被跳过了。这就是所谓的增量更新,而不是"把整行重写一遍"。

工程用的 MyBatis-Plus 版本是 3.4.1。这个版本里字段策略默认是 NOT_NULL------null 字段不参与拼 SQL。所以"只更新非空字段"是框架默认行为给的,不是业务代码里手写的 if 判断。理解这一点很关键:它既带来了好处,也带来了坑。
#mermaid-svg-qCbR98y0bb9uxAkb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qCbR98y0bb9uxAkb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qCbR98y0bb9uxAkb .error-icon{fill:#552222;}#mermaid-svg-qCbR98y0bb9uxAkb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qCbR98y0bb9uxAkb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qCbR98y0bb9uxAkb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qCbR98y0bb9uxAkb .marker.cross{stroke:#333333;}#mermaid-svg-qCbR98y0bb9uxAkb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qCbR98y0bb9uxAkb p{margin:0;}#mermaid-svg-qCbR98y0bb9uxAkb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qCbR98y0bb9uxAkb .cluster-label text{fill:#333;}#mermaid-svg-qCbR98y0bb9uxAkb .cluster-label span{color:#333;}#mermaid-svg-qCbR98y0bb9uxAkb .cluster-label span p{background-color:transparent;}#mermaid-svg-qCbR98y0bb9uxAkb .label text,#mermaid-svg-qCbR98y0bb9uxAkb span{fill:#333;color:#333;}#mermaid-svg-qCbR98y0bb9uxAkb .node rect,#mermaid-svg-qCbR98y0bb9uxAkb .node circle,#mermaid-svg-qCbR98y0bb9uxAkb .node ellipse,#mermaid-svg-qCbR98y0bb9uxAkb .node polygon,#mermaid-svg-qCbR98y0bb9uxAkb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qCbR98y0bb9uxAkb .rough-node .label text,#mermaid-svg-qCbR98y0bb9uxAkb .node .label text,#mermaid-svg-qCbR98y0bb9uxAkb .image-shape .label,#mermaid-svg-qCbR98y0bb9uxAkb .icon-shape .label{text-anchor:middle;}#mermaid-svg-qCbR98y0bb9uxAkb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qCbR98y0bb9uxAkb .rough-node .label,#mermaid-svg-qCbR98y0bb9uxAkb .node .label,#mermaid-svg-qCbR98y0bb9uxAkb .image-shape .label,#mermaid-svg-qCbR98y0bb9uxAkb .icon-shape .label{text-align:center;}#mermaid-svg-qCbR98y0bb9uxAkb .node.clickable{cursor:pointer;}#mermaid-svg-qCbR98y0bb9uxAkb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qCbR98y0bb9uxAkb .arrowheadPath{fill:#333333;}#mermaid-svg-qCbR98y0bb9uxAkb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qCbR98y0bb9uxAkb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qCbR98y0bb9uxAkb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qCbR98y0bb9uxAkb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qCbR98y0bb9uxAkb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qCbR98y0bb9uxAkb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qCbR98y0bb9uxAkb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qCbR98y0bb9uxAkb .cluster text{fill:#333;}#mermaid-svg-qCbR98y0bb9uxAkb .cluster span{color:#333;}#mermaid-svg-qCbR98y0bb9uxAkb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qCbR98y0bb9uxAkb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qCbR98y0bb9uxAkb rect.text{fill:none;stroke-width:0;}#mermaid-svg-qCbR98y0bb9uxAkb .icon-shape,#mermaid-svg-qCbR98y0bb9uxAkb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qCbR98y0bb9uxAkb .icon-shape p,#mermaid-svg-qCbR98y0bb9uxAkb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qCbR98y0bb9uxAkb .icon-shape .label rect,#mermaid-svg-qCbR98y0bb9uxAkb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qCbR98y0bb9uxAkb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qCbR98y0bb9uxAkb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qCbR98y0bb9uxAkb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 非 null
null
有
无
收到 UserInfo 实体
字段值是否为 null
拼进 SET 子句
跳过,保留库里原值
WHERE id = ?
执行 UPDATE
库里有没有这个 id
更新成功,影响行数 1
影响行数 0,且不报错

最后那两条分支值得停一下:id 在库里不存在时,updateById 不会抛异常,只是影响行数 0 。而 update 方法返回 void,连影响行数都拿不到。所以"更新了一个不存在的用户"在链路上是完全静默的。本项目里这个场景很少见(资料记录在首登时就创建了),但换到别的系统就是典型的坑。

好处:漏传字段不会清空资料

这是增量更新最直接的价值。用户只想改城市,前端就只提交 {"city": "上海"},生日、性别、学历全都保持库里原值。而如果实现是"全量覆盖"(UPDATE ... SET nickname=?, birthday=?, gender=? ... 把所有列都写上),漏传一个字段就等于把它清成 null------用户改个城市,结果生日没了。这是线上最典型的"资料被清空"事故。

所以这里的设计是合适的。但"合适"不等于"没坑"。

坑一:改不动 null------没法主动清空一个字段

NOT_NULL 策略的反面就是:你想把某个字段改成 null,做不到 。传 null 会被当作"这个字段不要更新",而不是"把它清空"。

比如用户想删掉自己填的收入(原本是 "20-30万"),提交:

json 复制代码
{ "income": null }

生成的 SQL 里根本不会有 income 这一列,库里的值原封不动。用户的"删除"操作静默失败,前端看到的还是旧值,特别难查------因为没有任何错误。

绕过它的办法有几种,各有代价:

java 复制代码
package com.mf.dubbo.api;

import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.mf.dubbo.mappers.UserInfoMapper;
import com.mf.model.domain.UserInfo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Component
public class UserInfoClearDemo {

    @Autowired
    private UserInfoMapper userInfoMapper;

    /**
     * 把指定用户的 income 字段显式清成 null
     * 实体字段策略是 NOT_NULL,传 null 会被跳过,所以必须用 wrapper.set 显式写
     */
    public void clearIncome(Long userId) {
        LambdaUpdateWrapper<UserInfo> wrapper = new LambdaUpdateWrapper<>();
        wrapper.eq(UserInfo::getId, userId)
               .set(UserInfo::getIncome, null);   // 显式 set 成 null
        userInfoMapper.update(null, wrapper);
    }
}

或者在字段上单独放开策略:

java 复制代码
package com.mf.model.domain;

import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;

public class UserInfoFieldStrategyDemo {

    /**
     * 单独放开该字段的更新策略:
     * IGNORED 表示不管值是什么都拼进 SQL,代价是该字段失去增量更新语义
     */
    @TableField(updateStrategy = FieldStrategy.IGNORED)
    private String income;
}

IGNORED 表示"不管值是什么都拼进 SQL"。代价是这个字段就再也没法做增量更新了------前端不传,它就会被写成 null。等于把坑从"改不动 null"换成了"容易误清空"。所以这不是普适解法,只能针对确实需要清空语义的字段单独开。

全局配置 update-strategy 也能改,但那是把所有字段一起改,风险更大。

坑二:updated 不会自动刷新

前面说过,UserInfo 没继承 BasePojo,created / updated 上没有 @TableField(fill = ...)。MyBatis-Plus 只在实体声明了填充字段时才会触发 MetaObjectHandler 的填充逻辑(User 就是靠 BasePojo 上那两行注解生效的)。UserInfo 没标,所以:

  • MyMetaObjectHandler.updateFill 里那句 setFieldValByName("updated", new Date(), metaObject) 对它不生效;
  • 更新后 updated 保持库里原值,除非请求体里显式传了 updated------而前端传 updated 恰恰是我们不希望发生的(更新时间该由服务端定)。

这算是本章的一个可改进点。修法有两种:让 UserInfo 也继承 BasePojo(要注意 BasePojo 是 abstract,字段名和已有的 created/updated 会冲突,得把实体里那两个字段删掉),或者在 Service 里显式补:

java 复制代码
package com.mf.server.service;

import com.mf.dubbo.api.UserInfoApi;
import com.mf.model.domain.UserInfo;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.stereotype.Service;

import java.util.Date;

@Service
public class UserInfoUpdateService {

    @DubboReference
    private UserInfoApi userInfoApi;

    /**
     * 方式二:不继承 BasePojo,在 Service 层手动补更新时间
     * updated 非 null,会被 updateById 拼进 SET 子句
     */
    public void update(UserInfo userInfo) {
        userInfo.setUpdated(new Date());
        userInfoApi.update(userInfo);
    }
}

第二种更轻量、不触碰实体继承关系,但属于"业务代码替框架兜底"。两种都能用,选一种并在团队里统一 就行------最怕的是一部分实体自动填充、一部分手动管,时间一长没人说得清哪张表的 updated 是可信的。

顺带一提,created 在更新链路里根本没有出现的理由。如果哪天真要防,可以在白名单里把它排除(见下一节)。

坑三:字段策略的默认值随版本变

FieldStrategy 的历史不太平。老版本里配置项叫 field-strategy,新版本拆成了 insert-strategy / update-strategy,可选值也从 IGNORED / NOT_NULL / NOT_EMPTY 这套扩过。默认值本身在不同大版本里也调整过。

所以别把"默认只更新非 null"当永久真理背下来 。工程里用的是 3.4.1,这个版本下 updateById 跳过 null 字段。升级 MyBatis-Plus 之前,去官方文档查一下当前版本的字段策略默认值,或者干脆在 yml 里把它写死:

yaml 复制代码
mybatis-plus:
  global-config:
    db-config:
      update-strategy: not_null   # 显式声明,避免升级后默认值变了导致行为漂移

写死的好处是可预期------升级框架时不会因为一个默认值变了,让"改不动 null"或"误清空"这类问题换个方向爆发。

三种需求对应的做法

需求 默认行为能做到吗 做法
只更新前端提交的字段,漏传的保持不变 能(默认) 直接 updateById(userInfo),3.4.1 下 null 字段自动跳过
把某个字段主动清成 null 不能 LambdaUpdateWrapper.set(字段, null) 显式写;或给该字段单独标 @TableField(updateStrategy = FieldStrategy.IGNORED)(会让该字段永久失去增量语义)
更新时刷新 updated 不能 让 UserInfo 继承 BasePojo(带 @TableField(fill = INSERT_UPDATE)),或在 Service 里 setUpdated(new Date())
强制全量覆盖(把整行按请求体重写) 不能 用 UpdateWrapper 逐个 set,或自定义 SQL

白名单:设计在需求篇,落地在这一节

需求分析篇给过一张表,明确了哪些字段允许用户改、哪些不让改。那张表是设计意图 ,这一节要说的是代码有没有照做。

答案是:没有。

updateUserInfo 收的是 UserInfo 实体,方法体里只覆写了 id,然后原样交给 updateById。所以请求体里任何非 null 字段都会被写进库,包括白名单之外的那些:

字段 需求篇的结论 当前代码实际行为
nickname / gender / birthday / city / income / education / profession / marriage 允许改 允许改 ✅ 符合预期
tags 允许改 允许改 ✅
avatar 不走本接口,必须过 OSS 上传 + 人脸校验 能直接改 ❌ 绕过了人脸校验
coverPic 不该由用户在这个接口改 能直接改 ❌
created 系统维护,用户无权改 能直接改 ❌(它是普通字段,非 null 就写进去)
updated 系统维护 能传,但默认不填充,语义更乱 ❌
id 不许改 被 setId 覆写挡住了 ✅

要分清楚两类风险。id 那条是越权 (改别人的数据),已经被 setId 挡住了。剩下这些都是改自己的、但改了不该在这改的东西 ------其中 avatar 最值得警惕:工程里改头像的正规流程是 updateHead(上传 OSS → 百度人脸识别 → 写库),走 PUT /users 传一个 avatar URL 就完全跳过了人脸校验。也就是说,一个没通过人脸识别的图片 URL 可以直接成为用户的头像。这不算严重的数据泄露,但把一条业务校验链路变成了摆设。

修法就是前面 DTO 那一节给的:只要收参数的对象里没有 avatar 字段,攻击者就传不进来。 白名单靠类型定义落地,而不是靠前端自觉。这是最省事也最不容易被绕过的方式------比在控制器里写一串 if 删字段可靠得多,因为后者只在你记得加字段时才会更新那份 if 列表。

一句话:"前端不传敏感字段"不构成任何防护。前端是攻击者可完全控制的。任何安全边界都必须在服务端。

坑位汇总

坑 触发条件 现象 处理
越权改别人资料 控制器不做 setId 覆写,直接用请求体 id 用户 A 能改用户 B 的资料,无任何报错 无条件 userInfo.setId(UserHolder.getUserId())
改不动 null 想让某字段清空,传 null 静默无变化,库里还是旧值 LambdaUpdateWrapper.set(field, null) 显式写,或单字段 FieldStrategy.IGNORED
updated 不刷新 UserInfo 未继承 BasePojo、无填充注解 更新后 updated 还是老时间 继承 BasePojo,或 Service 里 setUpdated(new Date())
白名单外字段被更新 前端传了 avatar / coverPic / created 直接写库,avatar 绕过人脸校验 入参改 DTO,只声明允许改的字段
反序列化失败 age 传了非数字(如 "abc") Jackson 抛 HttpMessageNotReadableException,请求没进控制器 前端保证类型;本章后加的 ExceptionAdvice 会把这类异常兜成 500(而不是 400),排查时别只盯着网关卡
birthday 格式不受控 实体里 birthday 是 String 传什么格式都收得下,存进库就是那个格式 约定 yyyy-MM-dd 并在前端限制,服务端可加 @Pattern 校验
Postman 选错方法 用 POST 打 PUT /users HTTP 405 Method Not Allowed 测试时方法选 PUT;405 说明路径匹配上了但动词不对
以为返回 null 就是失败 ResponseEntity.ok(null) 响应体是空的 看状态码:200 就是成功,body 为空是接口约定

验证:三段可复现的实验

测试环境按工程配置:应用层 mf-app-server 跑在 18080,mf-dubbo-db 跑在 18081,Nacos 在 192.168.136.160:8848。先登录拿一个 token:

bash 复制代码
# 1. 登录(走的是排除在拦截器之外的 /user/login 系列接口)
curl -X POST 'http://192.168.136.160:18080/user/login' \
  -H 'Content-Type: application/json' \
  -d '{"phone":"13612345678","verificationCode":"123456"}'

# 从响应里取出 token,存成环境变量,后面复用
TOKEN='eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwibW9iaWxlIjoiMTM2MTIzNDU2NzgifQ.xxxxx'

实验一:正常更新,确认只改了提交的字段

bash 复制代码
# 只提交 nickname 和 city
curl -i -X PUT 'http://192.168.136.160:18080/users' \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"nickname":"小美在杭州","city":"杭州"}'

预期响应:

text 复制代码
HTTP/1.1 200
Content-Length: 0

200 + 空 body 就是成功。然后查库:

sql 复制代码
SELECT id, nickname, city, gender, birthday, updated
FROM tb_user_info
WHERE id = 1;

要确认三件事:

  1. nickname 变成 小美在杭州、city 变成 杭州;
  2. gender、birthday 保持原值没被清空 ------这条验证的是增量更新,如果它们变成了 NULL,说明字段策略不是 NOT_NULL,去查 MyBatis-Plus 版本;
  3. updated 大概率还是老时间------这条验证的就是"坑二"。

实验二:不传 token,确认 401 来自拦截器

bash 复制代码
curl -i -X PUT 'http://192.168.136.160:18080/users' \
  -H 'Content-Type: application/json' \
  -d '{"nickname":"随便改改"}'

预期:

text 复制代码
HTTP/1.1 401

如果这里返回的是 200,说明 TokenInterceptor 没注册上或者 WebConfig 的 addPathPatterns 没覆盖到 /users。反过来,也可以在控制器里打个断点------请求根本进不来,这能证明鉴权发生在控制器之前。

实验三:传白名单外的字段,看它会不会被改(本篇最有价值的验证)

bash 复制代码
# 显式传一个 avatar,这个字段按需求篇的设计"不该走本接口"
curl -i -X PUT 'http://192.168.136.160:18080/users' \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"avatar":"https://example.com/not-face-checked.png"}'

然后查库:

sql 复制代码
SELECT id, nickname, avatar FROM tb_user_info WHERE id = 1;

当前工程实现下,avatar 会被改成 https://example.com/not-face-checked.png。 这一条就是白名单没有在服务端强制的铁证------它同时也说明了为什么"改头像要走独立接口"这个设计意图,只靠文档和前端遵守是守不住的。

把入参换成 DTO 之后再跑同一个请求,avatar 就应该纹丝不动。这个前后对比实验,比读十遍"要用 DTO"的说明都管用。

顺手验证 @PutMapping 的方法匹配:

bash 复制代码
# 故意用 POST 打这个路径
curl -i -X POST 'http://192.168.136.160:18080/users' \
  -H "Authorization: $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"nickname":"x"}'

预期 405 Method Not Allowed------405 不等于 404,它说明路由找到了这个路径但 HTTP 方法不对,是"方法选错"而不是"地址写错"。这两个状态码混淆起来能查半天。

更新完还得再查一次

PUT /users 返回的是 ok(null),不带任何业务数据。所以前端改完资料后必须再调一次 GET /users 才能拿到更新后的值。

也可以让 PUT 直接把最新资料返回来,省掉一次往返。这里没这么做,是个取舍:

方案 优点 代价
返回 ok(null)(当前) 接口职责单一,"写"就是写;响应体小 前端要多发一次 GET;两次请求之间若有人改了资料,展示会不一致
返回更新后的 UserInfoVo 前端一次拿到结果,体验顺 PUT 承担了"写 + 读"两件事;要再查一次库(或用提交的数据拼一个不等于库里真实值的响应);还得复用 VO 转换逻辑

选第一条路没问题,只是前后端要把"改完要重新查"写进约定,别让前端误以为空 body 是失败。

接口契约与原型

更新接口在"编辑资料"页上对应的表单:

doc 复制代码
┌──────────────────────────────┐
│ ←        编辑资料             │
├──────────────────────────────┤
│  头像                [ > ]    │  ← 点击走独立上传接口,不在本 PUT 里
├──────────────────────────────┤
│  昵称             小美在杭州   │  ← nickname  文本
│  性别              女      ▼  │  ← gender    单选(男 / 女)
│  生日            1998-05-20   │  ← birthday  String,约定 yyyy-MM-dd
│  城市              杭州    ▼  │  ← city      选择器
│  学历              本科    ▼  │  ← education
│  行业              互联网  ▼  │  ← profession
│  收入            20-30万   ▼  │  ← income
│  婚姻状态          未婚    ▼  │  ← marriage  枚举编号
├──────────────────────────────┤
│          [   保存   ]         │  ← 触发 PUT /users,只带改动过的字段
└──────────────────────────────┘

要点:

  • 表单里没有 id、没有手机号。id 由 token 决定,手机号归账号安全流程,两者都不该出现在这个页面。
  • 提交时只发改动过的字段,这正是增量更新友好的地方------漏传的字段不会被清空。
  • 头像那一行是跳转,不是这个 PUT 能处理的字段(尽管当前代码允许你传 avatar,这是实现与设计的偏差)。
  • 响应为空 body,前端要根据 HTTP 状态码判断成败,成功后回列表页并重新拉一次 GET /users。

API 速览

项 值
路径 /users
方法 PUT
请求头 Authorization: <JWT token>(必须,拦截器校验)
Content-Type application/json
请求体 UserInfo 的 JSON,只放待改字段
id 来源 服务端从 token 取并覆写,忽略请求体里的 id
成功返回 200,空 body
鉴权失败 401(由 TokenInterceptor 返回)
方法用错 405
落库语句 UPDATE tb_user_info SET <非 null 字段> WHERE id = ?
影响行数 update 返回 void,拿不到

涉及的类与方法:

类 方法 职责
UsersControler updateUserInfo 覆写 id、转发
UserInfoService update 一行 Dubbo 转发
UserInfoApi update 契约
UserInfoApiImpl update updateById 落库
UserInfoMapper 继承 BaseMapper 提供 updateById
UserHolder getUserId 取当前登录用户 id

官方文档

常见误解澄清

以为 updateById 是全量覆盖。 不是。它是按主键更新非 null 字段的动态 SQL,3.4.1 下默认策略是 NOT_NULL。这个是好事------所以前端可以只提交改动项。

以为可以用它把字段改成 null。 不行。传 null 等于不更新这个字段,库里原值不动,而且没有任何报错,是最难查的一类"操作没生效"。

以为更新时要自己带上 updated。 UserInfo 没继承 BasePojo、没有填充注解,所以框架不会帮你刷新它。要么在实体层面接上自动填充,要么在 Service 里显式 setUpdated(new Date())。但别让前端传------更新时间该由服务端决定。

以为前端不传敏感字段就安全。 前端是攻击者完全可控的。当前实现收 UserInfo 实体,请求体里任何非 null 字段都会落库,avatar 就是这么绕过人脸校验的。白名单只有在服务端(最好是在类型定义上)强制才有意义。

以为 POST 和 PUT 在 Spring 里没区别。 路由层面都能写,但语义不同,且 Spring 会严格按 HTTP 方法匹配------用错直接 405。REST 规范建议"读用 GET、创建用 POST、替换用 PUT、局部改用 PATCH",本项目按这个约定来;实际工程里用 POST 跑通也没人拦你,只是团队约定和网关策略上会吃亏。

以为返回 null body 就是失败。 ResponseEntity.ok(null) 是 200 + 空 body,故意的。判断成败看状态码,401 才是没权限、405 才是方法错。而且更新成功后要重新调 GET /users 才能拿到新值------这是接口设计上的取舍,不是 bug。

以为 id 传不传都行。 这条恰恰是最不能含糊的:请求体里的 id 必须被丢弃 ,服务端只认 token。userInfo.setId(UserHolder.getUserId()) 少写一个字,就是一个越权漏洞。

总结

  • 更新链路只动两个文件:UsersControler 加接口、UserInfoService 加转发。数据库侧完全复用首登保存时写好的 save / findById / updateById 那一套。
  • userInfo.setId(UserHolder.getUserId()) 是本篇最重要的三行代码里的第一行。 id 是这次 UPDATE 唯一的定位条件,也是最危险的一个参数------它必须来自服务端会话,绝不能来自请求体。用覆盖而不是"为空才设"。
  • @PutMapping 对应"替换一个已存在资源"的语义,和 GET / POST 形成一致的分工。功能上和 POST 都能跑,差别在规范层面。
  • updateById 的实际语义是增量更新 (动态 SQL,3.4.1 下跳过 null 字段)。好处是漏传字段不会清空资料;代价是主动清空字段做不到 ,updated 也不会自动刷新(因为 UserInfo 没接上 BasePojo 那套填充)。
  • 需求篇画的字段白名单,当前代码没有强制 。avatar、coverPic、created 这些都能通过 PUT /users 直接写库,头像那条还顺带绕过了人脸校验。改法是入参收 DTO,让白名单由类型定义保证。
  • 验证时最有价值的一次实验不是"改成功了没有",而是故意传一个白名单外的字段,看它会不会被写进去------这个探针能直接暴露服务端校验的真实边界。

下一篇顺着本章往下走,讲这套代码里反复出现的"每个接口都要自己验一遍 token"这个问题怎么统一收口:《代码优化:问题分析》。

相关推荐
Promising_GEO1 小时前
GEE实战02期:从卫星波段开始:利用 Sentinel-2 自己计算 NDVI
java·sentinel·遥感·空间分析
大文说跨境1 小时前
亚马逊多账号管理浏览器怎么选?云登/紫鸟/AdsPower 对比
java·前端·自动化
CoderYanger1 小时前
Java EE 进阶:4.Spring Web MVC 入门(第一弹)
java·开发语言·前端·spring·面试·java-ee·mvc
估值探索者2 小时前
【Python量化选股流水线 #04】选股器好不好用?「昨日候选→今日表现」回看验收
开发语言·python·股票数据·股票数据api接口
顽疲2 小时前
java springboot vue 养老系统源码详解:请假 care_leave 销假与床位停费日扣实践
java·vue.js·spring boot
XIAEE2 小时前
一个即时通讯项目是怎么长出来的:(Spring Boot 3 + Vue 3 微前端 + 悟空 IM + LiveKit)
java·spring
n87652 小时前
Playwright Java WEB UI 自动化实现框架与逻辑
java·前端·ui
Java_AI工程师2 小时前
Spring4 / SpringBoot1.x 老项目想接入 AI?不用升级框架!3 套无痛落地方案
java·人工智能·spring boot
BBmmo2 小时前
Java Stream 的 filter 为什么看起来没运行?
java