
在使用 DTO 进行 JPA 实体更新时,若 DTO 中未显式携带 createdAt 字段(如前端或 Postman 未发送),直接通过 setUserDependents() 赋值会导致数据库中已有的创建时间被覆盖为 null,破坏审计字段完整性。
在使用 dto 进行 jpa 实体更新时,若 dto 中未显式携带 `createdat` 字段(如前端或 postman 未发送),直接通过 `setuserdependents()` 赋值会导致数据库中已有的创建时间被覆盖为 `null`,破坏审计字段完整性。
问题根源在于:UserDependentsDTO 中的 createdAt 字段被反序列化为 null(当请求 JSON 中未包含该字段时),而 UserMapper 在执行 toEntity(UserDependentsDTO) 映射时,会将 null 值直接写入 UserDependentsEntity.createdAt。尽管 @PrePersist 保证了插入时自动赋值,但 @PreUpdate 不会回填缺失的 createdAt —— 它仅更新 updatedAt,而 createdAt 若已为 null,则永久丢失。
✅ 正确解决方案是:禁止 DTO 控制不可变审计字段,并在映射层主动保护 createdAt 和 updatedAt。
✅ 推荐做法:自定义 MapStruct 映射策略(推荐)
修改 UserMapper,排除 createdAt/updatedAt 的自动映射,并显式保留原值:
@Named("UserMapper")
@Mapper(
unmappedTargetPolicy = ReportingPolicy.IGNORE,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE // 关键!忽略 null 值赋值
)
public interface UserMapper {
UserMapper mapper = Mappers.getMapper(UserMapper.class);
UserDTO toDto(UserEntity entity);
UserEntity toEntity(UserDTO dto);
List<UserDTO> toDto(List<UserEntity> entity);
List<UserEntity> toEntity(List<UserDTO> dto);
// 为 UserDependentsEntity → UserDependentsDTO 单独定义映射,跳过审计字段
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
UserDependentsEntity toEntity(UserDependentsDTO dto);
// 反向映射也忽略,避免反向污染
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "updatedAt", ignore = true)
UserDependentsDTO toDto(UserDependentsEntity entity);
}? NullValuePropertyMappingStrategy.IGNORE 是关键配置:它确保当 DTO 字段为 null 时,MapStruct 跳过该属性赋值,从而保留数据库原有值(如 createdAt)。
✅ 替代方案:手动合并依赖项(适用于简单场景)
若暂不引入 MapStruct 高级配置,可在 service 层显式保护:
public void updatePreferencesAndDependents(UUID userId, UpdatePreferencesDependentsDTO updateDTO) {
var user = getUserById(userId);
// 更新基础用户字段(非集合)
user.setReferralHospital(updateDTO.getReferralHospital());
user.setReferralHospitalId(updateDTO.getReferralHospitalId());
user.setAttendance(updateDTO.getAttendance());
user.setCoverage(updateDTO.getCoverage());
user.setAccommodation(updateDTO.getAccommodation());
user.setPartaking(updateDTO.getPartaking());
// 安全更新 dependents:仅同步业务字段,保留 createdAt/updatedAt
List<UserDependentsEntity> updatedDependents = updateDTO.getUserDependents().stream()
.map(dto -> {
UserDependentsEntity existing = findExistingDependent(dto.getUuid(), user.getUserDependents());
if (existing != null) {
// 仅更新可变字段,跳过 createdAt/updatedAt
existing.setType(dto.getType());
existing.setAge(dto.getAge());
existing.setRemoved(dto.getRemoved());
return existing;
} else {
// 新增项:走正常生命周期(@PrePersist 自动设 createdAt)
return UserMapper.mapper.toEntity(dto);
}
})
.collect(Collectors.toList());
user.setUserDependents(updatedDependents);
repository.save(user); // 注意:此处 save(user) 已含 cascade,无需再 toEntity 再 save
}⚠️ 注意事项:
- 不要调用 UserMapper.mapper.toEntity(user) 后再 repository.save(entity),这会绕过 JPA 一级缓存和脏检查,导致 @PreUpdate 失效、关联实体状态错乱;
- UserEntity 应正确配置 @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true),确保依赖项被正确管理;
- LocalDateTimeDeserializer 默认对缺失字段反序列化为 null,这是标准行为,不应在 DTO 中强制要求前端传 createdAt —— 它本质是只读审计字段。
? 总结:审计字段(createdAt/updatedAt)必须由持久层(JPA Lifecycle Callbacks 或数据库 DEFAULT)控制,绝不应由外部输入(DTO)驱动。通过 MapStruct 的 ignore + IGNORE 策略,或业务层显式合并,即可彻底杜绝“更新即清空创建时间”的陷阱。

















