PAYATHON 2026

Spring Data 单向 OneToMany 如何写入 join table?

支付小周

结论

不需要额外创建中间实体。单向 @OneToMany 可以直接用 @JoinTable 维护关联表。

当前代码的问题在于:当 subscription.getPayments() 返回 null 时,新建的 HashSet 只赋给了局部变量,没有设置回 subscription。保存时,实体的 payments 属性依然是 null,JPA 也就不会向 join table 写入记录。

修正保存逻辑

需要补上 subscription.setPayments(payments):

@Transactional
public Subscription activate(
        @Valid Subscription subscription,
        @Valid Payment payment) {

    Set<Payment> payments = subscription.getPayments();

    if (payments == null) {
        payments = new HashSet<>();
        subscription.setPayments(payments);
    }

    payments.add(payment);

    return subscriptionRepository.save(subscription);
}

当前映射配置了:

cascade = CascadeType.ALL

如果 payment 是尚未持久化的新实体,保存 Subscription 时,JPA 会级联保存 Payment,随后写入关联表。

更合适的做法是在实体中直接初始化集合,避免它从一开始就是 null:

@Entity
public class Subscription {

    @OneToMany(
        fetch = FetchType.EAGER,
        cascade = CascadeType.ALL
    )
    @JoinTable(
        name = "subscription_payment",
        joinColumns = @JoinColumn(
            name = "subscription_id",
            referencedColumnName = "id"
        ),
        inverseJoinColumns = @JoinColumn(
            name = "payment_id",
            referencedColumnName = "id",
            unique = true
        )
    )
    private Set<Payment> payments = new HashSet<>();

    public Set<Payment> getPayments() {
        return payments;
    }

    public void setPayments(Set<Payment> payments) {
        this.payments = payments;
    }

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

这样,业务方法可以简化为:

@Transactional
public Subscription activate(
        @Valid Subscription subscription,
        @Valid Payment payment) {

    subscription.addPayment(payment);
    return subscriptionRepository.save(subscription);
}

更稳妥的写法:先加载受管理实体

如果方法参数中的 subscription 来自 HTTP 请求或其他反序列化过程,它通常不属于当前持久化上下文。此时可以根据 ID 从数据库重新查询实体,再修改关联:

@Transactional
public Subscription activate(
        Integer subscriptionId,
        @Valid Payment payment) {

    Subscription subscription = subscriptionRepository
            .findById(subscriptionId)
            .orElseThrow(() ->
                    new IllegalArgumentException("Subscription not found"));

    subscription.addPayment(payment);

    return subscription;
}

此时,事务中的 subscription 是 managed entity。集合变化后,JPA 会在 flush 或事务提交时同步关联表。严格来说,这里通常不必再次调用 save()。保留也可以,但不是必需的。

建议完善实体映射

Payment 中的枚举字段最好明确指定存储方式:

@Entity
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @JsonIgnore
    private Integer id;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private PaymentType paymentType;

    @Past
    @Column(nullable = false)
    private Date date;

    public enum PaymentType {
        MONEY,
        PROMO_CODE,
        TRIAL
    }
}

使用 EnumType.STRING 后,数据库会保存 MONEY、PROMO_CODE 等字符串。如果不指定,JPA 默认通常按枚举序号保存。以后调整枚举项的顺序,可能会改变已有数据的含义。

还应避免使用原始类型:

Set payments

应改为:

Set<Payment> payments

unique = true 的含义

以下配置会为关联表的 payment_id 添加唯一约束:

inverseJoinColumns = @JoinColumn(
    name = "payment_id",
    referencedColumnName = "id",
    unique = true
)

这意味着一个 Payment 最多只能属于一个 Subscription,符合 OneToMany 的关系语义。

如果同一个 Payment 需要关联多个 Subscription,那么实际关系是 ManyToMany,应改用 @ManyToMany,同时移除唯一约束。

什么时候需要中间实体

当关联表本身还有业务字段时,才适合将它映射为独立实体。例如,关联表除了:

subscription_id
payment_id

还需要保存:

created_at
status
operator_id
amount

这时,关联记录已经有了独立的业务含义,可以创建类似 SubscriptionPayment 的实体。

如果关联表只保存两个外键,单向 @OneToMany 配合 @JoinTable 就足够了。需要注意的是,修改的必须是实体自身持有的集合,同时还要确保 Subscription、Payment 的持久化状态和级联配置正确。

备注:内容仅供参考。