如何在 Rails 中实现付费查看用户帖子?
明确结论
付费帖子不必按”实体商品”来设计。对支付平台而言,用户购买的是数字内容的访问权。基本流程如下:
- 帖子保存价格、预览和完整正文。
- 用户点击购买后,服务器创建支付订单。
- 支付平台完成信用卡收款。
- 服务器通过 webhook 确认支付成功。
- 为购买者创建该帖子的访问权限。
- 每次展示完整正文时,由服务端检查访问权限。
不要只在前端隐藏 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 和帖子的权限逻辑。
备注:内容仅供参考。
