PAYATHON 2026

网站如何安全处理信用卡订阅付款?

支付小周

明确结论

不要把信用卡号、有效期或 CVV 存入自己的数据库,也不要让这些数据经过自己的服务器。

对于个人开发者和小型订阅网站,更合适的做法是使用成熟支付服务商提供的托管结账页或嵌入式安全支付组件。按月扣款则通过支付服务商生成的客户 ID、订阅 ID 和支付方式令牌来管理。

整个流程可以这样设计:

  1. 用户在支付服务商托管的页面填写信用卡信息。
  2. 支付服务商保存卡片,完成首次支付和必要的身份验证。
  3. 网站只保存 CustomerId、SubscriptionId 等非敏感标识。
  4. 后续月费由支付服务商自动扣款。
  5. 网站通过 Webhook 接收支付成功、失败、续订和取消事件。
  6. 网站根据 Webhook 更新用户的订阅权限。

这样可以降低信用卡信息泄露的风险,也能减少 PCI DSS 合规工作的复杂度。

为什么不应自行保存信用卡信息

自行保存信用卡数据,并不是简单地把几个字段加密后写入数据库。系统还要处理:

  • PCI DSS 合规要求
  • 密钥的生成、轮换和访问控制
  • 数据库备份中的敏感数据
  • 日志、监控和异常报告中的信息泄露
  • 服务器遭到入侵后的数据保护
  • 员工或运维账号的越权访问
  • 支付失败后的重试策略
  • 卡片过期和自动更新
  • 退款、拒付和欺诈处理
  • 3-D Secure 等额外验证流程

尤其不能保存 CVV。支付服务商和 PCI DSS 对 CVV 存储有严格限制,通常不允许在授权完成后继续保留。

即使信用卡号已经加密,只要系统能够解密并用它扣款,密钥和解密流程就仍然是攻击目标。对小型项目来说,这种做法的成本和风险通常都不值得承担。

推荐的系统结构

支付流程可以设计为:

浏览器
  │
  ├── 请求创建结账会话 ──> ASP.NET 应用
  │                            │
  │                            └── 调用支付服务商 API
  │
  ├── 跳转到托管结账页 ──> 支付服务商
  │
  └── 返回网站

支付服务商
  │
  └── Webhook ──> ASP.NET 应用 ──> 更新订阅状态

网站数据库只需保存以下信息:

UserId
PaymentProvider
ProviderCustomerId
ProviderSubscriptionId
SubscriptionStatus
CurrentPeriodEnd
LastPaymentStatus

以下信息不要保存:

CardNumber
CVV
完整磁条数据
银行卡密码
未经支付服务商令牌化的卡片资料

如果需要向用户显示卡片信息,可以保存支付服务商返回的非敏感摘要,比如卡片品牌、末四位和到期月份。这些字段应以支付服务商返回的数据为准,不要根据前端提交的数据自行生成。

实施步骤

1. 选择支持周期订阅的支付服务商

至少要确认服务商支持:

  • Recurring billing 或 Subscription
  • 托管结账页或官方安全支付组件
  • 支付方式令牌化
  • Webhook
  • 支付失败自动重试
  • 退款和取消订阅
  • 3-D Secure 或当地要求的身份验证
  • 面向目标国家、币种和企业主体的收款能力

Stripe、Braintree、Adyen 等服务都提供类似功能,但它们支持的地区、费率、结算条件和接口会发生变化。具体实施时,应查看所选服务商当前的官方文档。

2. 在支付平台创建订阅价格

不要让浏览器直接决定实际收费金额。通常应先在支付平台创建商品和周期价格,例如:

产品:高级内容订阅
价格:每月 49 元
周期:monthly

网站后端使用固定的价格 ID 创建订阅,避免攻击者篡改前端请求,把 49 元改成 0.49 元。

3. 由后端创建结账会话

下面以 ASP.NET Core 和常见的 Stripe Checkout 模式为例。不同版本的 .NET SDK 可能会调整类型或属性名称,实施时应以当前安装版本的官方文档为准。

using Microsoft.AspNetCore.Mvc;
using Stripe.Checkout;

[ApiController]
[Route("api/billing")]
public sealed class BillingController : ControllerBase
{
    [HttpPost("checkout")]
    public async Task<IActionResult> CreateCheckoutSession()
    {
        // 应从经过身份验证的用户上下文中取得,
        // 不要直接信任浏览器提交的 UserId。
        string userId = User.FindFirst("sub")?.Value
            ?? throw new UnauthorizedAccessException();

        var options = new SessionCreateOptions
        {
            Mode = "subscription",
            SuccessUrl = "https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
            CancelUrl = "https://example.com/billing/cancelled",
            ClientReferenceId = userId,
            LineItems = new List<SessionLineItemOptions>
            {
                new()
                {
                    Price = "price_xxxxxxxxx",
                    Quantity = 1
                }
            }
        };

        var service = new SessionService();
        Session session = await service.CreateAsync(options);

        return Ok(new { checkoutUrl = session.Url });
    }
}

API 密钥应放在环境变量、密钥管理服务等安全配置中,不能写入源代码,也不要提交到 Git 仓库。

Stripe.StripeConfiguration.ApiKey =
    builder.Configuration["Payments:SecretKey"];

生产环境还要检查配置是否为空。如果缺少必要配置,应用应在启动时立即失败,不能带着错误配置继续运行。

4. 浏览器跳转到托管结账页

前端只负责请求结账地址并完成跳转:

async function subscribe() {
  const response = await fetch("/api/billing/checkout", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    }
  });

  if (!response.ok) {
    throw new Error("无法创建结账会话");
  }

  const result = await response.json();
  window.location.assign(result.checkoutUrl);
}

用户会在支付服务商的页面填写信用卡信息,这些数据不会进入网站自己的 ASP.NET 请求、应用日志或数据库。

5. 使用 Webhook 确认支付结果

用户被重定向到 success 页面,并不表示付款一定成功。用户可以伪造 URL,也可能在支付仍处于异步处理状态时关闭页面。

订阅状态必须以通过签名验证的 Webhook 为准。

using Microsoft.AspNetCore.Mvc;
using Stripe;

[ApiController]
[Route("api/webhooks")]
public sealed class PaymentWebhookController : ControllerBase
{
    private readonly IConfiguration _configuration;
    private readonly ISubscriptionService _subscriptions;

    public PaymentWebhookController(
        IConfiguration configuration,
        ISubscriptionService subscriptions)
    {
        _configuration = configuration;
        _subscriptions = subscriptions;
    }

    [HttpPost("stripe")]
    public async Task<IActionResult> Handle()
    {
        string json;

        using (var reader = new StreamReader(Request.Body))
        {
            json = await reader.ReadToEndAsync();
        }

        string signature = Request.Headers["Stripe-Signature"].ToString();
        string secret = _configuration["Payments:WebhookSecret"]
            ?? throw new InvalidOperationException(
                "Webhook secret is not configured.");

        Event stripeEvent;

        try
        {
            stripeEvent = EventUtility.ConstructEvent(
                json,
                signature,
                secret);
        }
        catch (StripeException)
        {
            return BadRequest();
        }

        switch (stripeEvent.Type)
        {
            case "invoice.paid":
                await _subscriptions.MarkInvoicePaidAsync(stripeEvent);
                break;

            case "invoice.payment_failed":
                await _subscriptions.MarkPaymentFailedAsync(stripeEvent);
                break;

            case "customer.subscription.updated":
                await _subscriptions.SynchronizeAsync(stripeEvent);
                break;

            case "customer.subscription.deleted":
                await _subscriptions.MarkCancelledAsync(stripeEvent);
                break;
        }

        return Ok();
    }
}

事件名称和事件对象结构会因支付平台及 API 配置而异,不能直接照搬示例上线。应根据账户实际启用的事件类型编写处理逻辑并完成测试。

Webhook 必须解决的问题

验证签名

不能在反序列化请求体后直接更新订阅。必须使用支付服务商提供的 Webhook Secret 验证签名,而且验证过程通常需要使用原始请求体。

保证幂等性

支付服务商可能重复发送同一个事件。数据库应记录事件 ID,并为它建立唯一约束:

CREATE UNIQUE INDEX UX_PaymentWebhookEvent_EventId
ON PaymentWebhookEvent(EventId);

处理事件前,应先检查该事件是否已经成功处理。否则,同一条通知可能导致订阅期被重复延长、邮件被重复发送,或者账目被重复记录。

不依赖事件顺序

Webhook 不一定按照业务实际发生的顺序到达。收到订阅更新事件后,最好根据事件中的订阅 ID 重新查询支付平台的当前状态,或者比较事件创建时间和本地版本,防止旧事件覆盖新状态。

快速响应

Webhook 接口应尽快返回成功状态。耗时操作可以先写入可靠队列,再异步执行,但必须确认事件已经持久化后才能返回成功。

如何判断用户是否可以访问内容

不要只保存一个永久有效的 IsPremium = true。订阅至少要有明确的状态和有效期,例如:

public enum SubscriptionState
{
    Pending,
    Active,
    PastDue,
    Cancelled,
    Expired
}

public sealed class UserSubscription
{
    public string ProviderCustomerId { get; set; } = "";
    public string ProviderSubscriptionId { get; set; } = "";
    public SubscriptionState State { get; set; }
    public DateTimeOffset? CurrentPeriodEnd { get; set; }
}

用户访问受限内容时,由服务器检查订阅状态:

bool canAccess =
    subscription.State == SubscriptionState.Active &&
    subscription.CurrentPeriodEnd > DateTimeOffset.UtcNow;

实际业务还要明确以下规则:

  • 扣款失败后是否提供宽限期
  • 用户取消后能否继续使用到当前周期结束
  • 退款后是否立即撤销权限
  • 订阅暂停时如何处理
  • Webhook 暂时延迟时是否允许用户短期访问

这些都是业务规则,不应藏在支付回调代码里。

安全注意事项

不要信任前端提交的金额和用户身份

价格应由后端通过白名单映射:

private static readonly IReadOnlyDictionary<string, string> PriceIds =
    new Dictionary<string, string>
    {
        ["monthly"] = "price_monthly_xxx",
        ["yearly"] = "price_yearly_xxx"
    };

即使前端提交的是 plan = "monthly",后端也必须确认它属于允许值。不要允许前端直接提交任意 PriceId、金额或币种。

使用 HTTPS

结账入口、回调地址、用户中心和所有身份验证页面都必须使用 HTTPS。安全 Cookie、HSTS 和反向代理转发头也要正确配置。

分离测试环境与生产环境

测试密钥、生产密钥、Webhook Secret、商品 ID 和价格 ID 要分别配置。生产环境不能接受测试事件,开发调试时也不要使用生产卡片。

不记录敏感请求数据

需要检查应用日志、反向代理、APM、异常跟踪和请求审计配置。即使使用托管结账,也不能在日志中记录完整支付令牌、密钥或未经筛选的 Webhook 内容。

提供自助管理入口

优先使用支付服务商提供的客户门户,让用户自行完成以下操作:

  • 更新支付方式
  • 查看账单
  • 下载发票
  • 取消订阅
  • 更换套餐

这样可以减少自行开发敏感付款管理页面的工作量。

定期与支付平台对账

Webhook 可能因部署错误、网络故障或配置变更而丢失。网站可以定期查询支付平台中的有效订阅,并与本地数据库核对。付款状态通常应以支付平台的记录为主要依据,本地数据库主要用于授权和缓存。

PCI DSS 仍然需要关注

使用托管结账页不代表可以完全忽略 PCI DSS,但通常可以大幅缩小网站直接处理持卡人数据的范围。具体需要填写哪类自评问卷、是否要进行额外扫描,以及不同集成方式分别适用哪些要求,应以收单机构、支付服务商和当前 PCI DSS 规则为准。

如果使用自行托管的信用卡表单,即使表单字段由支付服务商的 JavaScript 组件承载,合规范围也可能不同于完全跳转到托管结账页。对个人开发者来说,完整托管的结账页通常最省事。

最终建议

小型 ASP.NET 订阅网站可以采用“支付服务商托管结账页 + 周期订阅 + Webhook + 本地权限表”的方案。

需要遵守以下原则:

  • 信用卡数据不能进入自己的服务器和数据库。
  • 后续扣款由支付服务商处理。
  • 本地只保存支付平台生成的标识和订阅状态。
  • 用户权限以通过签名验证的 Webhook 为依据。
  • 所有事件处理都要保证幂等性。
  • 金额、套餐和用户身份必须由后端验证。
  • 定期与支付平台对账,防止本地状态长期失真。

这套方案实施较快,日常维护工作较少,也比自行存储和处理信用卡信息更适合个人开发的项目。

备注:内容仅供参考。