MVC 电商网站如何接入第三方 Payment provider
结论
可以。你需要接入支持 Hosted Checkout(托管收银台) 或 Hosted Payment Page(托管支付页) 的 Payment provider。
一般流程如下:
- 用户在网站创建订单,选择银行卡等付款方式。
- MVC 后端调用支付服务商 API,创建支付会话。
- 支付服务商返回安全的支付页面 URL。
- 后端将用户重定向到该 URL。
- 用户在支付服务商的页面填写银行卡等敏感信息。
- 支付服务商通过 Webhook 将最终支付结果通知服务器。
- 服务器验证通知,随后更新订单状态。
Stripe Checkout、Adyen Hosted Checkout、Braintree、Checkout.com,以及银行或本地收单机构提供的托管支付页,都能实现这套流程。具体可以使用哪些服务,取决于网站注册国家、结算币种和业务类型。
为什么应使用托管支付页
除非团队有能力承担完整的 PCI DSS 合规、安全审计和密钥管理工作,否则不要自行设计表单来接收银行卡号、CVV 或有效期。
使用托管支付页有以下好处:
- 银行卡敏感信息直接提交给 Payment provider。
- 你的服务器不必保存或处理完整卡号、CVV。
- 3-D Secure、身份验证和风控流程通常由服务商处理。
- 支付页面使用 HTTPS,并由服务商持续维护。
- PCI DSS 合规范围通常更小。
不过,使用托管页面并不等于完全免除合规责任。上线前仍需按照服务商要求完成商户审核、网站信息配置和相应的 PCI 自评。
MVC 中的接入流程
1. 创建本地订单
用户点击付款前,先在数据库中创建订单,此时不要将订单标记为已付款:
Pending -> PaymentPending -> Paid
-> PaymentFailed
-> Cancelled
订单金额必须由服务器根据商品、优惠和运费重新计算,不能直接采用浏览器传来的金额。
2. 创建支付会话
后端向 Payment provider 提交以下信息:
- 商户订单号
- 金额和币种
- 商品摘要
- 支付成功返回地址
- 取消返回地址
- 用于关联订单的 metadata
- Webhook 通知地址,具体配置方式由服务商决定
服务商通常会返回会话 ID 和支付页面 URL。
下面用 ASP.NET MVC 风格的通用接口说明代码结构。这不是任何服务商的实际 SDK,正式接入时需要替换为所选服务商的官方 SDK:
public sealed class CreateCheckoutRequest
{
public string OrderNumber { get; init; } = string.Empty;
public long AmountInMinorUnit { get; init; }
public string Currency { get; init; } = string.Empty;
public string SuccessUrl { get; init; } = string.Empty;
public string CancelUrl { get; init; } = string.Empty;
}
public sealed class CheckoutSession
{
public string SessionId { get; init; } = string.Empty;
public string CheckoutUrl { get; init; } = string.Empty;
}
public interface IPaymentProvider
{
Task<CheckoutSession> CreateCheckoutSessionAsync(
CreateCheckoutRequest request,
CancellationToken cancellationToken);
}
金额建议用货币最小单位的整数表示。例如,对于支持两位小数的币种,19.99 通常写成 1999。不同币种的小数位规则并不相同,必须以支付服务商的币种文档为准,不能全部统一乘以 100。
3. 重定向用户
Controller 从数据库读取订单,再使用服务器中保存的金额创建支付会话:
public sealed class PaymentController : Controller
{
private readonly IOrderRepository _orders;
private readonly IPaymentProvider _paymentProvider;
public PaymentController(
IOrderRepository orders,
IPaymentProvider paymentProvider)
{
_orders = orders;
_paymentProvider = paymentProvider;
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Checkout(
long orderId,
CancellationToken cancellationToken)
{
var order = await _orders.FindAsync(orderId, cancellationToken);
if (order == null || order.Status != OrderStatus.Pending)
{
return HttpNotFound();
}
// 金额来自服务器中的订单,而不是请求参数。
var session = await _paymentProvider.CreateCheckoutSessionAsync(
new CreateCheckoutRequest
{
OrderNumber = order.OrderNumber,
AmountInMinorUnit = order.AmountInMinorUnit,
Currency = order.Currency,
SuccessUrl = Url.Action(
"Return",
"Payment",
new { orderId = order.Id },
Request.Url.Scheme),
CancelUrl = Url.Action(
"Cancelled",
"Payment",
new { orderId = order.Id },
Request.Url.Scheme)
},
cancellationToken);
order.PaymentSessionId = session.SessionId;
order.Status = OrderStatus.PaymentPending;
await _orders.SaveAsync(order, cancellationToken);
return Redirect(session.CheckoutUrl);
}
}
生产环境只能重定向到服务商返回的 HTTPS 地址,或配置中明确允许的 HTTPS 地址。不要接受客户端提交的任意跳转 URL,以免产生开放重定向漏洞。
使用 Webhook 确认付款
用户被重定向回 SuccessUrl,只能说明浏览器返回了网站,不能证明支付已经成功。用户可能伪造请求,也可能在付款成功后直接关闭页面。
订单是否已付款,应以通过签名验证的 Webhook 为准,也可以由服务器主动向服务商查询支付状态:
[HttpPost]
[AllowAnonymous]
public async Task<ActionResult> Webhook(
CancellationToken cancellationToken)
{
string payload;
using (var reader = new StreamReader(Request.InputStream))
{
payload = await reader.ReadToEndAsync();
}
var signature = Request.Headers["Payment-Signature"];
PaymentEvent paymentEvent;
try
{
paymentEvent = PaymentWebhookVerifier.Verify(
payload,
signature,
WebhookSecret);
}
catch (PaymentSignatureException)
{
return new HttpStatusCodeResult(400);
}
if (paymentEvent.Type == "payment.succeeded")
{
var order = await _orders.FindByPaymentSessionAsync(
paymentEvent.SessionId,
cancellationToken);
if (order == null)
{
return HttpNotFound();
}
// Webhook 可能被重复发送,因此更新必须具备幂等性。
if (order.Status != OrderStatus.Paid)
{
if (order.AmountInMinorUnit != paymentEvent.AmountInMinorUnit ||
!string.Equals(
order.Currency,
paymentEvent.Currency,
StringComparison.OrdinalIgnoreCase))
{
return new HttpStatusCodeResult(400);
}
order.Status = OrderStatus.Paid;
order.PaidAtUtc = paymentEvent.PaidAtUtc;
await _orders.SaveAsync(order, cancellationToken);
}
}
return new HttpStatusCodeResult(200);
}
这里的 PaymentWebhookVerifier、事件名称和请求头都只是示意名称。实际实现必须使用所选 Payment provider 官方 SDK 提供的签名验证功能,不能自行猜测签名算法或字段名称。
返回页面应该怎样处理
返回页面只用于显示订单当前的状态:
[HttpGet]
public async Task<ActionResult> Return(
long orderId,
CancellationToken cancellationToken)
{
var order = await _orders.FindAsync(orderId, cancellationToken);
if (order == null)
{
return HttpNotFound();
}
return View(order);
}
如果 Webhook 还没有到达,可以显示“付款结果确认中”,再由前端轮询订单状态。不要因为 URL 中带有 success=true、订单号或支付会话 ID 就直接发货。
选择 Payment provider 时要确认什么
接入前至少要检查这些事项:
- 是否支持商户所在的国家或地区。
- 是否支持所需币种和结算银行账户。
- 是否提供 Hosted Checkout,而不只是底层银行卡 API。
- 是否支持 3-D Secure、退款、部分退款和拒付处理。
- 是否提供沙箱环境、官方服务器端 SDK 和 Webhook 签名验证。
- 交易费、退款费、货币转换费和结算周期。
- 支付页面能否配置品牌、语言和移动端样式。
- 对网站隐私政策、退款政策、配送说明和商户审核有哪些要求。
如果业务主要面向某个国家,还应优先了解当地银行或持牌支付机构,因为国际服务商未必能为该地区的商户开户。
上线注意事项
- API secret 和 Webhook secret 只能保存在服务器端配置或密钥管理系统中。
- 测试环境和 production 环境必须使用不同的密钥及 Webhook 地址。
- 所有支付相关页面和回调地址都必须启用 HTTPS。
- 创建支付会话时使用幂等键,避免重复提交产生多笔付款。
- 分别保存服务商支付 ID、事件 ID 和本地订单号,方便对账。
- Webhook 处理必须允许重复通知,同时防止同一订单重复发货。
- 验证 Webhook 签名后,还要核对金额、币种、商户账户和订单关联关系。
- 不要记录完整请求中的银行卡敏感信息、密钥或签名。
- 为超时、付款失败、用户取消、退款和拒付设置明确的订单状态。
- 使用服务商提供的测试卡和沙箱,测试支付成功、支付失败、重复通知和延迟通知等情况。
在 MVC 项目中增加第三种支付方式,无需自行搭建银行卡付款页面。选择支持 Hosted Checkout 的 Payment provider,由 MVC 后端创建支付会话并重定向用户,最后通过验证后的 Webhook 确认订单即可。
备注:内容仅供参考。
