Java 项目实战: 交友项目 - 个人资料管理的需求分析与接口设计

概述

上一节把"首次登录完善资料"讲完了,那是从无到有 的一次性动作。但用户资料不是填完就冻结的------换个城市、改个昵称、调一下学历,这些每天都在发生。这篇讲的是已有资料之后的查看与修改,也就是"我的"页面背后的那套接口:为什么不能把实体直接丢给前端、哪些字段允许用户改哪些不让改、更新到底是全量覆盖还是增量。

纲要

  • 两个场景的分界:完善资料(首次、从无到有)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)。能跑,但有三个问题,而且都是后期才爆的:

  1. 字段过度暴露 。实体是数据库表的映射,表的列有多少,响应里就有多少。created、updated、coverPic、tags 这些前端根本不用的字段全被推了出去,以后每加一个内部列,接口响应就自动多一个字段------相当于表结构直接暴露给了客户端。
  2. 格式不受控 。实体里 age 是 Integer,前端要的是字符串;birthday 是 String 存着,给前端却可能要按固定格式输出。直接返回的话,格式约定散落在各处,改一次要动所有地方。
  3. 表结构与接口强耦合 。哪天 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 有哪些坑,都在下一篇《个人资料管理:查询用户资料代码实现》里。

相关推荐
北风toto1 小时前
Linux 服务器如何判断 CPU 是否支持 x86-64-v2 指令集
java·linux·服务器
1024奇点1 小时前
【仓颉语言入门 · 第二篇 第 8 课】
开发语言·数据库·ide·mysql·开源
萧瑟余晖1 小时前
Spring Security综合实战与安全加固详解
java·安全·spring
gopher95111 小时前
RUST:序列化与反序列化 serde + derive 详解
开发语言·后端·rust
imDwAaY1 小时前
操作系统如何管理程序运行?从线程、进程到内存隔离与用户态切换:CS162 Note2
android·java·redis
const_qiu1 小时前
第三章 08:3.10 本章小结
java·人工智能
Pniubi1 小时前
快速排序(力扣基础)(递归)
java·数据结构·算法
H.莓飛2 小时前
【Linux】自主 Shell
linux·运维·服务器·c语言·开发语言·visual studio
CarIise2 小时前
Spring MVC 请求参数绑定与常用注解2
java·spring·mvc