PAYATHON 2026

Freemium 应用如何安全处理按月订阅收费

支付阿杰

结论

不要在自己的数据库中存储信用卡号、有效期或 CVC。月度订阅应由 Stripe、Braintree、Adyen 等具备 PCI DSS 合规能力的支付服务商处理。

应用只需保存支付平台返回的标识和业务状态,例如:

  • customer_id
  • subscription_id
  • 订阅状态
  • 套餐标识
  • 当前计费周期结束时间

信用卡信息应由支付平台托管,或通过支付平台提供的前端组件直接提交。服务端负责创建订阅,再通过 webhook 更新用户权限。这套方式既能支持自动续费,也能缩小安全与合规范围。

推荐的订阅流程

一套可靠的 Freemium 订阅流程通常包括以下步骤:

  1. 用户注册后默认获得免费权限。
  2. 用户选择付费套餐。
  3. 前端通过支付平台托管的 Checkout 页面或安全表单收集付款方式。
  4. 支付平台创建 Customer 和 Subscription。
  5. 应用保存支付平台返回的 ID,不保存卡号。
  6. 支付平台每月自动尝试扣款。
  7. 付款成功、失败、退款或订阅取消后,支付平台发送 webhook。
  8. 应用验证 webhook 签名,再更新本地订阅状态和访问权限。

不要把用户从付款页面跳转到成功页当作付款成功的唯一依据。用户可能关闭页面,网络也可能中断,甚至有人可以直接访问成功页。订阅状态应以支付平台 API 和经过签名验证的 webhook 为准。

以 Stripe 和 Rails 为例

以下示例只展示核心结构,并非适用于某个固定版本的完整项目。Stripe API、事件名称和 Ruby SDK 接口可能随 API 版本变化。接入时应以项目锁定的 Stripe API 版本和官方文档为准。

安装 Ruby SDK:

# Gemfile
gem "stripe"
bundle install

使用 Rails Credentials 或部署平台的秘密变量配置密钥,不要将密钥提交到 Git:

# config/initializers/stripe.rb
Stripe.api_key = ENV.fetch("STRIPE_SECRET_KEY")

数据库只保存支付平台标识和业务状态,例如:

class AddBillingFieldsToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :stripe_customer_id, :string
    add_column :users, :stripe_subscription_id, :string
    add_column :users, :subscription_status, :string, default: "free"
    add_column :users, :subscription_period_end, :datetime

    add_index :users, :stripe_customer_id, unique: true
    add_index :users, :stripe_subscription_id, unique: true
  end
end

Rails migration 的版本号应与项目实际使用的 Rails 版本一致。

使用托管 Checkout 创建订阅

第一次实现支付功能时,优先考虑托管 Checkout。支付页面由支付服务商提供,信用卡数据不会经过应用服务器。

class Billing::CheckoutSessionsController < ApplicationController
  before_action :authenticate_user!

  def create
    customer_id = current_user.stripe_customer_id

    unless customer_id
      customer = Stripe::Customer.create(
        email: current_user.email,
        metadata: {
          user_id: current_user.id.to_s
        }
      )

      current_user.update!(stripe_customer_id: customer.id)
      customer_id = customer.id
    end

    session = Stripe::Checkout::Session.create(
      mode: "subscription",
      customer: customer_id,
      line_items: [
        {
          price: ENV.fetch("STRIPE_MONTHLY_PRICE_ID"),
          quantity: 1
        }
      ],
      success_url: billing_success_url + "?session_id={CHECKOUT_SESSION_ID}",
      cancel_url: billing_cancel_url,
      client_reference_id: current_user.id.to_s
    )

    redirect_to session.url, allow_other_host: true, status: :see_other
  end
end

STRIPE_MONTHLY_PRICE_ID 应指向支付平台后台预先配置的月付价格。金额和币种最好由服务端根据允许购买的套餐确定,不要接受前端直接传入的任意价格 ID 或金额。

如果允许用户选择套餐,应设置明确的白名单:

ALLOWED_PRICES = {
  "pro_monthly" => ENV.fetch("STRIPE_PRO_MONTHLY_PRICE_ID"),
  "team_monthly" => ENV.fetch("STRIPE_TEAM_MONTHLY_PRICE_ID")
}.freeze

price_id = ALLOWED_PRICES.fetch(params[:plan])

这样可以防止攻击者修改请求,并用未经授权的价格创建订阅。

使用 webhook 同步订阅状态

用户离开应用后,支付平台仍可能继续扣款、重试或取消订阅。因此,订阅系统不能缺少 webhook。

class StripeWebhooksController < ActionController::API
  def create
    payload = request.raw_post
    signature = request.headers["Stripe-Signature"]

    event = Stripe::Webhook.construct_event(
      payload,
      signature,
      ENV.fetch("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"
      handle_checkout_completed(event.data.object)
    when "customer.subscription.created",
         "customer.subscription.updated",
         "customer.subscription.deleted"
      sync_subscription(event.data.object)
    when "invoice.paid"
      handle_invoice_paid(event.data.object)
    when "invoice.payment_failed"
      handle_payment_failed(event.data.object)
    end
  end

  def handle_checkout_completed(session)
    return unless session.subscription

    user = User.find_by(stripe_customer_id: session.customer)
    return unless user

    user.update!(
      stripe_subscription_id: session.subscription
    )
  end

  def sync_subscription(subscription)
    user = User.find_by(stripe_customer_id: subscription.customer)
    return unless user

    period_end = subscription.items.data
      .map(&:current_period_end)
      .compact
      .max

    user.update!(
      stripe_subscription_id: subscription.id,
      subscription_status: subscription.status,
      subscription_period_end: period_end && Time.zone.at(period_end)
    )
  end

  def handle_invoice_paid(invoice)
    # 可记录账单号、付款结果或发送收据通知。
    # 是否立即开放权限,应结合订阅状态统一判断。
  end

  def handle_payment_failed(invoice)
    # 可通知用户更新付款方式。
    # 不建议因第一次失败就立即删除数据或永久关闭账号。
  end
end

不同 Stripe API 版本中的订阅周期字段位置可能不同。例如,周期结束时间可能与 subscription item 关联。实现时应检查实际收到的 webhook payload,不要直接假设对象结构。

签名验证必须使用原始请求体 request.raw_post。如果先解析 JSON 再重新序列化,签名验证可能失败。

如何判断用户是否拥有付费权限

不要只保存一个容易失真的 paid: true。订阅可能处于试用、正常、逾期、暂停、取消待生效或已终止等状态。

可以将支付平台的状态转换成应用自己的权限规则:

class User < ApplicationRecord
  ACCESSIBLE_SUBSCRIPTION_STATUSES = %w[trialing active].freeze

  def premium_access?
    ACCESSIBLE_SUBSCRIPTION_STATUSES.include?(subscription_status)
  end
end

是否允许 past_due 用户继续使用,应由产品策略决定。例如,可以设置几天宽限期,让支付平台重试扣款,同时给用户留出更新银行卡的时间。

取消订阅时还要区分:

  • 立即取消;
  • 在当前计费周期结束后取消;
  • 只关闭自动续费,本周期内继续提供服务。

所以,不能在用户点击”取消”后立即删除全部付费权限。

Webhook 必须支持幂等

支付平台可能重复发送同一个事件,也可能因为超时而重试。处理逻辑必须保证,同一事件执行多次时不会重复发放权益、重复记账或重复发送关键通知。

建议保存已经处理的事件 ID:

class CreateProcessedWebhookEvents < ActiveRecord::Migration[7.1]
  def change
    create_table :processed_webhook_events do |t|
      t.string :provider, null: false
      t.string :event_id, null: false
      t.timestamps
    end

    add_index :processed_webhook_events,
              [:provider, :event_id],
              unique: true
  end
end

处理事件时,可以利用数据库唯一索引防止并发重复:

def process_once(event)
  ProcessedWebhookEvent.create!(
    provider: "stripe",
    event_id: event.id
  )

  process_event(event)
rescue ActiveRecord::RecordNotUnique
  # 该事件已经处理过,安全忽略。
end

更严谨的实现还要处理业务更新失败后的重试。常见做法是在数据库事务中同时记录事件并更新业务状态,或者先持久化事件,再交给后台任务消费。

安全与合规注意事项

不要接触原始卡数据

应用服务器、日志、错误追踪系统和数据库中都不应出现:

  • 完整卡号;
  • CVC;
  • 磁条数据;
  • 未得到必要保护的支付凭据。

即使数据库已经加密,自行保存卡号仍会大幅扩大 PCI DSS 合规范围。CVC 通常也不得在授权完成后保存。

需要展示付款方式时,应使用支付平台提供的非敏感摘要,例如卡品牌、末四位和到期月份。这些信息仍属于业务数据,需要妥善保护。

前端和服务端都要使用 HTTPS

生产环境必须全程使用 HTTPS,并启用安全 Cookie 设置。支付密钥应存放在秘密管理系统或环境变量中:

  • Publishable key 可以发送给前端;
  • Secret key 只能保存在服务端;
  • Webhook signing secret 只能在服务端用于签名验证;
  • 测试环境和生产环境的密钥、商品及 webhook endpoint 应分开管理。

不信任前端传来的计费信息

不能只根据浏览器传来的参数决定用户 ID、金额、币种、折扣、套餐或权限等级。服务端必须结合当前登录用户和套餐白名单重新确认这些信息。

避免记录敏感请求

检查 Rails 日志、反向代理、监控平台和异常追踪工具的配置,并过滤可能包含个人信息或支付字段的参数:

# config/initializers/filter_parameter_logging.rb
Rails.application.config.filter_parameters += [
  :card,
  :card_number,
  :cvc,
  :security_code,
  :payment_method
]

参数过滤只是最后一道保护,不能取代”不让卡数据进入服务器”的设计。

密钥泄露后要立即轮换

不要把 API 密钥写进源代码、测试样例或前端 JavaScript。如果密钥曾被提交到 Git,即使后来已经删除,也应视为泄露并立即轮换。

不建议自己编写的功能

以下功能看起来简单,实际涉及大量边界情况,应尽量使用支付平台提供的现成功能:

  • 保存和更新付款方式;
  • 自动续费及失败重试;
  • 3D Secure 和 Strong Customer Authentication;
  • 税率和账单处理;
  • 优惠券与按比例退款;
  • 收据和发票;
  • 客户自助取消订阅或更换银行卡;
  • 拒付与争议处理。

例如,Stripe Customer Portal 可以让客户自行管理付款方式、下载账单和取消订阅。它是否适用,以及支持哪些配置,需要结合当前账户地区、业务模式和官方文档确认。

测试重点

上线前至少应覆盖以下场景:

  • 首次订阅成功;
  • 用户关闭支付页面;
  • 同一个 webhook 重复到达;
  • webhook 乱序到达;
  • 首次付款需要额外验证;
  • 月度续费成功;
  • 余额不足或卡片过期;
  • 支付失败后的自动重试;
  • 用户更新付款方式;
  • 在周期结束时取消;
  • 立即取消;
  • 套餐升级和降级;
  • 退款、拒付或订阅被平台终止;
  • webhook 处理超时或数据库暂时不可用。

测试环境应使用支付平台提供的测试卡号、测试时钟等功能,绝不能使用真实卡号进行开发测试。

Gem 的选择

直接使用官方 stripe gem,通常更容易理解实现细节,也方便对照官方文档。Rails 项目也可以考虑使用封装订阅逻辑的第三方 gem,但采用前应确认:

  • 项目是否仍在维护;
  • 是否支持当前使用的 Rails 和 Ruby 版本;
  • 是否兼容当前支付平台 API;
  • webhook 是否支持签名验证和幂等处理;
  • 是否覆盖升级、降级、试用、取消和失败重试;
  • 将来是否容易脱离该 gem。

第三方 gem 可以减少样板代码,但无法取代对订阅生命周期、webhook 和权限同步机制的理解。第一次实现时,采用”托管 Checkout + 官方 SDK + webhook + Customer Portal”通常风险较低,维护成本也更容易控制。

备注:内容仅供参考。