PAYATHON 2026

在线支付回调中如何安全存储 session state

支付阿杰

结论

优先选择方案 1:把支付流程所需的数据持久化到数据库,并创建一条状态为 Pending 的支付记录。收到支付平台回调后,先验证回调的真实性,再以幂等方式更新支付状态。

InProc、StateServer 和 SQLServer Session State 都不适合充当支付状态的唯一存储。它们可以保存页面流程中的临时数据,但不能替代支付订单表。也不要让支付平台原样传回用户的详细信息。如需关联支付请求与回调,只传递随机且不可猜测的支付引用标识。

为什么数据库更合适

支付回调与用户 Session 不属于同一条可靠的请求链:

  • 回调通常由支付平台的服务器发起,不一定携带用户浏览器的 Session Cookie。
  • 应用重启、发布或进程回收会清空 InProc Session。
  • 即使用户关闭页面,支付平台仍可能稍后发送回调。
  • 回调可能延迟、重复、乱序,甚至在超时后重新投递。
  • 支付成功页面可能比服务端回调更早到达,因此不能只凭页面跳转确认付款成功。

StateServer 和 SQLServer Session State 可以让 Session 在进程重启后继续存活,但无法解决回调关联、状态审计、幂等更新和业务数据持久化等问题。即使应用只部署在一台服务器上,也不能把支付状态仅保存在内存中。

数据库记录还可以保存支付状态变化、平台交易号和回调时间等信息,方便对账和故障恢复。

推荐流程

1. 发起支付前创建支付记录

在数据库中生成内部支付标识,例如随机的 PaymentReference,并保存已经由服务端确认的业务数据:

PaymentReference
UserId
OrderId
Amount
Currency
Status
ProviderTransactionId
CreatedAt
UpdatedAt

初始状态设为 Pending。金额、币种、商品和订单归属都应以数据库中的数据为准。处理回调时,不要直接信任浏览器或支付平台返回的同名字段。

2. 只向支付平台传递关联标识

如果支付平台支持 merchantReference、metadata 或类似字段,可以用它传递内部引用标识:

PaymentReference = "0ad1f5d7d2624d43bd46de3bc9f77d82"

这个标识应满足以下要求:

  • 使用密码学安全的随机值;
  • 不包含姓名、邮箱、地址、卡号等敏感信息;
  • 不使用连续递增的数据库主键;
  • 在数据库中具有唯一约束;
  • 只用于定位支付记录,不能单独证明支付成功。

如果平台可能截断或修改该字段,或者不保证原样返回,应以平台实际的 API 契约为准。

3. 验证回调真实性

处理回调前,应按照支付平台文档验证签名、时间戳或消息认证码。验签必须使用平台规定的原始请求体,不能先自行重新序列化 JSON,再用序列化后的内容验签。

部分平台还要求通过服务端 API 查询交易结果。这种情况下,应先完成验签,再使用平台交易号查询,并核对以下信息:

  • 商户账号;
  • 内部支付引用;
  • 订单号;
  • 金额和币种;
  • 交易状态;
  • 平台交易号。

不能因为回调参数中出现 status=success,就直接把订单标记为已支付。

4. 幂等更新支付状态

支付平台可能多次发送同一个回调,因此更新操作必须能够安全地重复执行。可以使用唯一索引、带状态条件的更新或数据库事务来保证幂等性。

下面是一个简化的 ASP.NET Core 示例。签名算法只是接口占位,实际实现必须遵循所用支付平台的规范:

[ApiController]
[Route("payments")]
public sealed class PaymentCallbackController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly IPaymentSignatureVerifier _signatureVerifier;

    public PaymentCallbackController(
        AppDbContext db,
        IPaymentSignatureVerifier signatureVerifier)
    {
        _db = db;
        _signatureVerifier = signatureVerifier;
    }

    [HttpPost("callback")]
    public async Task<IActionResult> Callback(CancellationToken cancellationToken)
    {
        using var reader = new StreamReader(Request.Body);
        string rawBody = await reader.ReadToEndAsync(cancellationToken);

        string signature = Request.Headers["X-Payment-Signature"].ToString();

        if (!_signatureVerifier.Verify(rawBody, signature))
        {
            return Unauthorized();
        }

        PaymentCallback? callback =
            JsonSerializer.Deserialize<PaymentCallback>(rawBody);

        if (callback is null)
        {
            return BadRequest();
        }

        Payment? payment = await _db.Payments
            .SingleOrDefaultAsync(
                x => x.PaymentReference == callback.PaymentReference,
                cancellationToken);

        if (payment is null)
        {
            return NotFound();
        }

        // 金额和币种必须与发起支付时保存的数据一致。
        if (payment.Amount != callback.Amount ||
            payment.Currency != callback.Currency)
        {
            return BadRequest();
        }

        // 重复回调直接确认,避免重复发货或重复记账。
        if (payment.Status == PaymentStatus.Paid)
        {
            return Ok();
        }

        if (callback.Status != "succeeded")
        {
            return Ok();
        }

        payment.Status = PaymentStatus.Paid;
        payment.ProviderTransactionId = callback.TransactionId;
        payment.UpdatedAt = DateTimeOffset.UtcNow;

        await _db.SaveChangesAsync(cancellationToken);

        return Ok();
    }
}

public sealed record PaymentCallback(
    string PaymentReference,
    string TransactionId,
    decimal Amount,
    string Currency,
    string Status);

在生产环境中,最好为平台交易号设置数据库唯一约束:

CREATE UNIQUE INDEX UX_Payments_ProviderTransactionId
ON Payments (ProviderTransactionId)
WHERE ProviderTransactionId IS NOT NULL;

如果确认付款后还要执行发货、增加积分或发送消息等操作,应在同一事务中更新支付状态并写入待处理事件,再通过 Outbox 或后台任务执行后续操作。这样可以避免数据库已经标记支付成功,但发货任务丢失。

Session 应该保存什么

Session 适合保存非关键的页面体验数据,例如:

  • 当前支付步骤;
  • 页面返回地址;
  • 未提交的表单草稿;
  • 展示偏好。

支付订单、应付金额、最终支付状态和已经完成的权益不能只保存在 Session 中。即使使用 SQLServer Session State,仍然会遇到 Session 过期、Cookie 丢失以及回调请求无法关联浏览器会话等问题。

如果现有页面必须依赖 Session,可以只在 Session 中保存 PaymentReference,同时确保系统始终能根据登录用户和数据库记录恢复支付流程。

敏感信息处理

不要把支付卡完整卡号、CVV、安全码或支付凭据存入普通业务数据库、Session、URL、日志或回调自定义字段。尽量使用支付平台托管的 Iframe、Hosted Payment Page 或 Tokenization,让敏感卡数据直接提交给支付平台。

如果确实需要保存用户姓名、地址等个人数据,应做到:

  • 只保存完成业务所必需的字段;
  • 使用 TLS 保护传输;
  • 对高敏感字段实施适当的静态加密;
  • 将加密密钥与数据库分开管理;
  • 限制数据库账号和后台人员的权限;
  • 避免在日志、异常信息和监控标签中输出敏感值;
  • 设置明确的数据保留期限和删除机制。

是否属于 PCI DSS 或当地隐私法规的适用范围,取决于实际处理的数据、支付集成方式和所在司法辖区,需要结合支付平台的要求和合规意见确认。

各方案的适用性

方案评价
数据库存储并在确认后更新推荐,适合作为支付状态的权威数据源
InProc Session不可靠,应用重启、发布或进程回收时会丢失
StateServer Session比 InProc 更能承受进程重启,但仍不适合作为支付记录
SQLServer Session State可以持久化 Session,但缺少支付业务所需的状态模型和审计能力
由支付平台传回详细信息不推荐,只应传递不含敏感信息的引用标识,并验证回调

数据库负责保存真实的业务状态,支付平台返回关联标识和经过验证的交易结果,Session 则只保存可丢失、可恢复的页面上下文。

备注:内容仅供参考。