概述
上一节把"首次登录完善资料"讲完了,那是从无到有 的一次性动作。但用户资料不是填完就冻结的------换个城市、改个昵称、调一下学历,这些每天都在发生。这篇讲的是已有资料之后的查看与修改,也就是"我的"页面背后的那套接口:为什么不能把实体直接丢给前端、哪些字段允许用户改哪些不让改、更新到底是全量覆盖还是增量。
纲要
- 两个场景的分界:完善资料(首次、从无到有)vs 个人资料管理(日常、已成再改),接口与字段范围的差异
- 查看资料的需求与设计:该返回什么、不该返回什么、为什么必须过一层 VO
- 实体 vs VO 对照表:过度暴露、格式不受控、表结构直接冲击接口这三个坑
- 接口定义:
GET /users的路径、方法、参数来源、返回结构 - 更新资料的需求与设计:字段可改白名单,以及手机号 /
id/created/ 头像 / 状态位为什么不能改 - 更新语义:全量覆盖 vs 增量更新,以及工程实际用的是哪一种
- 完整流程时序图:查看链路与更新链路
- 异常与边界场景:资料不存在、Token 失效、提交越权字段、并发更新
- 原型线框图:"我的"页与"编辑资料"页
- 前后端契约要点:字段命名、生日格式、标签字段的分隔方式
- 常见误解澄清
先分清两个场景
这两件事长得很像,都在操作 tb_user_info 同一张表,但目标不同,所以接口设计也该不同。混在一起写,后面字段权限和返回内容一定会乱。
- 完善资料(首次) :用户刚注册进来,库里这条记录还不存在。字段全给 、必须一次填完,写库动作是
insert。它是一次性的,填完就再也不会走了(除非清号重来)。 - 个人资料管理(本篇) :库里有记录了,用户进"我的"页看看自己长什么样,顺手改两笔。看的时候只想要展示字段,改的时候只想改其中一两项,读多写少、高频反复。
| 维度 | 完善资料(首次) | 个人资料管理(本篇) |
|---|---|---|
| 记录状态 | 不存在,待创建 | 已存在,待查看/修改 |
| 触发时机 | 注册后强制跳转,一次 | 进"我的"页、点"编辑资料",高频 |
| 典型接口 | POST /user/loginReginfo |
GET /users + PUT /users |
| 落库动作 | insert |
update |
| 字段范围 | 全量必填(昵称/性别/生日/城市/头像) | 只提交改动项,允许漏传 |
| 返回内容 | 无(前端固定跳首页) | 一份裁剪过的展示数据(View Object) |
| 手机号 | 不涉及(注册时已存在 tb_user) |
不下发、不可改,归账号安全域 |
| 头像 | 首登流程中必做,走独立上传接口 | 同样走独立上传接口,不混进资料更新 |
| 失败代价 | 用户进不了首页,必须重来 | 影响小,重试即可 |
一句话概括差异:首次保存是"创建",本篇是"读取 + 局部修改"。创建时字段是"给的越多越完整越好";改成再做一次全量提交就很危险------用户只想改昵称,结果前端漏传了生日,生日就被清空了。
查看资料:该返回什么
场景很直白:用户点底部 Tab 进"我的"页,页面上要显示昵称、头像、性别、城市这些。对应的接口是 GET /users,响应数据从 tb_user_info 里查。
真正需要想清楚的不是"怎么查",而是查出来给前端哪些字段 。tb_user_info 的实体字段不少,但并不是每一个都适合出现在客户端响应里。按"该不该下发"分三档:
| 分类 | 字段 | 处理 |
|---|---|---|
| 该下发(展示用) | nickname、avatar、gender、age、birthday、city、education、profession、income、marriage |
直接进响应 |
| 该下发但要加工 | age(实体是 Integer,前端要字符串)、birthday(日期格式化) |
转换后再下发 |
| 不该下发 | tags 之外的内部字段、created / updated、coverPic 等维护性字段 |
不暴露 |
| 绝对不下发 | mobile(手机号) |
脱敏或不返回,归账号安全域 |
手机号这条特别说明一下。原型里"安全设置"页显示的是 186****7674------脱敏之后的形态,而且它出现在安全设置而不是个人资料页 。这已经把边界划清楚了:手机号是账号身份标识,不是个人资料。它一旦做了可改,等于账号可以被换绑到别人手机上,那不是资料管理,是账号安全流程(要短信验证、要有单独接口),绝不能挂在 PUT /users 上顺手改掉。
内部字段同理。像 userStatus 这类状态位是后台管控用的(比如封禁、审核状态),它和"用户想展示什么"完全是两回事。前端拿到它没有用途,泄露出去反而给了客户端伪造的空间。
为什么不能直接返回实体
新手最容易写的版本是这样:控制器里查完 UserInfo,直接 return ResponseEntity.ok(userInfo)。能跑,但有三个问题,而且都是后期才爆的:
- 字段过度暴露 。实体是数据库表的映射,表的列有多少,响应里就有多少。
created、updated、coverPic、tags这些前端根本不用的字段全被推了出去,以后每加一个内部列,接口响应就自动多一个字段------相当于表结构直接暴露给了客户端。 - 格式不受控 。实体里
age是Integer,前端要的是字符串;birthday是String存着,给前端却可能要按固定格式输出。直接返回的话,格式约定散落在各处,改一次要动所有地方。 - 表结构与接口强耦合 。哪天 DBA 把
birthday从字符串改成Date,直接返回实体的接口响应就变了,客户端立刻解析失败。接口应该是稳定的契约,不该跟着表结构抖动。
解法是加一层视图对象(VO, View Object):实体负责"数据库长什么样",VO 负责"前端要什么"。两者字段名、字段类型、字段数量都可以不一样,中间用属性拷贝 + 少量加工来接。
| 对比项 | 实体 UserInfo |
视图对象 UserInfoVo |
|---|---|---|
| 定位 | 数据库表的映射 | 给前端的视图 |
| 字段范围 | 表的全部列 | 只含该暴露的字段 |
age 类型 |
Integer |
String(按接口文档要求) |
是否含 mobile |
不在本表,但设计上同属"账号信息" | 不含 |
是否含 created/updated |
含 | 不含 |
是否含 coverPic/tags |
含 | 当前不含(见下文说明) |
| 生命周期 | 跟着表走 | 跟着接口文档走 |
| 变更影响 | 改表 → 改实体 | 改接口文档 → 改 VO,和表解耦 |
工程里 UserInfoVo 长这样:
java
package com.mf.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserInfoVo implements Serializable {
private Long id; //用户id
private String nickname; //昵称
private String avatar; //用户头像
private String birthday; //生日
private String gender; //性别
private String age; //年龄(注意:这里是字符串,实体里是 Integer)
private String city; //城市
private String income; //收入
private String education; //学历
private String profession; //行业
private Integer marriage; //婚姻状态
}
对着字段看几眼,VO 的设计意图很清楚:
age从Integer变成了String。这不是随手写的,接口文档就是这么要求的。前端拿到数字和字符串处理方式不一样,既然文档定了字符串,VO 就得扛下这个转换。- 没有了
mobile。手机号不在这个响应里------这是有意为之。 - 没有了
created/updated。这两个是维护字段,前端不关心。 tags和coverPic目前也没进 VO 。这里有个可以讨论的点:标签是展示性很强的字段(兴趣匹配那块用得上),实体里存着却没下发。当前快照没给,说明这一版 VO 是按最小必要字段裁的------先把核心展示字段跑通。真要用标签,加字段即可,VO 加字段不影响其它接口,这正是 VO 的价值。marriage保留成Integer。因为它是枚举编码,前端拿编码自己去查文案表,传字符串反而多一层约定。
实体本身的字段对照(来自 mf-model 的 UserInfo):
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) 这行值得留意:主键不是自增,而是沿用 tb_user 的 id。两张表一对一,用户 id 就是资料 id,查询、更新都不需要额外的关联字段。
查询接口定义
按工程实读,查询接口如下(注意路径是 /users,类名在工程里写成了 UsersControler,少了一个 l,是历史拼写,不影响运行):
| 项 | 值 |
|---|---|
| 请求路径 | /users |
| 请求方式 | GET |
| 请求头 | Authorization: <JWT token>(必须) |
| 请求参数 | userID(可选)。不传时查询当前登录用户 |
| 参数来源 | 传了 userID 就查指定用户;不传则从 token 解出 id |
| 返回 | UserInfoVo |
| 失败 | Token 非法 → 401 |
这里有个设计细节:当前用户的资料查询,不需要前端传 userId。前端只要带着 token 来,服务端自己知道"我是谁"。控制器里是这样兜的:
java
/**
* 查询用户资料
* 参数: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);
}
UserHolder.getUserId() 是从 ThreadLocal 里取当前登录用户的 id------拦截器在请求进入时已经把用户对象塞进去了,控制器直接拿就行,不用再手动解析一遍 token。这套统一鉴权是后面"代码优化"章节的内容,当前快照已经演进到这个形态。
为什么不干脆让前端每次都传自己的 id?因为前端传的 id 不可信 。只要接口接收 id 参数,就得额外校验"这个 id 是不是当前用户",否则用户把 id 改成别人的,就能查别人资料------越权。userID 参数保留是为了支持"查看他人主页"的场景,到那时会另开一个面向他人资料的接口或单独校验,不能和"看自己"混用同一套无校验逻辑。
按 VO 设计,预期返回(这是设计目标,工程里 VO 的转换实现见下一篇):
json
{
"id": 106,
"nickname": "黑马小美",
"avatar": "https://mf-bucket.oss-cn-hangzhou.aliyuncs.com/avatar/106.jpg",
"birthday": "1990-09-11",
"gender": "女",
"age": "35",
"city": "北京",
"income": "15K-25K",
"education": "本科",
"profession": "产品经理",
"marriage": 1
}
更新资料:允许改哪些
更新比查询更需要克制。用户的直觉是"这是我自己的资料,我想改什么改什么",但接口设计上不能这么放。允许用户在 PUT /users 上修改的,只有展示类字段;账号身份字段和后台管控字段必须挡在外面。
| 字段 | 允许用户改 | 理由 |
|---|---|---|
nickname |
是 | 展示字段,改昵称是最常见的操作。改前要过敏感词 |
gender |
是 | 展示 + 推荐维度,允许用户修正 |
birthday |
是 | 展示 + 换算年龄,允许修正 |
city |
是 | 同城推荐的基础,用户搬家就要改 |
education |
是 | 标签化展示字段 |
marriage |
是 | 标签化展示字段 |
income |
是 | 标签化展示字段 |
profession |
是 | 标签化展示字段 |
tags |
是 | 兴趣标签,展示与匹配用 |
mobile |
否 | 手机号是账号身份标识。改手机号意味着账号换绑,必须走短信验证的独立安全流程,不可能随资料接口一起改 |
id |
否 | 主键,身份标识,改了就是另一个用户了 |
created |
否 | 创建时间由系统维护,用户无权改 |
updated |
否 | 更新时间由系统维护(或在写库时自动刷新) |
avatar |
否(不走本接口) | 头像是文件不是普通字段,要走独立的上传接口(含 OSS 上传 + 人脸校验),不能混在 JSON 资料更新里 |
userStatus 之类状态位 |
否 | 后台管控字段。用户不能自己把自己从封禁状态解出来 |
这张表说白了就是一条原则:"自己的资料" ≠ "自己的一切数据"。展示属性归用户,身份属性和管控属性归系统。接口的边界画在哪,直接决定了一个越权漏洞能不能成立。
头像为什么单独拎出来?因为它和"改昵称"根本不是一类操作。改昵称是一次 update;改头像要上传二进制文件到 OSS 拿回 URL、过一遍百度人脸识别确认是真人、再写库,三步里有两次外部服务调用和校验分支。塞进 PUT /users 会让一个纯 JSON 接口变成 multipart,还要为头像单独加一堆校验------接口职责就糊了。工程里它是 updateHead 方法,独立路径独立逻辑。
更新接口定义
| 项 | 值 |
|---|---|
| 请求路径 | /users |
| 请求方式 | PUT |
| 请求头 | Authorization: <JWT token>(必须) |
| Content-Type | application/json |
| 请求体 | UserInfo 对象,只放待改字段 |
| id 来源 | 从 token 取 ,服务端覆写,不信请求体里的 id |
| 返回 | 无业务数据,成功 200;token 非法 401 |
请求示例(用户只想改昵称和城市):
json
{
"nickname": "小美在杭州",
"city": "杭州"
}
注意这个请求体里没有 id。服务端从 token 拿到当前用户 id,直接覆写进去:
java
/**
* 更新用户资料
*/
@PutMapping
public ResponseEntity updateUserInfo(@RequestBody UserInfo userInfo,
@RequestHeader("Authorization") String token) {
userInfo.setId(UserHolder.getUserId()); // id 以 token 为准,忽略请求体
userInfoService.update(userInfo);
return ResponseEntity.ok(null);
}
userInfo.setId(UserHolder.getUserId()) 这一行是防越权的关键。如果把它改成"用请求体里的 id",用户就能改别人资料了。这一行不是可选的。
Dubbo 层的接口只暴露三个方法,职责很干净:
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);
}
save 给首登流程用,update 和 findById 是本篇这两条链路的落点。应用层只负责"取 token 里的 id、转 VO、调 Dubbo",真正的读写都在 mf-dubbo-db 那边。
这两个功能牵涉到的类和模块分布在四个地方,理清归属看代码会快很多:
tree
mf-test
├── mf-app-server # 应用层:对客户端提供 HTTP 接口
│ └── src/main/java/com/mf/server
│ ├── controller/UsersControler.java # GET /users、PUT /users(实读类名少一个 l)
│ ├── service/UserInfoService.java # 实体→VO、调用 Dubbo
│ └── interceptor/UserHolder.java # ThreadLocal 存当前登录用户,取 userId
├── mf-dubbo
│ ├── mf-dubbo-interface # 服务接口(应用层与 DB 层共享的契约)
│ │ └── .../api/UserInfoApi.java # save / update / findById
│ └── mf-dubbo-db # 服务实现:真正读写数据库
│ └── .../api/UserInfoApiImpl.java # update→updateById(非空字段增量更新)
│ .../mappers/UserInfoMapper.java # 继承 BaseMapper<UserInfo>
├── mf-model # 领域对象与 VO
│ └── src/main/java/com/mf/model
│ ├── domain/UserInfo.java # 数据库实体(全字段)
│ └── vo/UserInfoVo.java # 响应视图对象(裁剪后的展示字段)
└── mf-commons # 工具类(如 JWT 相关)
应用层不直接碰数据库,只调 mf-dubbo-interface 暴露的 UserInfoApi;UserInfo 和 UserInfoVo 都放在 mf-model 里,让被 Dubbo 跨进程调用的对象能被应用层和服务层共同引用。这个分层决定了后面看时序图时"谁调谁"的顺序。
更新语义:全量覆盖还是增量
这是本节唯一一个真正的设计决策,值得单独说。
- 全量覆盖 :提交什么就写什么,把整行记录按请求体重写一遍。实现简单,但前端必须把没改的字段也原样传回来 ,漏传的字段会被写成
null,等于清空。用户只想改个城市,结果生日没了------这是线上最典型的"资料被清空"事故。 - 增量更新 :只更新提交的非空字段 ,没传的字段保持原值。对前端友好,风险低。代价是要处理
null的语义:用户想"主动清空某个字段"时,传null到底算"不更新"还是"清空"?这是增量更新绕不开的歧义。
工程实际用的是增量更新 ------不是业务代码里手写的 if 判断,而是 MyBatis-Plus 的默认行为给的。UserInfoApiImpl.update 里:
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) {
// MyBatis-Plus 的 updateById 默认只拼非 null 字段,自动实现增量更新
userInfoMapper.updateById(userInfo);
}
@Override
public UserInfo findById(Long id) {
return userInfoMapper.selectById(id);
}
}
userInfoMapper.updateById() 是 BaseMapper 自带的方法,生成的 SQL 是动态的:只有实体里非 null 的字段才会出现在 SET 后面。所以只传 nickname 和 city 时,生成的 SQL 大致是:
sql
UPDATE tb_user_info
SET nickname = '小美在杭州',
city = '杭州'
WHERE id = 106;
其它字段原样不动。推荐增量更新,工程也是这么做的,理由就是前面那条:能避免漏传导致字段被清空。
但增量更新有个副作用得知道:null 变成"不更新"的语义之后,用户没法通过这个接口把某个字段改回空 。比如学历从"本科"改成不填,传 {"education": null} 会被当作"不更新学历",改不掉。真要支持清空,常规做法是约定空字符串 "" 表示清空,或单独提供一个显式的清空标记。个人资料这种场景里,清空是低频需求,当前不做也可以,但文档里要写明,免得前端以为传 null 就能清掉。
另外要注意 updated 这个字段。用 updateById 增量更新时,如果没在别处配置自动填充,updated 是不会自己变的。要么在实体上加 MyBatis-Plus 的自动填充注解,要么在更新前显式赋值------否则"更新时间"这列永远是旧值。
完整流程
查看链路:请求进拦截器,鉴权后把用户放进 ThreadLocal,控制器从 token 对应的用户里取 id,不传 userID 就是查自己,拿到实体后转 VO 再返回。
MySQL(tb_user_info) UserInfoMapper UserInfoApi(Dubbo) UserInfoService UsersControler 拦截器(鉴权) 客户端("我的"页) MySQL(tb_user_info) UserInfoMapper UserInfoApi(Dubbo) UserInfoService UsersControler 拦截器(鉴权) 客户端("我的"页) #mermaid-svg-Z8EOxYKyWzpYvsjk{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-Z8EOxYKyWzpYvsjk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Z8EOxYKyWzpYvsjk .error-icon{fill:#552222;}#mermaid-svg-Z8EOxYKyWzpYvsjk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Z8EOxYKyWzpYvsjk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Z8EOxYKyWzpYvsjk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Z8EOxYKyWzpYvsjk .marker.cross{stroke:#333333;}#mermaid-svg-Z8EOxYKyWzpYvsjk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Z8EOxYKyWzpYvsjk p{margin:0;}#mermaid-svg-Z8EOxYKyWzpYvsjk .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Z8EOxYKyWzpYvsjk text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Z8EOxYKyWzpYvsjk .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Z8EOxYKyWzpYvsjk .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Z8EOxYKyWzpYvsjk #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Z8EOxYKyWzpYvsjk .sequenceNumber{fill:white;}#mermaid-svg-Z8EOxYKyWzpYvsjk #sequencenumber{fill:#333;}#mermaid-svg-Z8EOxYKyWzpYvsjk #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Z8EOxYKyWzpYvsjk .messageText{fill:#333;stroke:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Z8EOxYKyWzpYvsjk .labelText,#mermaid-svg-Z8EOxYKyWzpYvsjk .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .loopText,#mermaid-svg-Z8EOxYKyWzpYvsjk .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .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-Z8EOxYKyWzpYvsjk .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Z8EOxYKyWzpYvsjk .noteText,#mermaid-svg-Z8EOxYKyWzpYvsjk .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Z8EOxYKyWzpYvsjk .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Z8EOxYKyWzpYvsjk .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Z8EOxYKyWzpYvsjk .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Z8EOxYKyWzpYvsjk .actorPopupMenu{position:absolute;}#mermaid-svg-Z8EOxYKyWzpYvsjk .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-Z8EOxYKyWzpYvsjk .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Z8EOxYKyWzpYvsjk .actor-man circle,#mermaid-svg-Z8EOxYKyWzpYvsjk line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Z8EOxYKyWzpYvsjk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET /users + Authorization 1 校验 JWT,解析用户对象 2 UserHolder.set(user) 3 放行 4 userID 为空 → UserHolder.getUserId() 5 findById(userID) 6 findById(userID) 7 selectById(id) 8 SELECT * FROM tb_user_info WHERE id = ? 9 UserInfo(实体) 10 UserInfo 11 UserInfo 12 实体 → UserInfoVo,age 转字符串 13 UserInfoVo 14 200 UserInfoVo 15
更新链路:同样是先鉴权,控制器用 token 里的 id 覆写请求体,再交给 Dubbo 增量更新。
MySQL(tb_user_info) UserInfoMapper UserInfoApi(Dubbo) UserInfoService UsersControler 拦截器(鉴权) 客户端("编辑资料"页) MySQL(tb_user_info) UserInfoMapper UserInfoApi(Dubbo) UserInfoService UsersControler 拦截器(鉴权) 客户端("编辑资料"页) #mermaid-svg-BI1Kc8U0wTM0cAnm{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-BI1Kc8U0wTM0cAnm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-BI1Kc8U0wTM0cAnm .error-icon{fill:#552222;}#mermaid-svg-BI1Kc8U0wTM0cAnm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-BI1Kc8U0wTM0cAnm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-BI1Kc8U0wTM0cAnm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-BI1Kc8U0wTM0cAnm .marker.cross{stroke:#333333;}#mermaid-svg-BI1Kc8U0wTM0cAnm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-BI1Kc8U0wTM0cAnm p{margin:0;}#mermaid-svg-BI1Kc8U0wTM0cAnm .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-BI1Kc8U0wTM0cAnm text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-BI1Kc8U0wTM0cAnm .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-BI1Kc8U0wTM0cAnm .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-BI1Kc8U0wTM0cAnm #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-BI1Kc8U0wTM0cAnm .sequenceNumber{fill:white;}#mermaid-svg-BI1Kc8U0wTM0cAnm #sequencenumber{fill:#333;}#mermaid-svg-BI1Kc8U0wTM0cAnm #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-BI1Kc8U0wTM0cAnm .messageText{fill:#333;stroke:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-BI1Kc8U0wTM0cAnm .labelText,#mermaid-svg-BI1Kc8U0wTM0cAnm .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .loopText,#mermaid-svg-BI1Kc8U0wTM0cAnm .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .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-BI1Kc8U0wTM0cAnm .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-BI1Kc8U0wTM0cAnm .noteText,#mermaid-svg-BI1Kc8U0wTM0cAnm .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-BI1Kc8U0wTM0cAnm .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-BI1Kc8U0wTM0cAnm .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-BI1Kc8U0wTM0cAnm .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-BI1Kc8U0wTM0cAnm .actorPopupMenu{position:absolute;}#mermaid-svg-BI1Kc8U0wTM0cAnm .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-BI1Kc8U0wTM0cAnm .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-BI1Kc8U0wTM0cAnm .actor-man circle,#mermaid-svg-BI1Kc8U0wTM0cAnm line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-BI1Kc8U0wTM0cAnm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 用 token 的 id 覆写,防越权 PUT /users + Authorization + {待改字段} 1 校验 JWT,UserHolder.set(user) 2 放行 3 userInfo.setId(UserHolder.getUserId()) 4 update(userInfo) 5 update(userInfo) 6 updateById(userInfo) 7 UPDATE ... SET 非null字段 WHERE id = ? 8 影响行数 9 ok 10 ok 11 ok 12 200 null 13
字段可见性与可改性,用一张流程图把两端的边界画出来:
#mermaid-svg-odbglFUvnQSm8maK{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-odbglFUvnQSm8maK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-odbglFUvnQSm8maK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-odbglFUvnQSm8maK .error-icon{fill:#552222;}#mermaid-svg-odbglFUvnQSm8maK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-odbglFUvnQSm8maK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-odbglFUvnQSm8maK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-odbglFUvnQSm8maK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-odbglFUvnQSm8maK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-odbglFUvnQSm8maK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-odbglFUvnQSm8maK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-odbglFUvnQSm8maK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-odbglFUvnQSm8maK .marker.cross{stroke:#333333;}#mermaid-svg-odbglFUvnQSm8maK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-odbglFUvnQSm8maK p{margin:0;}#mermaid-svg-odbglFUvnQSm8maK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-odbglFUvnQSm8maK .cluster-label text{fill:#333;}#mermaid-svg-odbglFUvnQSm8maK .cluster-label span{color:#333;}#mermaid-svg-odbglFUvnQSm8maK .cluster-label span p{background-color:transparent;}#mermaid-svg-odbglFUvnQSm8maK .label text,#mermaid-svg-odbglFUvnQSm8maK span{fill:#333;color:#333;}#mermaid-svg-odbglFUvnQSm8maK .node rect,#mermaid-svg-odbglFUvnQSm8maK .node circle,#mermaid-svg-odbglFUvnQSm8maK .node ellipse,#mermaid-svg-odbglFUvnQSm8maK .node polygon,#mermaid-svg-odbglFUvnQSm8maK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-odbglFUvnQSm8maK .rough-node .label text,#mermaid-svg-odbglFUvnQSm8maK .node .label text,#mermaid-svg-odbglFUvnQSm8maK .image-shape .label,#mermaid-svg-odbglFUvnQSm8maK .icon-shape .label{text-anchor:middle;}#mermaid-svg-odbglFUvnQSm8maK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-odbglFUvnQSm8maK .rough-node .label,#mermaid-svg-odbglFUvnQSm8maK .node .label,#mermaid-svg-odbglFUvnQSm8maK .image-shape .label,#mermaid-svg-odbglFUvnQSm8maK .icon-shape .label{text-align:center;}#mermaid-svg-odbglFUvnQSm8maK .node.clickable{cursor:pointer;}#mermaid-svg-odbglFUvnQSm8maK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-odbglFUvnQSm8maK .arrowheadPath{fill:#333333;}#mermaid-svg-odbglFUvnQSm8maK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-odbglFUvnQSm8maK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-odbglFUvnQSm8maK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-odbglFUvnQSm8maK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-odbglFUvnQSm8maK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-odbglFUvnQSm8maK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-odbglFUvnQSm8maK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-odbglFUvnQSm8maK .cluster text{fill:#333;}#mermaid-svg-odbglFUvnQSm8maK .cluster span{color:#333;}#mermaid-svg-odbglFUvnQSm8maK 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-odbglFUvnQSm8maK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-odbglFUvnQSm8maK rect.text{fill:none;stroke-width:0;}#mermaid-svg-odbglFUvnQSm8maK .icon-shape,#mermaid-svg-odbglFUvnQSm8maK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-odbglFUvnQSm8maK .icon-shape p,#mermaid-svg-odbglFUvnQSm8maK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-odbglFUvnQSm8maK .icon-shape .label rect,#mermaid-svg-odbglFUvnQSm8maK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-odbglFUvnQSm8maK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-odbglFUvnQSm8maK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-odbglFUvnQSm8maK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GET 查看
PUT 更新
昵称/性别/生日/城市/学历等
mobile/id/created/状态位
avatar
请求进入 /users
GET 还是 PUT
查 UserInfo 实体
裁字段 + 加工
保留: 昵称/头像/性别/城市/学历等
转换: age Integer→String
剔除: mobile/created/updated/状态位
UserInfoVo 返回
请求体字段白名单校验
字段在白名单里吗
允许更新
忽略,不写库
忽略,走独立上传接口
增量 updateById
返回 200
异常与边界场景
| 场景 | 表现 | 处理方式 | 取舍说明 |
|---|---|---|---|
| 资料不存在 | 首登用户还没填资料就查 GET /users |
返回 404 或空 VO,前端引导去完善页 |
别直接抛 500,这属于正常业务分支 |
| 未登录 / Token 失效 | 拦截器或控制器校验失败 | 返回 401 |
前端收到 401 统一跳登录页 |
| 提交了不允许改的字段 | 请求体里带了 mobile / userStatus |
静默忽略,只更新白名单字段 | 推荐忽略而非报错:报错会把内部字段清单暴露给攻击者,还容易误伤版本不一致的前端 |
提交空对象 {} |
没有任何可更新字段 | 直接返回 200,不做写库 |
updateById 全 null 时生成的 SQL 无 SET,MyBatis-Plus 会拒绝执行或空转,先判空更省事 |
| 提交的 id 与 token 不一致 | 请求体伪造了别人的 id | 以 token 为准覆写,忽略请求体 id | 这是硬性防越权,不能妥协 |
| 昵称含敏感词 | 改了带敏感词的昵称 | 拦截并提示 | 敏感词过滤是独立能力,放在更新前做 |
| 同一用户两端并发修改 | 手机和 Pad 同时改,后提交的覆盖先提交的 | 最后写入赢(Last Write Wins) | 个人资料场景可以接受:单人操作,冲突概率低。知道有这特性即可,不必上乐观锁 |
| 超长文本 / 非法枚举 | city 传了一段乱码 |
长度与枚举校验 | 加在校验层,别指望数据库约束兜底 |
| 更新不存在的 id | token 合法但资料行不存在 | 影响行数为 0,返回成功 | 可接受;严格一点可先 selectById 判断再更新 |
原型线框图
原型里"我的"页和"编辑资料"页都有,照着实读文本还原。移动端一屏一图。
第一屏:我的(个人主页)
doc
┌─────────────────────────────────┐
│ │
│ [头像] 黑马小美 │
│ 北京 │
│ │
│ 互相关注 喜欢 粉丝 │
│ 12 36 88 │
│ │
│ ─────────────────────────────── │
│ 通用设置 > │
│ 客服在线 > │
│ 我的动态 > │
│ 谁看过我 > │
│ │
│ ─────────────────────────────── │
│ [交友] [消息] [我的] [圈子] │
│ (底部 Tab,我的高亮) │
└─────────────────────────────────┘
要点:
- 头像 + 昵称 + 城市是资料展示的核心三件套,对应
GET /users返回的avatar/nickname/city。 - 页面加载即触发一次资料查询,当前登录用户不传
userID。 - 头像区域"点击后进入编辑资料页面"------查看与编辑是同一份数据的两个视图。
- 底部 Tab 在"我的"高亮,说明这是登录后稳定可达的页面。
第二屏:编辑资料
doc
┌─────────────────────────────────┐
│ ← 返回 编辑资料 │
│ ─────────────────────────────── │
│ 头像 [当前头像] │
│ 昵称 黑马小美 │
│ 选择性别 女 │
│ 现居城市 北京 │
│ 生日 1990-09-11 │
│ 学历 本科 │
│ 月收入 15K-25K │
│ 行业 产品经理 │
│ 婚姻状态 未婚 │
│ │
│ ─────────────────────────────── │
│ ← 返回 安全设置 │
│ 修改密码 > │
│ 手机号 186****7674 │
│ 手势锁 > │
└─────────────────────────────────┘
要点:
- 昵称 :点击弹出输入框(有取消/确定),提交前做敏感词过滤 ;输入范围限定汉字、字母、数字、
-和_。敏感词命中时提示"输入内容包含敏感词"。 - 性别:弹出滚轮,男 / 女二选一。
- 现居城市 :弹出滚轮,省份 + 城市,精准到市级。
- 生日 :弹出滚轮选日期,格式
1990-09-11。 - 头像:不在这个表单里直接改,点进去走独立的"设置头像"流程(拍照/相册 → 裁剪 → 人脸审核)。
- 手机号 :出现在安全设置 页且已脱敏为
186****7674,旁边是"修改密码"和"手势锁"。这三项都是账号安全域,不归个人资料接口管,这正是前面把手机号排除在外的原型依据。 - 页面上的"取消/确定"按钮说明更新是显式提交的,不是输入即保存------所以一次
PUT /users提交的是用户在这一次编辑里改动的字段集合。
前后端契约要点
接口设计定下来之后,剩下容易出问题的是"字段怎么表达"。三处需要提前约定死:
| 契约点 | 约定 | 为什么 |
|---|---|---|
| 字段命名 | 统一小驼峰 (nickname、coverPic) |
Java 字段与 JSON 同名,不需要额外的序列化映射;避免 nick_name / nickName 混用 |
| 生日格式 | 统一 yyyy-MM-dd 字符串 |
实体里 birthday 本身就是 String,继续按字符串传最省事。别用时间戳:时区差异会让"1990-09-11"变成"1990-09-10" |
| 标签字段 | 库里以逗号分隔的字符串 存储;对外建议改成数组下发 | 实体是 tags("旅行,美食,摄影")。直接下发字符串,前端每种语言都得自己 split;下发数组 ["旅行","美食","摄影"] 更友好。但当前 VO 未下发 tags,所以这一条先记着,等要用时再定,免得后面出现"更新传数组、查看返回字符串"的不一致 |
| id 传递 | 更新时请求体不带 id ,查询当前用户不带 userID | 双方都从 token 取,少一个可被篡改的入口 |
| 时间字段 | created / updated 不下发 |
内部维护字段,前端不需要 |
年龄 age |
响应里是字符串 | 接口文档定的,别按实体类型以为是数字 |
关于生日格式那个坑,再强调一次:先用时间戳、后改成日期字符串,或者反过来,都是典型的返工来源。约定一次、写进接口文档、两边照着做。
常见误解澄清
- "直接返回实体最省事"。省的是几行拷贝代码,代价是字段过度暴露、格式不受控、表结构变更直接冲击接口。VO 那点转换成本换的是接口的稳定性。
- "改自己的资料,什么都能改" 。手机号、
id、created、状态位都不能动。手机号是账号身份,状态位是后台管控,把它们放进用户可改集合就是安全漏洞。 - "更新随便用全量覆盖就行" 。前端漏传任何字段,那个字段就被清空。用 MyBatis-Plus 的
updateById天然是增量更新,只更新非空字段,风险小得多。 - "查看自己的资料也要传 userId" 。不用。传了反而要额外校验,不传则从 token 取 id,少一个参数就少一个越权口子。
- "改头像也在资料更新接口里"。不在。头像是文件,要走上传 + 人脸校验的独立接口,别往 JSON 接口里塞。
API 速览
| 功能 | 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|---|
| 查看资料(当前用户) | GET |
/users |
Header Authorization;userID 可选,不传查自己 |
UserInfoVo |
| 查看资料(指定用户) | GET |
/users |
Header Authorization;userID=<目标用户id> |
UserInfoVo |
| 更新资料 | PUT |
/users |
Header Authorization;Body 为待改字段的 UserInfo;id 由 token 决定 |
空(200) |
| 服务层查询 | - | UserInfoApi.findById(Long) |
用户 id | UserInfo |
| 服务层更新 | - | UserInfoApi.update(UserInfo) |
含 id 与非空待改字段 | 无 |
官方文档
总结
- 完善资料是"从无到有"的一次性
insert,个人资料管理是"已成再改"的GET+PUT。两者共用一张表,但字段范围、返回内容和落库动作都不同,设计上要分开。 - 查看资料的核心不是查询,而是返回哪些字段 。加一层 VO,把"数据库长什么样"和"前端要什么"解耦,顺带解决格式转换(如
age从Integer到String)。手机号、内部字段、维护字段都不该直接下发。 - 更新资料的核心是白名单 。展示类字段允许改,身份类(
mobile、id)、维护类(created、updated)、管控类(userStatus)一律不允许。头像是文件,走独立接口。 - 更新语义选增量更新 。工程用 MyBatis-Plus 的
updateById天然实现了这一点,只更新非空字段,避免漏传导致字段被清空;代价是null不能用来清空字段,需要在文档里写明。 - 防越权就一句话:id 从 token 取,不信请求体。
下一步是把设计落成代码------UsersController 和 UserInfoService 怎么写、Dubbo 服务怎么调、以及 VO 转换的 BeanUtils.copyProperties 有哪些坑,都在下一篇《个人资料管理:查询用户资料代码实现》里。