PAYATHON 2026

如何在 Rails 中实现付费查看用户帖子?

支付小周

明确结论

付费帖子不必按”实体商品”来设计。对支付平台而言,用户购买的是数字内容的访问权。基本流程如下:

  1. 帖子保存价格、预览和完整正文。
  2. 用户点击购买后,服务器创建支付订单。
  3. 支付平台完成信用卡收款。
  4. 服务器通过 webhook 确认支付成功。
  5. 为购买者创建该帖子的访问权限。
  6. 每次展示完整正文时,由服务端检查访问权限。

不要只在前端隐藏 HTML,也不要依赖 JavaScript 判断或支付后的跳转参数来保护正文。用户能否看到完整内容,必须由 Rails 服务端决定。

如果发帖者可以获得销售收入,业务就属于 marketplace 场景。此时应使用支付平台的 marketplace/connected accounts 产品,例如 Stripe Connect,同时处理退款、争议、平台手续费、身份验证和税务责任,不能只实现普通的一次性收款。

推荐的数据模型

至少需要以下几类数据:

  • Post:保存帖子、预览、完整内容和价格。
  • Purchase:记录一次购买订单及其支付状态。
  • PostAccess:记录用户已经取得某篇帖子的访问权。
  • 支付平台账户信息:需要向作者分账时使用。

金额应以最小货币单位的整数保存。例如,人民币 9.90 元保存为 990 分,以免出现浮点数精度问题。

# db/migrate/..._add_paywall_fields_to_posts.rb
class AddPaywallFieldsToPosts < ActiveRecord::Migration[7.1]
  def change
    add_column :posts, :preview, :text
    add_column :posts, :price_cents, :integer, null: false, default: 0
    add_column :posts, :currency, :string, null: false, default: "usd"

    add_check_constraint :posts,
                         "price_cents >= 0",
                         name: "posts_price_cents_non_negative"
  end
end
# db/migrate/..._create_purchases.rb
class CreatePurchases < ActiveRecord::Migration[7.1]
  def change
    create_table :purchases do |t|
      t.references :buyer, null: false, foreign_key: { to_table: :users }
      t.references :post, null: false, foreign_key: true
      t.integer :amount_cents, null: false
      t.string :currency, null: false
      t.string :status, null: false, default: "pending"
      t.string :provider, null: false
      t.string :provider_session_id
      t.string :provider_payment_id

      t.timestamps
    end

    add_index :purchases, :provider_session_id, unique: true
    add_index :purchases, :provider_payment_id, unique: true
  end
end
# db/migrate/..._create_post_accesses.rb
class CreatePostAccesses < ActiveRecord::Migration[7.1]
  def change
    create_table :post_accesses do |t|
      t.references :user, null: false, foreign_key: true
      t.references :post, null: false, foreign_key: true
      t.references :purchase, null: false, foreign_key: true

      t.timestamps
    end

    add_index :post_accesses, %i[user_id post_id], unique: true
  end
end

对应的模型可以这样定义:

class Post < ApplicationRecord
  belongs_to :user

  has_many :purchases, dependent: :restrict_with_exception
  has_many :post_accesses, dependent: :restrict_with_exception

  validates :price_cents,
            numericality: { only_integer: true, greater_than_or_equal_to: 0 }

  def paid?
    price_cents.positive?
  end

  def accessible_by?(viewer)
    return true unless paid?
    return true if viewer == user
    return false unless viewer

    post_accesses.exists?(user: viewer)
  end
end
class Purchase < ApplicationRecord
  belongs_to :buyer, class_name: "User"
  belongs_to :post
  has_one :post_access, dependent: :restrict_with_exception

  enum :status, {
    pending: "pending",
    paid: "paid",
    refunded: "refunded",
    failed: "failed"
  }, prefix: true
end
class PostAccess < ApplicationRecord
  belongs_to :user
  belongs_to :post
  belongs_to :purchase

  validates :user_id, uniqueness: { scope: :post_id }
end

在页面中控制内容访问

索引页只输出预览。不要先把付费正文发送到浏览器,再用 CSS 将其隐藏。

<% @posts.each do |post| %>
  <article>
    <h2><%= link_to post.title, post_path(post) %></h2>
    <p><%= post.preview %></p>

    <% if post.paid? %>
      <p><%= number_to_currency(post.price_cents / 100.0) %></p>
    <% end %>
  </article>
<% end %>

详情页由服务端决定返回完整正文还是购买入口:

class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    @can_read = @post.accessible_by?(current_user)
  end
end
<h1><%= @post.title %></h1>

<% if @can_read %>
  <%= simple_format(@post.body) %>
<% else %>
  <%= simple_format(@post.preview) %>

  <% if user_signed_in? %>
    <%= button_to "购买完整内容",
                  post_checkout_path(@post),
                  method: :post %>
  <% else %>
    <%= link_to "登录后购买", new_user_session_path %>
  <% end %>
<% end %>

如果正文允许用户输入 HTML,需要用严格的白名单进行清理,防止 XSS。不要直接使用 raw @post.body。

创建支付订单

下面以 Stripe Checkout 的典型流程为例。具体字段可能随着 Stripe API 版本和账户配置而变化,接入时应查阅对应版本的官方文档。

路由:

Rails.application.routes.draw do
  resources :posts, only: %i[index show] do
    post :checkout, to: "checkouts#create"
  end

  post "/webhooks/stripe", to: "stripe_webhooks#create"
end

控制器:

class CheckoutsController < ApplicationController
  before_action :authenticate_user!

  def create
    post = Post.find(params[:post_id])

    if post.user == current_user || post.accessible_by?(current_user)
      redirect_to post_path(post), notice: "你已经可以查看这篇帖子。"
      return
    end

    unless post.paid?
      redirect_to post_path(post)
      return
    end

    purchase = Purchase.create!(
      buyer: current_user,
      post: post,
      amount_cents: post.price_cents,
      currency: post.currency,
      provider: "stripe",
      status: "pending"
    )

    session = Stripe::Checkout::Session.create(
      mode: "payment",
      line_items: [
        {
          price_data: {
            currency: purchase.currency,
            unit_amount: purchase.amount_cents,
            product_data: {
              name: post.title
            }
          },
          quantity: 1
        }
      ],
      customer_email: current_user.email,
      client_reference_id: purchase.id.to_s,
      metadata: {
        purchase_id: purchase.id.to_s,
        post_id: post.id.to_s,
        buyer_id: current_user.id.to_s
      },
      success_url: post_url(post, payment: "processing"),
      cancel_url: post_url(post)
    )

    purchase.update!(provider_session_id: session.id)

    redirect_to session.url, allow_other_host: true
  end
end

这里需要遵守两个原则:

  • 价格必须从数据库中的 Post 读取,不能使用浏览器提交的金额。
  • Purchase 必须保存购买时的金额和币种,不能只读取帖子的当前价格。否则作者修改价格后,历史订单会与支付记录不一致。

实际项目还要防止用户连续点击并生成大量订单。可以复用尚未过期的 pending 订单,也可以为创建结账会话设置合适的幂等键。

使用 webhook 开通访问权限

用户返回 success_url 不代表支付已经成功。跳转参数可以伪造,用户也可能已经付款,却没有返回网站。支付结果应以通过签名验证的 webhook 为准。

class StripeWebhooksController < ApplicationController
  skip_forgery_protection

  def create
    payload = request.raw_post
    signature = request.headers["Stripe-Signature"]

    event = Stripe::Webhook.construct_event(
      payload,
      signature,
      Rails.application.credentials.dig(:stripe, :webhook_secret)
    )

    process_event(event)

    head :ok
  rescue JSON::ParserError, Stripe::SignatureVerificationError
    head :bad_request
  end

  private

  def process_event(event)
    case event.type
    when "checkout.session.completed"
      complete_purchase(event.data.object)
    end
  end

  def complete_purchase(session)
    purchase = Purchase.find_by!(
      id: session.metadata.purchase_id,
      provider_session_id: session.id
    )

    return unless session.payment_status == "paid"

    Purchase.transaction do
      purchase.lock!

      purchase.update!(
        status: "paid",
        provider_payment_id: session.payment_intent
      )

      PostAccess.find_or_create_by!(
        user: purchase.buyer,
        post: purchase.post
      ) do |access|
        access.purchase = purchase
      end
    end
  end
end

Webhook 可能重复发送,也可能不按顺序到达,所以处理逻辑必须具备幂等性。数据库唯一索引和 find_or_create_by! 能防止重复创建访问权。生产环境通常还会单独保存已经处理过的 webhook event ID。

如果启用了延迟付款方式,checkout.session.completed 不一定意味着资金已经到账。此时需要根据所启用的付款方式继续处理后续的成功或失败事件。即使只接受即时信用卡付款,也应查阅支付平台的当前文档,确认各事件的准确含义。

作者分账不能只靠普通收款

如果全部款项都归平台所有,实现一次性 Checkout 通常就够了。如果购买款需要支付给帖子作者,则应使用支付平台专门提供的 marketplace 能力。

以 Stripe 为例,通常需要接入 Stripe Connect,并为作者建立 connected account。常见的资金路径有:

  • 交易发生时直接为作者收款,同时收取平台手续费。
  • 平台先收款,之后再向作者转账。
  • 延迟结算,在退款或争议窗口结束前暂时保留资金。

具体选择取决于业务所在的国家或地区、平台承担的责任、退款机制和支付账户类型。不要只在数据库里记录作者余额,然后私下定期转账。这种做法可能涉及身份验证、资金托管、税务申报和支付合规问题。

如果现阶段只是验证产品需求,可以先让所有款项进入平台账户,并明确暂时不向作者分账。商业和合规方案确定后,再接入 connected accounts。

退款和撤销权限

需要事先确定退款后是否撤销阅读权限。如果决定撤销,可以在收到退款 webhook 后更新订单并删除访问记录:

Purchase.transaction do
  purchase.lock!
  purchase.update!(status: "refunded")
  purchase.post_access&.destroy!
end

以下情况也需要分别处理:

  • 全额退款与部分退款;
  • 平台主动退款与信用卡争议;
  • 支付失败、撤销和退款;
  • 作者删除帖子后的购买者权益;
  • 作者大幅修改帖子后的处理方式。

如果提供永久购买,较稳妥的做法是保留购买快照或版本记录。否则用户购买后,作者仍可能替换正文,甚至删除内容。

安全与业务注意事项

不要自己处理信用卡号

信用卡信息应直接提交给支付平台的托管页面或官方客户端组件。Rails 服务器只保存支付平台返回的 customer、session、payment 等标识,不保存卡号、CVC 或完整有效期。这样可以明显缩小 PCI DSS 的合规范围,但不代表平台完全没有合规义务。

权限检查应覆盖所有出口

除了网页详情页,以下位置也不能泄露完整正文:

  • JSON API;
  • RSS 和站点地图;
  • 搜索结果及搜索索引;
  • Open Graph 和页面描述;
  • 邮件通知;
  • 后台导出;
  • 页面缓存;
  • 日志和错误追踪数据。

使用 fragment caching 时,缓存键必须区分”可阅读”和”不可阅读”两种状态。否则完整正文可能会被缓存并返回给尚未购买的用户。

作者不能购买自己的帖子

作者应自动拥有访问权,无需创建虚假的购买记录。免费帖子也不需要进入支付流程。

防止改价和越权

以下信息必须由服务端确定:

  • 当前购买者;
  • 帖子 ID;
  • 金额和币种;
  • 收款作者;
  • 支付成功后授予的帖子权限。

客户端提交的 user_id、amount、paid 或作者账户 ID 都不可信。

数字内容仍需处理税务和消费者权益

数字内容虽然不是实体商品,但仍可能涉及税务、退款和消费者保护要求。数字服务税、销售税、VAT、发票和退款规则取决于平台及买卖双方所在地区。正式上线前,应请支付服务商或专业顾问确认适用规则。

一个更清晰的完整流程

完整流程可以归纳为:

用户打开帖子
    ↓
Rails 检查作者身份、免费状态或 PostAccess
    ↓
无权限时仅返回预览和购买按钮
    ↓
服务器按数据库价格创建 Purchase
    ↓
服务器创建支付平台 Checkout Session
    ↓
用户在支付平台页面付款
    ↓
支付平台向 Rails 发送已签名 webhook
    ↓
Rails 验证签名并将 Purchase 标记为 paid
    ↓
Rails 创建 PostAccess
    ↓
用户再次请求帖子,服务器返回完整正文

这套设计将”收款”与”内容授权”分开。支付平台负责安全处理资金,Rails 负责维护订单状态和阅读权限。以后即使更换支付提供商,也不必重写 PostAccess 和帖子的权限逻辑。

备注:内容仅供参考。