You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Boot自定义Attribute Converter异常:重复调用与解密失效

JPA自定义AttributeConverter加密字段的问题排查

在Spring Boot+Spring JPA场景下,对自定义对象字段做加密存储时,自定义的AttributeConverter未按预期工作,存在两个问题:

  • 调用repository.save()保存数据时,convertToDatabaseColumn方法被重复调用4次,且会触发convertToEntityAttribute方法;
  • 调用repository.findByReferenceIdAndName()查询数据时,convertToEntityAttribute方法未被调用,无法获取解密后的值。

相关代码如下:

public class PIDetails {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "id")
    private Integer id;

    @Column(name = "name")
    private String name;

    @Column(name = "referenceId")
    private String referenceId;

    @Column(name = "pdo")
    @Convert(converter = AttributeEncryptor.class)
    private PersonalDetails pdo;

    public PIDetails(String lender, String referenceId) {
        this.lender = lender;
        this.referenceId = referenceId;
    }

    public void setpdo(@NotNull BoardingDetails boardingDetails) {
        this.bfo = boardingDetails;
    }
}

public class PersonalDetails {
    private FinancialDetails.FinancialDetailsData financialDetails;  
}

@NoArgsConstructor
@AllArgsConstructor
@SuperBuilder
@Getter
public class FinancialDetails extends OnboardingDetails {
    @NotNull(message = "financial data can't be null")
    private FinancialDetailsData data;

    @NoArgsConstructor
    @AllArgsConstructor
    @Builder
    @Getter
    @Setter
    public static class FinancialDetailsData {
        @JsonProperty("externalReferenceId")
        private String externalReferenceId;

        @JsonProperty("panNumber")
        private String panNumber;

        @JsonProperty("kycNo")
        private String kycNo;

        @JsonProperty("dob")
        private long dob;
    }

}


@Converter
public class AttributeEncryptor implements AttributeConverter<PersonalDetails, String> {

    private static final String AES = "AES";
    private static final String SECRET = "secret-key-12345";

    private final Key key;
    private final Cipher cipher;
    private Gson gson = new Gson();

    public AttributeEncryptor() throws Exception {
        key = new SecretKeySpec(SECRET.getBytes(), AES);
        cipher = Cipher.getInstance(AES);
    }

    @Override
    public String convertToDatabaseColumn(PersonalDetails personalDetails) {
        try {
            if(!isEncryptedString(personalDetails.getFinancialDetails().getPanNumber())){
                String encryptedPan = encrypt(personalDetails.getFinancialDetails().getPanNumber());
                personalDetails.getFinancialDetails().setPanNumber(encryptedPan);
            }
            String personalDetailsJson= gson.toJson(personalDetails);
            return personalDetailsJson;
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    @Override
    public PersonalDetails convertToEntityAttribute(String dbData) {
        try {
            PersonalDetails personalDetails = gson.fromJson(dbData, PersonalDetails.class);
            String panNumber = decrypt(personalDetails.getFinancialDetails().getPanNumber());
            personalDetails.getFinancialDetails().setPanNumber(panNumber);
            return personalDetails;
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
    }

    public  String encrypt(String attribute){
        try {
            cipher.init(Cipher.ENCRYPT_MODE, key);
            return Base64.getEncoder().encodeToString(cipher.doFinal(attribute.getBytes()));
        } catch (IllegalBlockSizeException | BadPaddingException | InvalidKeyException e) {
            throw new IllegalStateException(e);
        }
    }

    public  String decrypt(String attribute){
        try {
            cipher.init(Cipher.DECRYPT_MODE, key);
            return new String(cipher.doFinal(Base64.getDecoder().decode(attribute)));
        } catch (InvalidKeyException | BadPaddingException | IllegalBlockSizeException e) {
            throw new IllegalStateException(e);
        }
    }

    public boolean isEncryptedString(String attribute) {
        try {
            decrypt(attribute);
            return true;
        } catch(Exception e) {
            return false;
        }
    }
}

代码中存在的问题

1. 修改原始实体触发JPA重复转换

在convertToDatabaseColumn方法中,直接修改了传入的personalDetails对象的panNumber字段:

personalDetails.getFinancialDetails().setPanNumber(encryptedPan);

JPA会跟踪实体对象的状态变化,实体被修改后会重新触发字段转换逻辑,导致convertToDatabaseColumn被多次调用,甚至触发convertToEntityAttribute方法(实体状态变更后JPA可能重新校验加载)。

2. Cipher实例非线程安全,存在并发风险

Cipher类实例不是线程安全的,当前代码将其定义为全局成员变量,多线程环境下同时调用加密/解密方法会导致异常或数据错乱。

3. 实体类字段映射错误,赋值失败

  • PIDetails构造函数中使用this.lender,但类中未定义lender字段;
  • setpdo方法参数为BoardingDetails,却给this.bfo赋值,而类中实际字段是pdo,导致pdo字段无法被正确赋值,后续转换易出现空指针异常。

4. 序列化/反序列化字段映射不匹配

PersonalDetails类的字段是financialDetails(类型FinancialDetails.FinancialDetailsData),但FinancialDetails类的对应数据字段是data(类型FinancialDetailsData)。Gson序列化时,PersonalDetails的financialDetails会被序列化为JSON的"financialDetails"字段,与实际存储的结构不匹配,导致反序列化时personalDetails.getFinancialDetails()为null,进而在convertToEntityAttribute中抛出异常,甚至导致JPA无法触发转换逻辑。

5. 查询未触发解密的潜在原因

除上述序列化问题外,还可能是:

  • JPA使用二级缓存,缓存了未解密的原始数据,未触发Converter;
  • 自定义查询方法返回DTO而非实体对象,Converter仅对实体字段生效;
  • pdo字段被标记为懒加载,但查询时未显式加载该字段。

内容的提问来源于stack exchange,提问作者Shiva

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.18 23:07:01