如何使用 DelayedJob 安全处理信用卡支付
结论
不要把卡号、有效期或 CVV 保存为 DelayedJob 参数,也不要试图用“先加密,执行时再解密”的方式规避 PCI 合规要求。
更安全的做法是:
- 在浏览器或移动端使用支付网关提供的 SDK,把卡信息直接提交给网关。
- 由网关返回一次性 token、Payment Method ID 或 Payment Intent ID。
- Rails 只保存网关引用,以及订单、金额、幂等键等业务数据。
- DelayedJob 只接收本地支付记录的 ID,再通过网关引用完成支付。
- 使用幂等请求、交易状态查询和 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 同时处理同一条记录,无法单独防止网关重复扣款。防止重复扣款还需要使用网关支持的幂等键。
正确处理超时和重试
支付请求超时不等于支付失败。实际情况可能是:
- 网关没有收到请求。
- 网关收到了请求,但还没有处理完成。
- 网关已经扣款,但响应没有成功返回。
- 应用收到了响应,却在保存本地状态时失败。
因此,不能让 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,而不是依靠“把卡号加密后放进队列”。
备注:内容仅供参考。
