如何在 ASP.NET 计费项目中实现 payment gateway?
明确结论
支付网关不是可以”访问所有银行账户”的统一入口。
在 ASP.NET 计费项目中,通常只需接入一家支付服务商,就能受理该服务商支持的银行卡、电子钱包或本地支付方式,不必逐家对接发卡银行。具体能覆盖哪些用户,取决于服务商支持的国家和地区、卡组织、币种、结算方式,以及商户资质。
如果业务需要读取账户余额、查询流水或直接操作用户的银行账户,则需要使用开放银行(Open Banking)或账户信息服务。这些能力不属于普通支付网关,通常需要用户授权,并受当地金融监管要求约束。
支付网关在系统中的作用
典型的支付流程如下:
- ASP.NET 服务端创建订单,计算最终应付金额。
- 服务端调用支付服务商 API,创建支付会话或支付意图。
- 前端跳转到服务商托管的收银台,或使用服务商提供的前端组件采集支付信息。
- 用户通过银行卡、电子钱包等方式付款。
- 支付服务商通过 Webhook 将支付结果通知服务端。
- 服务端验证通知的真实性,然后更新订单状态。
- 服务商按照结算周期,将资金划入商户绑定的结算账户。
这套流程是由支付服务商代商户收款,应用不会直接登录或访问付款人的银行账户。
一家支付网关是否足够
如果业务只面向一个市场,且支付服务商已经支持所需的银行卡、币种和付款方式,接入一家通常就够了。
遇到以下情况时,可能需要接入多家服务商:
- 业务覆盖多个国家或地区;
- 需要支持某些本地支付方式;
- 单一服务商不支持目标币种或商户所在地区;
- 需要在某家服务商发生故障时切换支付通道;
- 需要根据手续费、成功率或结算周期选择通道;
- 各服务商对订阅扣款、分账、退款等功能的支持不同。
选择服务商前,需要确认:
- 商户所在国家或地区能否开户;
- 支持哪些银行卡组织和付款方式;
- 是否支持一次性付款、订阅和自动续费;
- 是否支持目标币种及跨境结算;
- 手续费、退款费和拒付处理规则;
- 是否提供 ASP.NET 或 .NET SDK;
- Webhook 使用什么签名验证方式;
- 是否提供测试环境;
- 是否需要进行 PCI DSS 合规评估;
- 资金最终可以结算到哪些银行账户。
推荐的项目结构
不要把某家服务商的调用逻辑分散在 Controller 中。可以先定义统一的支付接口,再为各服务商编写具体实现。以后增加第二个支付通道时,订单业务不需要重写。
public sealed record CreatePaymentRequest(
string OrderId,
long AmountMinor,
string Currency,
string SuccessUrl,
string CancelUrl);
public sealed record CreatePaymentResult(
string ProviderPaymentId,
string CheckoutUrl);
public interface IPaymentGateway
{
Task<CreatePaymentResult> CreatePaymentAsync(
CreatePaymentRequest request,
CancellationToken cancellationToken = default);
Task RefundAsync(
string providerPaymentId,
long? amountMinor,
CancellationToken cancellationToken = default);
}
AmountMinor 表示最小货币单位。例如,人民币 1000 通常表示 10.00 CNY。不要使用 double 保存金额。内部可以用整数表示最小货币单位,也可以根据服务商的要求使用 decimal。
创建支付订单
Controller 负责接收请求和组织流程。订单金额必须根据数据库中的商品、套餐或账单记录重新计算,不能直接采用浏览器提交的金额。
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/payments")]
public sealed class PaymentsController : ControllerBase
{
private readonly IPaymentGateway _gateway;
private readonly IOrderService _orders;
public PaymentsController(
IPaymentGateway gateway,
IOrderService orders)
{
_gateway = gateway;
_orders = orders;
}
[HttpPost("{orderId}/checkout")]
public async Task<ActionResult<CreatePaymentResult>> CreateCheckout(
string orderId,
CancellationToken cancellationToken)
{
var order = await _orders.GetPendingOrderAsync(
orderId,
cancellationToken);
if (order is null)
{
return NotFound();
}
if (order.IsPaid)
{
return Conflict("Order has already been paid.");
}
var request = new CreatePaymentRequest(
OrderId: order.Id,
AmountMinor: order.AmountMinor,
Currency: order.Currency,
SuccessUrl: $"https://example.com/payment/success?orderId={order.Id}",
CancelUrl: $"https://example.com/payment/cancel?orderId={order.Id}");
var result = await _gateway.CreatePaymentAsync(
request,
cancellationToken);
await _orders.SaveProviderPaymentIdAsync(
order.Id,
result.ProviderPaymentId,
cancellationToken);
return Ok(result);
}
}
IPaymentGateway 的具体实现要按照所选服务商的官方 SDK 编写。不同服务商采用的认证方式、请求字段和返回结构并不相同,不能假设它们使用一套通用的 API 参数。
使用依赖注入注册支付服务
非敏感选项可以保存在配置文件中,API 密钥则应通过环境变量或密钥管理服务保存。
builder.Services.Configure<PaymentOptions>(
builder.Configuration.GetSection("Payment"));
builder.Services.AddHttpClient<IPaymentGateway, ExamplePaymentGateway>();
配置模型示例:
public sealed class PaymentOptions
{
public string ApiBaseUrl { get; init; } = string.Empty;
public string ApiKey { get; init; } = string.Empty;
public string WebhookSecret { get; init; } = string.Empty;
}
不要将生产环境密钥提交到 Git 仓库,也不要在日志中记录私钥、完整支付请求或银行卡敏感数据。
必须通过 Webhook 确认支付结果
支付成功页面只能用于改善用户体验,不能作为记账依据。用户可能会关闭页面、伪造跳转参数,也可能在付款成功后未能返回网站。
服务端应以通过签名验证的 Webhook 为准:
[ApiController]
[Route("api/payment-webhooks")]
public sealed class PaymentWebhooksController : ControllerBase
{
private readonly IWebhookVerifier _verifier;
private readonly IOrderService _orders;
public PaymentWebhooksController(
IWebhookVerifier verifier,
IOrderService orders)
{
_verifier = verifier;
_orders = orders;
}
[HttpPost]
public async Task<IActionResult> Handle(
CancellationToken cancellationToken)
{
using var reader = new StreamReader(Request.Body);
var payload = await reader.ReadToEndAsync(cancellationToken);
var signature = Request.Headers["X-Payment-Signature"].ToString();
PaymentWebhookEvent paymentEvent;
try
{
paymentEvent = _verifier.VerifyAndParse(payload, signature);
}
catch
{
return Unauthorized();
}
if (paymentEvent.Type == "payment.succeeded")
{
await _orders.MarkAsPaidAsync(
paymentEvent.OrderId,
paymentEvent.ProviderPaymentId,
paymentEvent.EventId,
cancellationToken);
}
return Ok();
}
}
这里的 X-Payment-Signature、事件名称和签名算法只是结构示例,实际名称应以所选服务商的官方文档为准。
Webhook 处理还要保证幂等。同一个事件可能会被重复发送,因此需要保存唯一的 EventId。如果事件已经处理过,直接返回成功,避免重复入账、发货或开通订阅。
安全与合规注意事项
不要自行处理银行卡数据
优先使用服务商托管的收银台或官方前端组件,让银行卡号直接提交给支付服务商。不要让完整卡号、CVV 等敏感数据经过自己的 ASP.NET 服务器,否则会明显扩大 PCI DSS 合规范围,并增加数据泄露风险。
服务端校验金额和币种
收到 Webhook 后,需要核对:
- 订单编号是否存在;
- 支付服务商订单号是否匹配;
- 金额是否与本地账单一致;
- 币种是否一致;
- 当前订单状态是否允许变更;
- 支付状态是否确实成功。
不能仅凭 "payment.succeeded" 就把任意订单标记为已支付。
使用 HTTPS 和密钥管理
生产环境必须使用 HTTPS。API 密钥和 Webhook Secret 应保存在受控配置中,例如环境变量或云平台的密钥管理服务,并根据服务商的支持情况定期轮换。
设计完整的订单状态
计费系统不能只有”已支付”和”未支付”两种状态。通常还要考虑:
Pending
Processing
Paid
Failed
Cancelled
PartiallyRefunded
Refunded
Disputed
具体采用哪些状态,应根据一次性付款、订阅、退款和拒付等业务场景决定。
测试异常流程
除了正常付款,还要测试:
- 用户取消支付;
- 支付失败或仍在处理中;
- Webhook 重复、乱序或延迟;
- 创建支付请求超时;
- 支付已经成功,但本地更新失败;
- 全额退款与部分退款;
- 订阅续费失败;
- 拒付和争议处理。
如果真正需要访问银行账户
如果应用需要在用户授权后读取余额、获取交易记录或从银行账户发起转账,应选择支持 Open Banking、账户聚合或银行转账发起的服务商。
这类流程通常包括:
- 将用户重定向到银行或授权机构;
- 获取用户的明确授权;
- 使用短期授权码交换访问令牌;
- 在授权范围内读取账户信息或发起付款;
- 处理令牌过期、授权撤销和数据保护要求。
这类服务与银行卡收单不是同一种产品,也没有一个接口可以天然覆盖全球所有银行账户。实际覆盖范围取决于服务商接入了哪些银行、在哪些司法辖区运营,以及持有哪些监管许可。
实施建议
普通的 ASP.NET 计费项目可以先选择一家覆盖目标市场的成熟支付服务商,用托管收银台完成第一版,同时把支付能力封装在 IPaymentGateway 接口后面。
上线前需要完成 Webhook 验签、幂等处理、服务端金额校验、退款流程和异常补偿。只有现有服务商无法满足地区、付款方式或可靠性要求时,再接入第二家支付通道。
备注:内容仅供参考。
