PAYATHON 2026

订阅与循环计费 Rails app 的 models 关联设计

支付小周

结论

建议把 Subscription 作为 User 与 Plan 之间的关联模型:

  • 一个 User 可以有多个 Subscription
  • 一个 Plan 可以被多个 Subscription 引用
  • 每个 Subscription 只属于一个 User 和一个 Plan
  • 一个 Subscription 可以对应一笔或多笔付款记录 Transaction

所以,Subscription 不应设置 has_many :plans,User 也不能使用 belongs_to :plan, through: :subscription。如果需要从用户直接访问其订阅过的套餐,应使用 has_many :plans, through: :subscriptions。

class User < ApplicationRecord
  has_many :subscriptions
  has_many :plans, through: :subscriptions
end

class Plan < ApplicationRecord
  has_many :subscriptions
  has_many :users, through: :subscriptions
end

class Subscription < ApplicationRecord
  belongs_to :user
  belongs_to :plan

  has_many :transactions
end

class Transaction < ApplicationRecord
  belongs_to :subscription
end

为什么这样设计

Plan 表示套餐模板,其中可以包含套餐名称、价格、服务额度和计费周期。用户购买套餐时,通常不需要为该用户新建一条 Plan 记录。

Subscription 表示某个用户在某段时间内订阅了某个套餐。它保存一次具体订阅关系的信息,例如:

  • user_id
  • plan_id
  • starts_at
  • ends_at
  • status
  • 当时实际采用的价格

因此,一个 Subscription 应当指向一个 Plan:

class Subscription < ApplicationRecord
  belongs_to :plan
end

不应写成:

has_many :plans

只有一次订阅可以同时包含多个套餐时,Subscription 才需要通过额外的明细表关联多个 Plan。普通的套餐订阅系统通常不需要这样设计。

用户与套餐的访问方式

用户和套餐之间是多对多关系,这层关系由 Subscription 承载:

class User < ApplicationRecord
  has_many :subscriptions
  has_many :plans, through: :subscriptions
end

定义后可以执行:

user.subscriptions
user.plans
plan.subscriptions
plan.users

下面这种写法不正确:

belongs_to :plan, through: :subscription

belongs_to 表示当前表保存了目标记录的外键,例如 subscriptions.plan_id。它不能用来表达这种通过中间集合访问目标记录的关系。

如果业务需要取得用户当前使用的套餐,可以先定义“当前订阅”,再通过它取得套餐。例如:

class User < ApplicationRecord
  has_many :subscriptions

  has_one :current_subscription,
          -> { where(status: "active").order(starts_at: :desc) },
          class_name: "Subscription"

  has_one :current_plan,
          through: :current_subscription,
          source: :plan
end

不过,业务必须明确保证“当前订阅”的判定规则。只依赖排序,无法阻止同一个用户同时存在多条 active 记录。

Transaction 应该如何关联

一次订阅续费可能涉及多次支付尝试、退款或补扣,这种情况下适合使用一对多关系:

class Subscription < ApplicationRecord
  belongs_to :user
  belongs_to :plan

  has_many :transactions
end

class Transaction < ApplicationRecord
  belongs_to :subscription
end

这样,一条订阅记录可以关联:

  • 支付成功记录
  • 支付失败记录
  • 重试记录
  • 退款记录

查询方式如下:

subscription.transactions
transaction.subscription
transaction.subscription.user
transaction.subscription.plan

如果业务能严格保证每个订阅周期只有一笔付款,也可以使用一对一关系:

class Subscription < ApplicationRecord
  has_one :transaction
end

class Transaction < ApplicationRecord
  belongs_to :subscription
end

实际支付流程通常会出现失败重试或退款,所以 has_many :transactions 一般更合适。

推荐的表结构

如果业务采用“每个月生成一条订阅或续费记录”的方式,可以使用类似的结构:

create_table :subscriptions do |t|
  t.references :user, null: false, foreign_key: true
  t.references :plan, null: false, foreign_key: true

  t.string :status, null: false
  t.datetime :starts_at, null: false
  t.datetime :ends_at
  t.integer :price_cents, null: false
  t.string :currency, null: false

  t.timestamps
end

create_table :transactions do |t|
  t.references :subscription, null: false, foreign_key: true

  t.string :status, null: false
  t.integer :amount_cents, null: false
  t.string :currency, null: false
  t.string :provider_reference

  t.timestamps
end

即使 Plan 已经保存了价格,也建议在 Subscription 或 Transaction 中记录成交时的金额。套餐价格以后可能调整,历史付款记录不应随之变化。

更常见的另一种建模方式

如果用户的订阅关系长期有效,只是每个月扣一次款,更常见的设计是:

  • Subscription 表示持续的订阅合同
  • 每次月度扣费生成一条 Transaction
  • 不要每个月新建一条 Subscription

关联仍然是:

class User < ApplicationRecord
  has_many :subscriptions
end

class Plan < ApplicationRecord
  has_many :subscriptions
end

class Subscription < ApplicationRecord
  belongs_to :user
  belongs_to :plan
  has_many :transactions
end

class Transaction < ApplicationRecord
  belongs_to :subscription
end

例如,用户订阅一年时只保留一条 Subscription,每个月新增一条 Transaction。取消、暂停、恢复和更换套餐都围绕这条订阅记录处理。

如果业务确实将每个月视为一次独立续订,也可以每月创建一条 Subscription。不过,最好将它命名为 SubscriptionPeriod、Renewal 或 BillingCycle,避免同一个模型同时表示“长期订阅”和“单月账期”。

数据完整性注意事项

Rails 关联不能代替数据库约束。至少应为外键添加索引和外键约束:

add_index :subscriptions, :user_id
add_index :subscriptions, :plan_id
add_index :transactions, :subscription_id

如果每个用户在任意时刻只能有一个有效订阅,还需要同时在业务逻辑和数据库层进行约束。直接添加:

validates :user_id, uniqueness: true

通常过于严格,因为这也会禁止用户保留历史订阅记录。具体方案取决于数据库类型,以及 status 和起止时间的定义。

此外,Transaction 虽然可以作为 Rails model 名称,但容易与 Active Record 中的数据库事务概念混淆。如果它表示支付记录,使用 Payment、Charge 或 PaymentTransaction 通常更容易理解。

整体关系可以概括为:

User 1 ─── * Subscription * ─── 1 Plan
                    |
                    1
                    |
                    *
               Transaction

Plan 定义卖什么,Subscription 记录谁订了什么,Transaction 记录实际发生的付款。

备注:内容仅供参考。