PAYATHON 2026

如何在 eCommerce 结账流程中显示具体错误?

支付老李

明确结论

可以显示比「无法处理订单」更具体的提示,前提是支付网关或收单机构返回了可用的错误码。

网站通常无法自行判断姓名是否与信用卡信息一致,也不能通过数据库确认 CVV 是否正确。这些信息需要由支付服务商验证,并以结构化响应返回,例如:

  • CVV 校验失败
  • 账单地址校验失败
  • 卡号格式无效
  • 卡片已过期
  • 余额不足
  • 发卡行拒绝交易
  • 支付服务暂时不可用

开发时不要把支付网关的原始错误信息直接展示给用户。后端应根据错误码,将其转换成安全、易懂且便于用户采取行动的提示,并把完整响应记录在受保护的日志中。

为什么目前只能显示通用错误

现有结账逻辑可能只判断支付是否成功:

if (!paymentResult.Success)
{
    ModelState.AddModelError(
        string.Empty,
        "无法处理订单。请检查账单和信用卡信息,然后重试。"
    );
}

这段代码没有区分具体的失败类型。要显示更明确的原因,需要继续读取支付接口返回的字段,例如:

Status
ErrorCode
DeclineCode
CvvResult
AvsResult
TransactionId

字段名称由支付网关及其 SDK 决定,不能假设所有服务商都使用同一种格式。

发卡行有时只会返回含义模糊的 declined 或 do_not_honor。遇到这类响应时,商户无法得知具体原因,也不应自行推测。

推荐的实现方式

1. 确认支付服务商返回的信息

先检查当前支付接口的响应对象和官方文档,确认以下内容:

  • 是否提供结构化错误码或拒绝码
  • 是否提供 CVV 校验结果
  • 是否提供 AVS(Address Verification Service)结果
  • 哪些错误可以安全地展示给持卡人
  • 如何在测试环境中模拟不同的失败场景

如果当前集成只能返回成功或失败,可能需要升级 SDK、调整 API 请求,或修改支付服务的封装层,以保留完整的结构化响应。

2. 在支付层定义统一结果

MVC Controller 不应直接依赖某个支付网关返回的错误文本。可以先定义一套网站内部统一使用的结果类型:

public enum PaymentFailureReason
{
    None,
    InvalidCardNumber,
    ExpiredCard,
    InvalidCvv,
    AddressMismatch,
    InsufficientFunds,
    IssuerDeclined,
    GatewayUnavailable,
    Unknown
}

public sealed class PaymentResult
{
    public bool Success { get; init; }
    public PaymentFailureReason FailureReason { get; init; }
    public string? ProviderCode { get; init; }
    public string? TransactionId { get; init; }
}

再由支付服务把网关响应转换成内部类型:

public PaymentResult MapProviderResponse(ProviderResponse response)
{
    if (response.IsSuccessful)
    {
        return new PaymentResult
        {
            Success = true,
            FailureReason = PaymentFailureReason.None,
            TransactionId = response.TransactionId
        };
    }

    var reason = response.DeclineCode switch
    {
        "invalid_number"       => PaymentFailureReason.InvalidCardNumber,
        "expired_card"         => PaymentFailureReason.ExpiredCard,
        "incorrect_cvc"        => PaymentFailureReason.InvalidCvv,
        "incorrect_address"    => PaymentFailureReason.AddressMismatch,
        "insufficient_funds"   => PaymentFailureReason.InsufficientFunds,
        "issuer_declined"      => PaymentFailureReason.IssuerDeclined,
        "processing_error"     => PaymentFailureReason.GatewayUnavailable,
        _                      => PaymentFailureReason.Unknown
    };

    return new PaymentResult
    {
        Success = false,
        FailureReason = reason,
        ProviderCode = response.DeclineCode,
        TransactionId = response.TransactionId
    };
}

上面的错误码仅用于演示。实际实施时,必须替换成当前支付服务商定义的代码。

3. 将内部原因转换成用户提示

用户提示应简洁,并说明接下来可以怎么做:

private static string GetCustomerMessage(PaymentFailureReason reason)
{
    return reason switch
    {
        PaymentFailureReason.InvalidCardNumber =>
            "卡号似乎不正确,请检查后重新输入。",

        PaymentFailureReason.ExpiredCard =>
            "这张卡已过期,请使用其他银行卡。",

        PaymentFailureReason.InvalidCvv =>
            "安全码(CVV)不正确,请检查卡片背面或正面的安全码。",

        PaymentFailureReason.AddressMismatch =>
            "账单地址未通过验证,请确认其与发卡行登记的信息一致。",

        PaymentFailureReason.InsufficientFunds =>
            "交易未获批准,请更换付款方式或联系发卡行。",

        PaymentFailureReason.IssuerDeclined =>
            "发卡行未批准本次交易,请联系发卡行或使用其他付款方式。",

        PaymentFailureReason.GatewayUnavailable =>
            "支付服务暂时不可用,请稍后重试。",

        _ =>
            "暂时无法处理订单,请检查付款信息或使用其他付款方式。"
    };
}

部分支付服务商可能不允许商户直接显示「余额不足」,或者返回的信息不足以支持这一判断。这时应改用「交易未获批准」等中性表述。

4. 在 MVC Controller 中显示提示

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<ActionResult> Checkout(CheckoutViewModel model)
{
    if (!ModelState.IsValid)
    {
        return View(model);
    }

    PaymentResult result = await _paymentService.ChargeAsync(model);

    if (!result.Success)
    {
        string message = GetCustomerMessage(result.FailureReason);
        ModelState.AddModelError(nameof(model.Payment), message);

        _logger.LogWarning(
            "Payment failed. Reason={Reason}, ProviderCode={ProviderCode}, TransactionId={TransactionId}",
            result.FailureReason,
            result.ProviderCode,
            result.TransactionId
        );

        ClearSensitivePaymentFields(model);
        return View(model);
    }

    return RedirectToAction("Confirmation", new { id = result.TransactionId });
}

视图可以通过验证组件显示错误:

@Html.ValidationMessageFor(
    model => model.Payment,
    null,
    new { @class = "validation-error" }
)

如果项目使用 ASP.NET Core MVC,通常可以写成:

<span asp-validation-for="Payment" class="text-danger"></span>

前端校验与支付校验要分开

部分错误可以在提交前发现,例如:

  • 卡号长度不正确或未通过 Luhn 校验
  • 有效期格式错误
  • 必填字段为空
  • 邮政编码格式错误

前端和服务器端模型验证可以立即提示这些问题:

public sealed class CheckoutViewModel
{
    [Required]
    public string BillingName { get; set; } = string.Empty;

    [Required]
    public string BillingAddress { get; set; } = string.Empty;

    [Required]
    [RegularExpression(@"^\d{3,4}$", ErrorMessage = "请输入 3 位或 4 位安全码。")]
    public string Cvv { get; set; } = string.Empty;
}

格式正确并不代表信息真实有效。CVV、地址和交易授权的最终结果,仍要以支付网关或发卡行的响应为准。

「姓名不匹配」通常无法准确提示

「输入的姓名与信用卡信息不匹配」并不一定是能够实现的提示。

大多数银行卡授权流程不会向商户提供可靠的持卡人姓名匹配结果。支付网关更常返回以下信息:

  • CVV/CVC 校验结果
  • 数字街道地址匹配结果
  • 邮政编码匹配结果
  • 发卡行授权结果

因此,只有支付服务商明确返回姓名校验结果时,网站才能显示相关提示。不能根据一次普通的拒付响应,自行将原因判断为「姓名不匹配」。

安全与合规注意事项

不要直接显示原始网关消息

原始响应可能包含内部代码、风控规则或其他不适合公开的信息。应通过错误码白名单进行映射。无法识别的错误统一显示安全的通用提示。

不要记录敏感卡片数据

日志、SQL 数据库和异常信息中不应保存:

  • CVV/CVC
  • 完整卡号
  • 磁道数据
  • PIN
  • 未脱敏的支付请求或响应

CVV 尤其不能存储。如果日志需要识别银行卡,应使用支付服务商提供的 payment method ID、token,或经过合规处理的卡号后四位。

优先使用支付服务商的托管控件或令牌化方案

理想情况下,卡号和 CVV 不应直接经过网站服务器,而应由支付服务商提供的托管字段或前端 SDK 收集,并转换成 token。这样可以降低敏感数据泄露的风险,但实际 PCI DSS 范围仍需根据系统架构和支付服务商的要求确定。

避免错误提示泄露过多信息

过于具体的错误提示可能被用于批量测试盗取的卡片信息。应同时采取以下措施:

  • 限制结账和支付尝试的频率
  • 对连续失败进行风控拦截
  • 使用 CAPTCHA 或设备风险识别
  • 监控同一账户、IP、设备或银行卡的异常尝试
  • 不显示「卡号存在,但只有 CVV 错误」等可能帮助攻击者逐项验证的信息

实施工作量取决于现有支付集成

如果当前支付 SDK 已经返回结构化错误码,改造通常涉及三处:

  1. 在支付服务层解析错误码。
  2. 建立内部失败原因与用户提示之间的映射。
  3. 在结账页面显示字段级或页面级错误,并补充安全日志和测试。

如果支付接口只返回布尔值,需要先修改接口封装,保留支付服务商返回的拒绝码、CVV/AVS 结果和交易标识。如果服务商本身不提供更详细的结果,网站就无法自行判断具体错误,只能继续使用通用提示,或考虑更换支持结构化失败响应的支付接口。

备注:内容仅供参考。