Spring Boot 修改接口全字段更新为什么危险?MetaLite 如何只更新变化字段
摘要: 修改接口如果把请求对象完整覆盖到数据库实体,调用方漏传的字段可能被清空,旧页面还可能覆盖其他人刚写入的新值。MetaLite 使用
UpdateHelper比较参数对象与现有实体,只生成真正变化的字段,同时保留修改前后值用于审计。本文拆解这套差异更新模型,并说明 null、类型兼容、并发控制和敏感字段审计的真实边界。
一个看似普通的用户修改接口,往往这样写:
java
SysUserEntity entity = converter.convert(param);
userDao.updateById(entity);
代码很短,但它把三个问题混在了一起:
- 哪些字段允许修改?
- 哪些字段真的发生了变化?
- 修改前后分别是什么?
当参数模型、数据库实体和页面版本不完全一致时,全字段覆盖很容易变成隐蔽的数据破坏。
一、全字段覆盖为什么会误伤数据
假设数据库已有:
json
{
"userName": "alice",
"mobile": "13800000000",
"status": 1
}
旧版页面只提交:
json
{
"userName": "Alice"
}
如果先把请求转换成完整实体,再执行全字段更新,mobile 和 status 可能变成 Java 默认值或 null。
即使 ORM 默认跳过 null,也无法判断:
- 调用方没有传;
- 调用方明确要清空;
- JSON 反序列化后得到 null;
- 字段根本不属于当前修改场景。
所以"参数对象转实体"不能直接等价于"生成 UPDATE 集合"。
二、MetaLite 如何生成差异更新
UpdateHelper 接收两个对象:
text
Param:调用方希望修改的新值
Entity:数据库当前保存的旧值
它先收集双方字段,再按同名字段逐个比较:
java
Object newValue = getFieldValue(paramField, param);
Object oldValue = getFieldValue(entityField, entity);
if (newValue != null && !Objects.equals(newValue, oldValue)) {
before.add(...oldValue);
after.add(...newValue);
}
最后,afterToUpdate() 只把变化后的字段放进 Update:
java
for (UpdateField field : after) {
update.set(field.getName(), field.getValue());
}
数据库只更新真实发生变化的字段,而不是把整个实体重新写一遍。
三、为什么参数模型和实体模型应该分开
差异比较依赖同名且类型兼容的字段,但两类对象职责不同。
参数模型表达本次接口允许输入什么:
java
class UpdateUserParam {
private String mobile;
private Integer status;
}
实体模型表达数据库完整状态:
java
class SysUserEntity {
private Long id;
private String userName;
private String mobile;
private Integer status;
private LocalDateTime createTime;
}
调用方没有权限修改的 id、createTime 等字段,不应出现在参数模型中。
这样,更新白名单首先由类型结构决定,而不是靠 Controller 里零散的 if 判断。
四、字段比较为什么还要检查类型
同名不代表同一种语义。
当前实现使用 ClassUtils.isAssignable 双向判断类型是否兼容,也支持基本类型与包装类型的比较。
如果参数字段和实体字段类型不兼容,会直接跳过,而不是尝试字符串强转:
text
Param.status = String
Entity.status = Integer
→ 不参与差异更新
这种策略避免了隐式转换错误,但也意味着字段被跳过时不会自动报错。
如果某个字段是关键业务字段,参数校验和自动化测试应覆盖它确实进入差异集合,不能仅凭字段同名推断更新一定生效。
五、如何只比较指定字段
UpdateHelper 支持传入 compareFieldNames:
java
UpdateCompareResult result = UpdateHelper.genCompareResult(
List.of("mobile", "status"), param, entity);
这相当于第二层显式白名单。
它适合以下场景:
- 同一个参数类被多个修改接口复用;
- 当前操作只能修改少数字段;
- 管理员接口和用户自助接口权限不同;
- 状态字段必须走专用状态机,不能普通更新。
白名单为空时,当前实现以实体的全部字段名为候选集合,再跳过双方不共有的字段。
因此,安全敏感接口更适合显式传入字段列表,而不是依赖"参数对象里刚好没有危险字段"。
六、修改前后值如何服务审计
UpdateCompareResult 同时保存:
java
private List<UpdateField> before;
private List<UpdateField> after;
每个字段包含名称、描述和值。
字段描述优先读取参数字段上的 @Schema.description,为空时读取 @Schema.title。因此可以生成更易读的变更记录:
text
手机号:138****0000 → 139****0000
状态:启用 → 停用
同一份差异结果既可以转换成数据库 Update,也可以交给操作日志、审批记录或变更通知。
这比更新后重新查询再猜测差异更可靠,因为写入集合和审计集合来自同一次比较。
七、null 在当前模型中表示"不修改"
源码只在新值非 null 时记录差异:
java
if (paramFieldVal != null
&& !Objects.equals(paramFieldVal, entityFieldVal)) {
// 记录差异
}
因此参数传 null 与参数没传,在差异层都会表现为"不修改"。
优点是局部更新不会意外清空字段;代价是无法表达"把数据库字段显式改成 NULL"。
如果业务需要清空能力,应使用可区分三态的协议,例如:
text
未出现:不修改
出现且有值:更新为该值
出现且显式清空:更新为 NULL
可以通过字段存在性记录、Patch 操作对象或专用 setNull 语义实现,不能继续让一个 Java null 同时承担两种含义。
八、差异更新不能解决并发覆盖
差异比较通常先查数据库,再执行更新:
text
T1 查询 status=0
T2 查询 status=0
T1 更新 status=1
T2 更新 mobile,同时仍基于旧实体计算
只更新变化字段可以减少互相覆盖的范围,但不能证明操作基于最新版本。
需要防止丢失更新时,应在 WHERE 中增加版本或旧值条件:
sql
UPDATE sys_user
SET mobile=?, version=version+1
WHERE id=? AND version=?
更新条数为 0 时,返回并发冲突,让调用方刷新后重试。
当前 UpdateHelper 负责字段差异,不包含乐观锁版本校验。两者应组合使用,而不能互相替代。
九、审计日志不能原样保存所有值
差异对象中可能出现手机号、证件号、密钥、Token 或密码相关字段。
直接把 before、after 全量序列化到日志,会把数据库保护范围内的数据复制到日志系统。
审计层至少要支持:
- 敏感字段脱敏;
- 密码和密钥字段不记录值;
- 大文本截断;
- 审计记录访问授权;
- 保存操作者、TraceId、时间和操作来源。
"记录了修改前后值"只是审计数据结构,不等于完整审计安全已经完成。
十、一个可靠修改接口的执行顺序
推荐把修改链路固定为:
text
校验请求与字段权限
→ 查询当前实体
→ 生成字段差异
→ 没有差异则直接返回
→ 带版本条件执行局部更新
→ 保存脱敏后的审计记录
→ 清理相关缓存
实体差异更新真正解决的,不只是少生成几个 SET。
它把"允许修改什么、实际修改什么、之前是什么、之后是什么"变成同一份可验证的数据,让数据库写入、并发控制和操作审计能够围绕一个明确事实协作。
框架简介 MetaLite 是面向企业生产环境的新一代 Java 微服务技术底座。系列文章重点分享代码背后的设计思路、技术取舍与工程实践。
源码基线 JDK 21、Spring Boot 3.2.9、Spring Cloud 2023.0.1、Spring Cloud Alibaba 2023.0.1.3,具体组件版本以项目 backend-bom 为准。
作者简介 15 年 Spring 体系企业级开发经验,专注于 Java 微服务架构、工程治理与生产实践。
持续更新 MetaLite 系列内容将持续更新,围绕核心设计、源码链路、技术取舍与生产实践展开。欢迎关注作者,及时获取后续内容。
在线演示 演示地址: admin.metalite.top/ 演示账号: guess 演示密码: admin@2026