如何在 MVC C# 中读取并传递支付响应结果
结论
支付完成后,不要通过 WebRequest.Create(resultURL) 主动读取结果。应当把 MVC Controller 的公开地址配置为支付平台的返回地址。支付结束后,由支付平台通过 POST 将响应提交到该地址,具体方式以所用的 2C2P 接口文档为准。
Controller 收到响应后,需要:
- 读取支付平台提交的响应字段。
- 按 2C2P 文档解密或验证签名。
- 根据订单号更新数据库中的支付状态。
- 使用
HttpClient把验证后的结果传给内部 API。 - 跳转到支付结果页面。
支付时浏览器通常会离开当前页面,所以不能依赖页面中的 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 都不应负责最终确认支付结果。
备注:内容仅供参考。
