.NET 网站如何实现信用卡月度订阅付款?
结论
可以实现,但不要自行保存银行卡信息,也不要编写定时任务直接扣款。更稳妥的做法是接入支持订阅计费的支付服务,例如 Stripe Billing、PayPal Subscriptions,或当地收单机构提供的周期扣款产品。
常见流程如下:
- 在支付平台创建按月计费的价格。
- 使用平台托管的支付页面收集银行卡信息。
- 由支付平台创建订阅并按月自动扣款。
- .NET 后端通过 Webhook 同步付款成功、付款失败、取消等状态。
- 用户取消订阅时,调用支付平台的取消订阅 API,停止后续扣款。
下面使用 ASP.NET Core 和 Stripe 举例。Stripe.NET 的具体类名和参数可能随版本变化,实施时应以已安装版本的官方文档为准。
为什么要交给支付平台处理
银行卡号、有效期和安全码都是敏感支付数据。如果网站服务器直接接收或保存这些信息,PCI DSS 合规范围会明显扩大,数据泄露风险也会增加。
使用支付平台托管的 Checkout 页面或官方前端组件后,银行卡数据可以直接提交给支付平台。应用数据库通常只需保存:
- 本地用户 ID
- 支付平台客户 ID
- 订阅 ID
- 价格或套餐 ID
- 订阅状态
- 当前计费周期结束时间
不要在日志或数据库中保存完整卡号、CVV/CVC。即使业务方提出这类要求,也不应采用这样的设计。
基本数据模型
可以为用户添加以下订阅字段:
public sealed class UserSubscription
{
public long Id { get; set; }
public string UserId { get; set; } = default!;
public string ProviderCustomerId { get; set; } = default!;
public string ProviderSubscriptionId { get; set; } = default!;
public string PriceId { get; set; } = default!;
public string Status { get; set; } = default!;
public DateTimeOffset? CurrentPeriodEnd { get; set; }
}
Status 不应简化为 bool IsPaid。订阅可能处于试用、正常、欠款、暂停、已取消或未完成等状态,因此需要保留支付平台返回的实际状态。
创建月度订阅
先在支付平台后台创建产品和月度价格,取得类似 price_xxx 的价格 ID。价格和金额最好由服务器根据套餐读取,不要直接使用浏览器传入的金额。
安装 Stripe.NET:
dotnet add package Stripe.net
密钥应保存在配置中。生产环境要使用密钥管理服务或安全的环境变量,不要把密钥提交到 Git 仓库。
{
"Stripe": {
"SecretKey": "从安全配置源读取",
"WebhookSecret": "从安全配置源读取"
}
}
创建 Checkout Session:
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe.Checkout;
[ApiController]
[Route("api/subscriptions")]
public sealed class SubscriptionsController : ControllerBase
{
[Authorize]
[HttpPost("checkout")]
public ActionResult CreateCheckout()
{
var userId = User.FindFirst("sub")?.Value;
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
// 应由服务端根据用户选择的套餐查询,不要直接信任客户端 PriceId。
const string priceId = "price_xxx";
var options = new SessionCreateOptions
{
Mode = "subscription",
SuccessUrl = "https://example.com/account?checkout=success",
CancelUrl = "https://example.com/pricing?checkout=cancelled",
ClientReferenceId = userId,
LineItems = new List<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = 1
}
},
SubscriptionData = new SessionSubscriptionDataOptions
{
Metadata = new Dictionary<string, string>
{
["userId"] = userId
}
}
};
var service = new SessionService();
var session = service.Create(options);
return Ok(new { session.Url });
}
}
服务端创建 Session 后,前端跳转到 session.Url。银行卡信息由用户在支付平台页面输入,不会经过网站后端。
不能仅凭用户访问了 SuccessUrl 就开通服务。这个请求可能被伪造,用户也可能在付款完成前关闭页面。最终状态必须通过经过签名验证的 Webhook 确认。
通过 Webhook 同步订阅状态
订阅创建、扣款成功、扣款失败或订阅取消时,支付平台会向网站发送事件。常见事件包括:
checkout.session.completedinvoice.paidinvoice.payment_failedcustomer.subscription.updatedcustomer.subscription.deleted
Webhook 端点需要读取原始请求体并验证签名:
using Microsoft.AspNetCore.Mvc;
using Stripe;
[ApiController]
[Route("api/webhooks/stripe")]
public sealed class StripeWebhookController : ControllerBase
{
private readonly IConfiguration _configuration;
public StripeWebhookController(IConfiguration configuration)
{
_configuration = configuration;
}
[HttpPost]
public async Task<IActionResult> Handle()
{
var json = await new StreamReader(Request.Body)
.ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].ToString();
var webhookSecret =
_configuration["Stripe:WebhookSecret"]
?? throw new InvalidOperationException("Webhook secret is missing.");
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(
json,
signature,
webhookSecret);
}
catch (StripeException)
{
return BadRequest();
}
switch (stripeEvent.Type)
{
case "invoice.paid":
// 将对应账期标记为已付款,必要时恢复服务。
break;
case "invoice.payment_failed":
// 标记付款失败,通知用户更新付款方式。
break;
case "customer.subscription.updated":
// 同步订阅状态和计费周期结束时间。
break;
case "customer.subscription.deleted":
// 标记订阅已取消,不再提供新的付费周期。
break;
}
return Ok();
}
}
实际项目还要记录支付平台的事件 ID,并为该字段建立唯一约束。支付平台可能重复投递同一个事件,因此 Webhook 处理必须具备幂等性。
Webhook 的到达顺序不一定与事件的发生顺序完全一致。更新状态时,要结合事件创建时间、订阅 ID 和数据库中的当前状态进行判断,防止旧事件覆盖新状态。
取消订阅
取消通常有两种处理方式:
- 立即取消:现在停止订阅,不再产生后续账单。
- 周期结束时取消:不再续费,但允许用户继续使用到已付款周期结束。
如果要求取消后立即停止后续扣款,可以直接删除支付平台上的订阅:
using Stripe;
public sealed class SubscriptionManager
{
public async Task CancelImmediatelyAsync(
string providerSubscriptionId,
CancellationToken cancellationToken)
{
var service = new SubscriptionService();
await service.CancelAsync(
providerSubscriptionId,
cancellationToken: cancellationToken);
}
}
控制器不能接收任意订阅 ID 后直接执行取消。应先从数据库查询当前登录用户的订阅,并确认该订阅确实属于此用户:
[Authorize]
[HttpPost("cancel")]
public async Task<IActionResult> Cancel(
CancellationToken cancellationToken)
{
var userId = User.FindFirst("sub")?.Value;
if (string.IsNullOrWhiteSpace(userId))
return Unauthorized();
var subscription = await FindSubscriptionForUserAsync(
userId,
cancellationToken);
if (subscription is null)
return NotFound();
await _subscriptionManager.CancelImmediatelyAsync(
subscription.ProviderSubscriptionId,
cancellationToken);
return Accepted();
}
Accepted 表示取消请求已经提交。数据库中的最终状态仍要通过 Webhook 更新,不能只依赖这次 API 调用。
如果业务允许用户继续使用到当前付款周期结束,可以设置 CancelAtPeriodEnd = true,不必立即取消:
var service = new SubscriptionService();
await service.UpdateAsync(
providerSubscriptionId,
new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true
},
cancellationToken: cancellationToken);
“停止后续扣款”和”立即关闭访问权限”是两个不同的问题。立即取消通常会阻止下一次续费,但是否退款、是否立刻关闭服务,需要由业务规则明确规定。
自动扣款失败怎么处理
信用卡可能因为余额不足、卡片过期、银行拒绝或需要额外验证而扣款失败。一次失败不应直接视为永久取消。
可以按以下方式处理:
- 记录失败状态,但不要保存敏感卡片信息。
- 通过邮件或站内通知提醒用户更新付款方式。
- 使用支付平台提供的重试和催收设置。
- 设置宽限期,例如在若干天内保留有限的访问权限。
- 最终扣款失败或订阅正式取消后,再关闭服务。
具体的重试次数和间隔通常可以在支付平台后台配置,不建议自行编写扣款重试定时器。
上线前需要确认的事项
- 使用 HTTPS,分别管理测试环境和生产环境的密钥。
- 由服务器控制价格、币种和套餐映射。
- 验证所有 Webhook 签名。
- Webhook、创建订阅和取消操作都要具备幂等性。
- 保存支付平台的客户 ID、订阅 ID 和事件 ID。
- 明确取消、退款、试用期、欠款和套餐变更规则。
- 不保存完整卡号或安全码。
- 确认服务商是否支持目标国家、币种、银行卡类型和周期扣款。
- 根据经营地区处理税费、发票、消费者撤销权和自动续费提示。
- 在测试环境中验证首次付款、续费成功、续费失败、换卡、重复事件和取消等完整流程。
基本分工是,支付平台负责银行卡数据和周期扣款,.NET 应用负责用户、套餐、权限以及订阅状态同步。这样可以实现按月自动收费和随时取消,同时降低支付安全与合规风险。
备注:内容仅供参考。
