概述
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;
要确认三件事:
nickname变成小美在杭州、city变成杭州;gender、birthday保持原值没被清空 ------这条验证的是增量更新,如果它们变成了NULL,说明字段策略不是NOT_NULL,去查 MyBatis-Plus 版本;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 |
官方文档
- MyBatis-Plus 官网(字段策略、更新方法)
- MyBatis-Plus 配置参考(
update-strategy) - Spring Framework
@PutMapping - Apache Dubbo 注解配置(
@DubboService/@DubboReference)
常见误解澄清
以为 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"这个问题怎么统一收口:《代码优化:问题分析》。