PAYATHON 2026

如何使用 DelayedJob 安全处理信用卡支付

支付阿杰

结论

不要把卡号、有效期或 CVV 保存为 DelayedJob 参数,也不要试图用“先加密,执行时再解密”的方式规避 PCI 合规要求。

更安全的做法是:

  1. 在浏览器或移动端使用支付网关提供的 SDK,把卡信息直接提交给网关。
  2. 由网关返回一次性 token、Payment Method ID 或 Payment Intent ID。
  3. Rails 只保存网关引用,以及订单、金额、幂等键等业务数据。
  4. DelayedJob 只接收本地支付记录的 ID,再通过网关引用完成支付。
  5. 使用幂等请求、交易状态查询和 webhook 处理超时及结果不确定的交易。

加密卡号不代表自动满足 PCI DSS。CVV 的要求更加严格,即使经过加密,通常也不能在授权完成后保存。具体适用的 PCI 范围和 SAQ 类型取决于网关集成方式,应由支付服务商或合格的 PCI 顾问确认。

为什么不能直接把卡信息交给 DelayedJob

DelayedJob 会序列化任务参数,并把结果写入 delayed_jobs 表。如果参数包含完整卡信息,这些数据还可能进入:

  • 数据库及其备份
  • 数据库副本、快照和导出文件
  • DelayedJob 的失败任务记录
  • 异常跟踪平台和应用日志
  • Rails 参数日志
  • 管理后台或数据库查询工具

这会增加泄漏风险,也会明显扩大 PCI DSS 的适用范围。

即使字段经过应用层加密,应用仍持有解密密钥,后台任务也必须具备解密能力。密钥存储、轮换、访问控制、审计、备份加密,以及密钥泄漏后的处置,都需要纳入安全设计。加密只能作为纵深防御措施,不能用来绕过 PCI 要求。

CVV 更不能这样处理。不要把 CVV 写入数据库、任务队列、缓存或日志,也不要保存其加密版本供以后使用。

推荐的支付流程

可以采用下面的流程:

浏览器
  └─ 将卡信息直接发送给支付网关
       └─ 获得 token / payment_method_id / payment_intent_id

Rails
  └─ 创建 PaymentAttempt
       └─ 只保存网关引用、金额、订单号和幂等键
            └─ 将 PaymentAttempt ID 交给 DelayedJob

DelayedJob worker
  └─ 根据 ID 加载记录
       └─ 调用支付网关
            ├─ 成功:更新为 succeeded
            ├─ 明确失败:更新为 failed
            └─ 结果未知:查询网关或等待 webhook

前端取得网关 token 后,可以立即提交给 Rails:

{
  "order_id": "order_123",
  "payment_method_token": "gateway_token_abc"
}

这里的 payment_method_token 是网关生成的引用,不能是经过 Base64 编码、哈希处理或自行加密的卡号。

需要注意,使用 token 不一定能让系统完全脱离 PCI 范围。还要根据网关文档确认 token 能否重复使用、是否可以反向关联卡号、有效期有多长,以及应用采用了哪种网关集成方式。

Rails 控制器示例

下面只是架构示例,实际使用时需要把 PaymentGateway 替换为对应支付网关的 SDK 封装。

class PaymentsController < ApplicationController
  def create
    order = current_user.orders.find(params.require(:order_id))

    payment = order.payment_attempts.create!(
      amount_cents: order.total_cents,
      currency: order.currency,
      gateway_payment_method_id: params.require(:payment_method_token),
      idempotency_key: SecureRandom.uuid,
      status: "pending"
    )

    ProcessPaymentJob.delay.perform(payment.id)

    render json: {
      payment_id: payment.id,
      status: payment.status
    }, status: :accepted
  end
end

DelayedJob 中只保存类似下面的参数:

ProcessPaymentJob.perform(12345)

其中没有卡号,也没有整个支付对象。只传数据库记录 ID 还有一个好处:worker 执行任务时会重新读取最新状态,不会依赖任务中已经过期的对象快照。

DelayedJob 示例

class ProcessPaymentJob
  def self.perform(payment_id)
    payment = PaymentAttempt.find(payment_id)

    payment.with_lock do
      return if payment.status == "succeeded"
      return if payment.status == "processing"

      payment.update!(status: "processing")
    end

    result = PaymentGateway.charge(
      payment_method_id: payment.gateway_payment_method_id,
      amount_cents: payment.amount_cents,
      currency: payment.currency,
      idempotency_key: payment.idempotency_key
    )

    payment.update!(
      status: "succeeded",
      gateway_transaction_id: result.transaction_id,
      completed_at: Time.current
    )
  rescue PaymentGateway::Declined => error
    payment&.update!(
      status: "failed",
      failure_code: error.code
    )
  rescue PaymentGateway::ResultUnknown
    payment&.update!(status: "unknown")

    # 不要在结果不明时直接再次扣款。
    ReconcilePaymentJob.delay.perform(payment_id)
  end
end

数据库锁只能避免多个本地 worker 同时处理同一条记录,无法单独防止网关重复扣款。防止重复扣款还需要使用网关支持的幂等键。

正确处理超时和重试

支付请求超时不等于支付失败。实际情况可能是:

  1. 网关没有收到请求。
  2. 网关收到了请求,但还没有处理完成。
  3. 网关已经扣款,但响应没有成功返回。
  4. 应用收到了响应,却在保存本地状态时失败。

因此,不能让 DelayedJob 遇到任何异常都直接重新发起扣款,否则一次网络超时就可能造成重复扣款。

建议为每次支付尝试生成一个稳定的幂等键:

idempotency_key = "payment-attempt-#{payment.id}"

同一次支付尝试中的所有重试都必须使用同一个键。只有用户明确发起一笔新的支付时,才能创建新的支付尝试和新的幂等键。

交易结果不确定时,应优先采取以下措施:

  • 使用幂等键重新查询或恢复请求
  • 根据网关交易引用查询状态
  • 等待并验证网关 webhook
  • 将支付标记为 unknown 或 pending_confirmation
  • 确认上一笔没有成功后,再创建第二次扣款

支付状态可以设计为:

pending
processing
requires_action
succeeded
failed
unknown
refunded

不要只用简单的 paid: true/false,因为它无法表示“网关可能已经扣款,但本地尚未得知结果”的状态。

Webhook 仍然很重要

后台任务无法消除网络中断带来的不确定性。worker 调用网关后,同样可能崩溃或超时。因此,应把网关 webhook 作为确认最终状态的渠道。

Webhook 处理器通常需要:

  • 验证签名和时间戳
  • 根据网关事件 ID 去重
  • 通过 Payment Intent ID 或交易 ID 查找本地记录
  • 以幂等方式更新支付状态
  • 忽略已经处理过的重复事件
  • 避免在日志中记录完整请求体里的敏感字段

即使同步 API 调用已经返回成功,webhook 处理也必须能够安全地重复执行。

如果网关不支持前端 tokenization

如果支付网关要求卡信息先经过 Rails 服务器,泄漏风险和 PCI 适用范围都会明显增加。可以考虑:

  • 改用网关的 hosted payment page
  • 使用网关提供的嵌入式安全字段
  • 在当前请求中立即完成 tokenization,再把 token 交给后台任务
  • 更换支持 Payment Method、Payment Intent 或 vault token 的网关

如果服务器必须暂时接触卡信息,也不应把这些信息放进 DelayedJob。可以在当前请求中直接使用卡信息向网关换取 token,确保参数不会进入日志,并尽快丢弃原始数据。即便如此,服务器仍可能处于 PCI DSS 的适用范围内。

如果网关 token 只能使用一次或很快过期,还需要确认它是否适合排队处理。任务必须在 token 有效期内执行,并且要为任务积压制定处理方案。

Rails 和 DelayedJob 的额外防护

过滤可能包含敏感信息的参数:

# config/initializers/filter_parameter_logging.rb

Rails.application.config.filter_parameters += [
  :card_number,
  :number,
  :pan,
  :cvv,
  :cvc,
  :security_code,
  :expiry,
  :expiration,
  :payment_method_token
]

token 通常不等同于卡号,但仍不适合出现在日志中。

还要检查以下位置:

  • DelayedJob 的 handler 字段
  • 失败任务保存的异常和 last_error
  • APM、错误监控和日志聚合平台
  • 数据库备份及数据库管理权限
  • 管理后台展示的支付字段
  • 网关响应对象的序列化
  • Rails 参数检查和调试日志
  • worker 的最大重试次数及退避策略

不要把网关 SDK 的完整请求或响应对象直接保存到任务、数据库或错误报告中。只保留排查问题和对账所需的非敏感字段,例如交易 ID、状态、错误代码和时间。

是否应该自行加密卡信息

从技术上说,可以先加密卡信息并写入数据库,再由 worker 解密,但不建议用这种方式解决当前问题:

  • 加密后的 PAN 仍可能属于持卡人数据
  • 应用仍需访问解密密钥
  • 加密不会让 CVV 变得适合保存
  • 密钥、备份、日志、监控和权限都必须纳入安全控制
  • 应用代码漏洞仍可能同时泄漏密文和密钥
  • 自行设计加密方案容易出现 nonce 重用、密钥轮换和完整性验证问题

如果业务确实需要保存可恢复的卡号,应使用支付网关提供的合规 vault,不要自行建立卡数据仓库。

DelayedJob 可以处理支付任务,但任务载荷应该是支付记录 ID 和网关 token,而不是卡片数据。整个安全边界应依靠网关 tokenization、幂等请求、状态对账和 webhook,而不是依靠“把卡号加密后放进队列”。

备注:内容仅供参考。