PAYATHON 2026

如何在 Customer 中存储当前选中的 Payment 信息

支付小周

结论

payments 和 currentlySelectedPayment 可以同时存在,两者含义不同:前者是 Customer 拥有的全部支付方式,后者是其中默认或当前选中的一项。在 JPA 中,可以分别映射为 @OneToMany 和 @ManyToOne。

不建议只在 Payment 中增加 selected flag。切换银行卡时需要同时修改两条记录,而且普通的 JPA 唯一约束很难表达“每个 Customer 最多只能有一条 selected = true”。将当前选中的 Payment 保存在 Customer 上,关系会更清楚。

需要注意以下几点:

  • Payment 必须明确属于哪个 Customer。
  • 设置当前 Payment 时,必须验证它属于该 Customer。
  • 相关修改应放在同一个事务中。
  • 如果数据库必须强制保证归属关系,需要增加复合外键或调整表结构。

推荐的实体映射

如果每条 Payment 只属于一个 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 = "currently_selected_payment_id")
    private Payment currentlySelectedPayment;

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

    public void removePayment(Payment payment) {
        if (Objects.equals(currentlySelectedPayment, payment)) {
            currentlySelectedPayment = null;
        }

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

    public void selectPayment(Payment payment) {
        if (payment == null) {
            currentlySelectedPayment = null;
            return;
        }

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

        currentlySelectedPayment = payment;
    }

    // getters and setters
}

Payment 保存指向 Customer 的外键:

@Entity
@Table(
    uniqueConstraints = @UniqueConstraint(
        name = "uk_payment_customer_card",
        columnNames = {"customer_id", "cardType", "cardNumber"}
    )
)
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 cardType;
    private String cardNumber;
    private String nameOnCard;

    private LocalDate dateOfExpiry;

    // getters and setters
}

这两个字段表达的关系如下:

Customer 1 ─── * Payment
Customer 1 ─── 0..1 currentlySelectedPayment

currentlySelectedPayment 指向 Payment 表中的一条记录,不会复制银行卡信息。

如果业务含义是“默认支付方式”,建议将字段命名为 defaultPayment 或 defaultPaymentMethod。currentlySelectedPayment 容易被理解为页面上的临时选择,而这类页面状态通常不应持久化到数据库。

在业务层验证归属关系

不能只根据 paymentId 查询 Payment,然后直接赋值。否则,用户可能提交属于另一个 Customer 的 Payment ID。

可以同时使用 Customer ID 和 Payment ID 查询:

public interface PaymentRepository extends JpaRepository<Payment, Long> {

    Optional<Payment> findByIdAndCustomerId(
        Long paymentId,
        Long customerId
    );
}

再在事务中设置:

@Service
public class CustomerPaymentService {

    private final CustomerRepository customerRepository;
    private final PaymentRepository paymentRepository;

    public CustomerPaymentService(
        CustomerRepository customerRepository,
        PaymentRepository paymentRepository
    ) {
        this.customerRepository = customerRepository;
        this.paymentRepository = paymentRepository;
    }

    @Transactional
    public void selectPayment(Long customerId, Long paymentId) {
        Customer customer = customerRepository.findById(customerId)
            .orElseThrow(() -> new EntityNotFoundException(
                "Customer not found: " + customerId
            ));

        Payment payment = paymentRepository
            .findByIdAndCustomerId(paymentId, customerId)
            .orElseThrow(() -> new IllegalArgumentException(
                "Payment does not belong to customer: " + customerId
            ));

        customer.selectPayment(payment);
    }
}

与先加载 Payment、再调用 payments.contains(payment) 相比,这种查询更稳妥,也不需要初始化整个 Payment 集合。

添加 Payment

关联两端都要同步设置:

@Transactional
public Payment addPayment(Long customerId, PaymentData data) {
    Customer customer = customerRepository.findById(customerId)
        .orElseThrow(() -> new EntityNotFoundException(
            "Customer not found: " + customerId
        ));

    Payment payment = new Payment();
    payment.setCardType(data.cardType());
    payment.setCardNumber(data.cardNumber());
    payment.setNameOnCard(data.nameOnCard());
    payment.setDateOfExpiry(data.dateOfExpiry());

    customer.addPayment(payment);

    if (customer.getCurrentlySelectedPayment() == null) {
        customer.selectPayment(payment);
    }

    return payment;
}

由于配置了 cascade = CascadeType.ALL,保存 Customer 时可以级联保存新建的 Payment。是否需要级联和 orphanRemoval,要看 Payment 是否由 Customer 独占:

  • 如果 Payment 只属于一个 Customer,可以使用当前配置。
  • 如果 Payment 可能由多个 Customer 共享,就不适合这样建模,应改用 @ManyToMany 或单独的关联实体。
  • 对银行卡支付方式来说,通常更适合为每个 Customer 保存独立的支付方式记录,而不是让多个 Customer 共享同一条 Payment。

为什么不推荐 selected flag

下面的设计看起来很简单:

private boolean selected;

但每次切换都要完成两个动作:

oldPayment.setSelected(false);
newPayment.setSelected(true);

遇到并发请求或事务中途失败时,可能发生以下情况:

  • 同一个 Customer 有两张卡被标记为选中。
  • 所有卡都处于未选中状态。
  • 切换过程中出现竞争,后提交的数据覆盖先提交的数据。

标准的 @UniqueConstraint 也无法直接表达“同一个 customer_id 下只能有一行 selected = true”。部分唯一索引可以做到,但具体语法取决于数据库,无法通过可移植的 JPA 映射实现。

因此,在 Customer 上保存一个外键通常更合适。一个字段天然只能指向一个当前 Payment。

如何在数据库层强制保证归属关系

如果只设置以下外键:

customer.currently_selected_payment_id
    -> payment.id

数据库只能确认 Payment 存在,无法自动确认它的 customer_id 与当前 Customer 一致。应用层查询和校验通常已经足够。如果这项归属约束必须由数据库保证,可以设计复合外键:

FOREIGN KEY (id, currently_selected_payment_id)
REFERENCES payment (customer_id, id)

同时,需要让 Payment 表中的 (customer_id, id) 成为候选键,例如增加唯一约束:

UNIQUE (customer_id, id)

这种关系在 JPA 中映射起来更复杂,还可能涉及重复列映射和插入顺序。是否采用这种方案,要根据数据完整性要求和所用数据库决定。也可以使用数据库 trigger,但同样会引入与具体数据库相关的实现。

即使数据库已经添加约束,服务层仍应校验归属关系,以便返回清楚的业务错误。

原有逻辑需要调整的地方

原来的判断有几个问题:

customer.getCurrentlySelectedPayment().equals(payment)

如果当前 Payment 尚未设置,这段代码会触发 NullPointerException。可以改为:

Objects.equals(customer.getCurrentlySelectedPayment(), payment)

不过,赋值前通常不需要自行判断。JPA 的脏检查会判断字段是否真的发生了变化:

customer.selectPayment(payment);

另外,这段代码:

customer.getPayments().contains(payment)

是否可靠,取决于 Payment 的 equals() 和 hashCode() 实现。将 JPA Entity 放入 HashSet 时尤其要小心。如果 hashCode() 使用数据库生成的 ID,持久化后 ID 会从 null 变成具体值,从而破坏集合的哈希结构。

更安全的方式是通过受约束的查询检查重复,或者使用真正不可变的业务键实现相等性。不要直接使用可修改的卡片属性计算 hashCode()。

支付数据本身的注意事项

下面这种全局唯一约束通常不合适:

@UniqueConstraint(columnNames = {"cardType", "cardNumber"})

同一张卡可能被多个 Customer 合法使用。如果需要限制同一个 Customer 重复添加银行卡,唯一约束中至少应包含 customer_id。

生产系统通常也不应直接保存完整卡号。更常见的做法是保存支付服务商返回的 token,以及用于展示的非敏感信息:

private String providerPaymentMethodId;
private String cardBrand;
private String lastFourDigits;
private Integer expiryMonth;
private Integer expiryYear;

具体如何存储,必须符合所接入支付服务商的要求和适用的安全规范。

创建订单或处理购买时,还应在订单或支付记录中保存“本次交易实际使用的 Payment”。不能事后根据 customer.currentlySelectedPayment 推断,因为 Customer 之后可能更换默认银行卡。

备注:内容仅供参考。