PAYATHON 2026

如何在 MVC C# 中读取并传递支付响应结果

支付阿杰

结论

支付完成后,不要通过 WebRequest.Create(resultURL) 主动读取结果。应当把 MVC Controller 的公开地址配置为支付平台的返回地址。支付结束后,由支付平台通过 POST 将响应提交到该地址,具体方式以所用的 2C2P 接口文档为准。

Controller 收到响应后,需要:

  1. 读取支付平台提交的响应字段。
  2. 按 2C2P 文档解密或验证签名。
  3. 根据订单号更新数据库中的支付状态。
  4. 使用 HttpClient 把验证后的结果传给内部 API。
  5. 跳转到支付结果页面。

支付时浏览器通常会离开当前页面,所以不能依赖页面中的 JavaScript 变量保存订单数据。提交支付请求前,就要把订单和支付流水写入数据库。

为什么 WebRequest.Create(resultURL) 不合适

resultURL 通常是支付平台完成支付后回调或重定向到的商户地址,不是一个需要商户主动抓取的页面。

下面的代码只是让服务器重新请求一次该 URL:

WebRequest request = WebRequest.Create(resultURL);
WebResponse response = request.GetResponse();

这个请求存在几个问题:

  • 它不包含支付平台原本提交给浏览器或回调地址的表单数据。
  • 它无法恢复支付前保存在页面内存中的 JavaScript 状态。
  • 它可能再次触发页面流程。
  • 它不能可靠地证明支付成功。

数据丢失的原因并不是页面发生了跳转,而是需要长期保存的数据被留在了页面中。订单号、金额、商户流水号和初始支付状态,都应在跳转到支付平台前保存到服务器。

推荐的处理流程

创建本地订单
    ↓
保存订单号、金额和 Pending 状态
    ↓
浏览器提交支付表单到 2C2P
    ↓
用户完成支付
    ↓
2C2P 将响应提交到 MVC 返回地址
    ↓
MVC 验证响应并更新订单
    ↓
MVC 调用内部 API
    ↓
跳转到支付结果页

如果 2C2P 接口同时提供服务器通知地址和浏览器返回地址,应优先通过服务器通知确认支付状态。用户可能关闭页面,网络也可能中断,浏览器返回还可能因重复刷新而多次执行,因此不能把它作为可靠的支付确认依据。

MVC 接收支付响应

下面使用经典 ASP.NET MVC 5 写法演示。响应字段名称仅用于说明代码结构,需要替换为当前 2C2P 接口文档规定的实际字段。

public class PaymentResponseForm
{
    public string paymentResponse { get; set; }
}

Controller 可以直接接收表单字段:

using System.Threading.Tasks;
using System.Web.Mvc;

public class PaymentController : Controller
{
    [HttpPost]
    public async Task<ActionResult> Callback(PaymentResponseForm form)
    {
        if (form == null || string.IsNullOrWhiteSpace(form.paymentResponse))
        {
            return new HttpStatusCodeResult(400, "Missing payment response");
        }

        // 必须按照当前 2C2P 接口文档进行解密、验签和字段校验。
        PaymentResult result;

        try
        {
            result = DecodeAndVerifyPaymentResponse(form.paymentResponse);
        }
        catch
        {
            return new HttpStatusCodeResult(400, "Invalid payment response");
        }

        // 使用响应中的商户订单号查询本地订单。
        var order = await _orderService.FindByMerchantOrderNoAsync(
            result.MerchantOrderNo
        );

        if (order == null)
        {
            return HttpNotFound("Order not found");
        }

        // 不仅要检查状态,还要核对订单号、金额和币种。
        if (order.Amount != result.Amount ||
            !string.Equals(order.Currency, result.Currency,
                System.StringComparison.OrdinalIgnoreCase))
        {
            return new HttpStatusCodeResult(400, "Payment data mismatch");
        }

        // 更新操作应当具有幂等性,避免重复回调造成重复入账。
        await _orderService.ApplyPaymentResultAsync(order, result);

        // 将验证后的结构化结果传给内部 API。
        await _internalApi.SendPaymentResultAsync(result);

        return RedirectToAction(
            "Result",
            new { orderNo = result.MerchantOrderNo }
        );
    }

    [HttpGet]
    public async Task<ActionResult> Result(string orderNo)
    {
        var order = await _orderService.FindByMerchantOrderNoAsync(orderNo);

        if (order == null)
        {
            return HttpNotFound();
        }

        return View(order);
    }

    private PaymentResult DecodeAndVerifyPaymentResponse(string payload)
    {
        // 此处调用与当前 2C2P 产品及协议版本相匹配的解密/验签实现。
        // 不要只对 payload 做 Base64 解码后就信任其中的数据。
        throw new System.NotImplementedException();
    }
}

如果暂时无法确定平台提交的字段名称,可以在开发环境中查看表单键:

[HttpPost]
public ActionResult Callback()
{
    foreach (string key in Request.Form.Keys)
    {
        string value = Request.Form[key];

        // 只用于受控的开发环境。
        // 支付响应、令牌、卡片信息等敏感内容不能写入普通日志。
    }

    return new HttpStatusCodeResult(200);
}

确认字段后,应改用强类型模型,不要长期通过 Request.Form 处理全部数据。

将结果传给内部 API

应由 MVC 后端调用内部 API,不要让支付结果页中的 JavaScript 转发响应。后端转发可以防止用户篡改响应,也不会因为用户关闭页面而中断。

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;

public class InternalApiClient
{
    private readonly HttpClient _httpClient;

    public InternalApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task SendPaymentResultAsync(PaymentResult result)
    {
        var requestBody = new
        {
            merchantOrderNo = result.MerchantOrderNo,
            transactionId = result.TransactionId,
            status = result.Status,
            amount = result.Amount,
            currency = result.Currency
        };

        string json = JsonConvert.SerializeObject(requestBody);

        using (var request = new HttpRequestMessage(
            HttpMethod.Post,
            "api/payments/result"))
        {
            request.Content = new StringContent(
                json,
                Encoding.UTF8,
                "application/json"
            );

            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", GetAccessToken());

            using (HttpResponseMessage response =
                await _httpClient.SendAsync(request))
            {
                response.EnsureSuccessStatusCode();
            }
        }
    }

    private string GetAccessToken()
    {
        throw new NotImplementedException();
    }
}

不要为每次请求创建新的 HttpClient。在 ASP.NET MVC 5 中,可以复用同一个实例,也可以通过项目使用的依赖注入容器管理它的生命周期。

发起支付前先保存订单

提交表单后,浏览器会离开当前页面,这是正常行为。需要保留的数据应在提交支付表单前写入后端:

async function startPayment(parameters) {
    const saveResponse = await fetch('/Payment/CreateOrder', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json'
        },
        body: JSON.stringify({
            merchantOrderNo: parameters.merchantOrderNo,
            amount: parameters.amount,
            currency: parameters.currency
        })
    });

    if (!saveResponse.ok) {
        throw new Error('Unable to create payment order');
    }

    const form = $('<form></form>');
    form.attr('method', 'post');
    form.attr('action', url);

    $.each(parameters, function (key, value) {
        const field = $('<input />');
        field.attr('type', 'hidden');
        field.attr('name', key);
        field.attr('value', value);
        form.append(field);
    });

    $(document.body).append(form);
    form.submit();
}

实际项目中,金额、币种等重要数据应由服务器根据商品和订单重新计算,不能直接信任浏览器提交的值。

防止重复回调

支付平台可能因为超时、网络异常或自身的重试机制,多次发送同一个结果。因此,无论更新订单还是调用内部 API,都要保证操作具有幂等性。

一种常见做法是为支付流水号设置唯一约束,并在事务中更新订单:

public async Task ApplyPaymentResultAsync(
    Order order,
    PaymentResult result)
{
    if (order.ProcessedTransactionId == result.TransactionId)
    {
        return;
    }

    if (order.PaymentStatus == PaymentStatus.Paid)
    {
        return;
    }

    order.ProcessedTransactionId = result.TransactionId;
    order.PaymentStatus = MapPaymentStatus(result.Status);
    order.PaidAtUtc = DateTime.UtcNow;

    await _dbContext.SaveChangesAsync();
}

如果数据库已经更新成功,但内部 API 调用失败,不应把订单回滚为“未支付”。更可靠的处理方式是记录一条待发送事件,再由后台任务重试内部 API 请求。

安全与状态判断

处理支付响应时,需要注意:

  • 返回地址必须使用 HTTPS。
  • 按文档验证签名、MAC、JWT 或加密响应。具体方式取决于使用的 2C2P 产品和协议版本。
  • 不能只根据浏览器地址栏中的 status=success 判断支付成功。
  • 需要核对商户订单号、交易号、金额、币种和商户身份。
  • 不要在日志、页面或异常信息中输出完整的支付载荷和敏感字段。
  • 浏览器返回页只用于展示结果。最终状态应以验证过的服务器通知或支付状态查询结果为准。
  • 外部平台提交的 POST 通常不带本站生成的防伪令牌,因此回调 Action 不应依赖普通表单使用的 ValidateAntiForgeryToken。回调真实性应通过平台签名或加密响应验证。
  • 如果支付状态仍是处理中,页面应显示“处理中”,并通过服务器查询接口或后续通知更新状态,不能直接标记为失败。

没有必要阻止支付页面的正常跳转。业务状态应保存在服务器端,再由 MVC 的回调 Action 接收并验证支付平台提交的数据。WebRequest 和页面中的 JavaScript 都不应负责最终确认支付结果。

备注:内容仅供参考。