如何在 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 之后可能更换默认银行卡。
备注:内容仅供参考。
