PAYATHON 2026

如何确定 Customer 与 currentPaymentMethod 的 JPA 映射

支付阿杰

结论

当前映射不太合理。selectedPayment 是否应该使用 @OneToOne,不能只看“一个 Customer 只能选一张卡”,还要看反向关系:一条 Payment 记录是否只能被一个 Customer 引用。

更合适的设计是:

  • Customer.payments 和 Payment.customer 使用双向 @OneToMany / @ManyToOne,明确支付方式属于哪个客户。
  • Customer.selectedPayment 通常使用不带级联的 @ManyToOne。
  • 业务代码需要保证 selectedPayment 属于当前 Customer 的 payments。
  • 如果该字段表示“本次购买使用的卡”,应放在订单、结账会话或支付记录上。Customer 更适合保存“默认支付方式”。

推荐映射

@Entity
public class Customer {

    @Id
    @GeneratedValue
    private Long id;

    @OneToMany(
        mappedBy = "customer",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    private Set<Payment> payments = new HashSet<>();

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "selected_payment_id")
    private Payment selectedPayment;

    public void addPayment(Payment payment) {
        payments.add(payment);
        payment.setCustomer(this);
    }

    public void removePayment(Payment payment) {
        if (payment.equals(selectedPayment)) {
            selectedPayment = null;
        }

        payments.remove(payment);
        payment.setCustomer(null);
    }

    public void selectPayment(Payment payment) {
        if (payment != null && !payments.contains(payment)) {
            throw new IllegalArgumentException(
                "Selected payment must belong to this customer"
            );
        }

        selectedPayment = payment;
    }

    // getters and setters
}
@Entity
public class Payment {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    private String creditCardNumber;
    private String creditCardType;

    public void setCustomer(Customer customer) {
        this.customer = customer;
    }

    // getters and setters
}

对应的表结构大致如下:

customer
--------
id
selected_payment_id

payment
-------
id
customer_id
credit_card_number
credit_card_type

payment.customer_id 记录支付方式属于哪个客户,customer.selected_payment_id 记录客户当前选中的支付方式。

为什么不建议直接使用 @OneToOne

@OneToOne 表示一条 Payment 最多只能被一个 Customer.selectedPayment 引用,数据库通常会通过唯一约束实现:

UNIQUE (selected_payment_id)

这和“每个 Customer 只能保存一个 selectedPayment”不是同一个概念。单值字段已经保证每个 Customer 最多引用一条记录,不需要再用 @OneToOne 表达一次。

如果业务明确规定一条 Payment 永远只能属于一个 Customer,而且数据库必须禁止其他 Customer 选择它,那么使用 @OneToOne 也不能算错。不过,支付方式的所有权已经由 Payment.customer 表达,selectedPayment 只是从多个支付方式中选出的一个引用。这里使用 @ManyToOne 可以避免不必要的唯一约束,也更符合字段的实际用途。

必须处理的完整性问题

普通外键只能保证:

  • selected_payment_id 指向一条存在的 Payment;
  • customer_id 指向一个存在的 Customer。

它无法自动保证:

customer.selected_payment_id 指向的 Payment
确实属于同一个 customer

因此,普通外键无法阻止下面这种错误数据:

Customer A.selectedPayment -> Payment B1
Payment B1.customer         -> Customer B

至少要在领域方法或服务层校验:

@Transactional
public void selectPayment(Long customerId, Long paymentId) {
    Customer customer = customerRepository.findById(customerId)
        .orElseThrow();

    Payment payment = paymentRepository.findById(paymentId)
        .orElseThrow();

    if (!payment.getCustomer().equals(customer)) {
        throw new IllegalArgumentException(
            "Payment does not belong to customer"
        );
    }

    customer.selectPayment(payment);
}

如果数据库层必须保证这种强一致性,可以增加复合外键或触发器。不过,具体实现取决于所用数据库,在 JPA 中也会复杂不少。多数系统会结合服务层校验、事务边界和数据库外键来维护数据完整性。

不要在两个关系上都使用 CascadeType.ALL

payments 是 Customer 拥有的子集合,因此在这个关系上使用:

cascade = CascadeType.ALL,
orphanRemoval = true

通常没有问题。

selectedPayment 只是对集合中某个对象的额外引用,不应再次负责 Payment 的生命周期管理。因此不建议这样写:

@OneToOne(cascade = CascadeType.ALL)
private Payment selectedPayment;

否则,同一个 Payment 会通过两条关联参与持久化和删除,可能导致删除顺序难以判断,甚至引发外键冲突。

删除支付方式前,还需要先清空选中关系:

if (payment.equals(selectedPayment)) {
    selectedPayment = null;
}
payments.remove(payment);

否则,当 orphanRemoval = true 准备删除 Payment 时,customer.selected_payment_id 可能仍然引用这条记录,数据库会拒绝删除。

“当前购买”不宜存放在 Customer 上

如果 selectedPayment 表示当前这次购买选择的卡,将它保存到 Customer 会带来并发问题。例如,同一个客户同时打开两个结账页面:

  • 结账 A 选择 Card 1;
  • 结账 B 选择 Card 2;
  • B 的操作覆盖 Customer 上的值;
  • A 提交时可能读取到 Card 2。

本次选择更适合保存在订单或结账会话中:

@Entity
public class Order {

    @Id
    @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Customer customer;

    @ManyToOne(fetch = FetchType.LAZY)
    private Payment paymentMethod;
}

如果 Customer 需要保存长期偏好,字段可以命名为:

private Payment defaultPaymentMethod;

订单负责保存本次实际选择。必要时还应保存支付令牌、卡组织和卡号后四位等快照,以免用户之后修改默认卡,进而影响历史订单。

支付卡数据的安全风险

不要直接保存完整信用卡号,也不能保存 CVV。生产系统通常只保存支付服务商提供的 token,以及用于展示的非敏感信息:

private String providerToken;
private String cardBrand;
private String lastFourDigits;
private Integer expirationMonth;
private Integer expirationYear;

存储、传输或记录完整卡号都会涉及严格的 PCI DSS 合规要求。除非系统具备相应的安全与合规能力,否则应将这些数据交给支付服务商托管。

综合来看,支付方式的归属适合使用 @OneToMany / @ManyToOne;默认支付方式应使用单独且不级联的引用;本次购买选择则应放在 Order 或 Checkout 上。这样的模型更清晰,也更安全。

备注:内容仅供参考。