Hotcakes custom payment provider 回调处理与 return url 构造
结论
可以在 ProcessCheckout 中准备支付请求,并将顾客重定向到支付平台。但判断订单是否支付成功,不能以顾客是否返回页面为依据。
支付平台的 server-to-server 通知应由独立且允许匿名访问的 HTTP endpoint 接收。这个 endpoint 负责验证签名、查询订单、幂等更新支付状态,并按照支付平台的要求返回响应。顾客的 return URL 只用于恢复前台结账流程和展示支付结果。
return URL 和通知 URL 都应通过 ASP.NET MVC 的路由机制生成绝对 URL,不要手动拼接域名和路径。
推荐的处理流程
完整流程通常如下:
- Hotcakes 创建订单或生成待支付订单。
ProcessCheckout构造支付平台要求的参数。- 将 Hotcakes 订单号或不可预测的支付引用传给支付平台。
- 顾客被重定向到支付平台。
- 支付平台向通知 endpoint 发起 server-to-server 请求。
- 商店验证通知并更新订单付款状态。
- 支付平台将顾客重定向到 return URL。
- 返回页面重新读取订单状态并展示结果。
这里需要区分两个地址:
notifyUrl、callbackUrl或webhookUrl:供支付平台服务器调用。returnUrl:供顾客浏览器在支付后返回。
不同支付平台使用的字段名可能不同,应以相应的接口文档为准。
在 ProcessCheckout 中重定向
如果使用 Hosted Payment Page,顾客需要离开商店并前往支付平台,那么在 ProcessCheckout 阶段准备跳转是合理的。
但在远程支付流程完成前,不应将订单标记为已支付。此时订单最多只能进入类似“等待付款”的状态。
下面是一个结构示例。具体的方法签名需要根据当前 Hotcakes 版本和自定义支付插件接口进行调整:
public ActionResult ProcessCheckout()
{
var order = LoadCurrentOrder();
var returnUrl = Url.Action(
"PaymentReturn",
"Checkout",
new { orderId = order.bvin },
Request.Url.Scheme
);
var notifyUrl = Url.Action(
"PaymentNotification",
"PaymentCallback",
null,
Request.Url.Scheme
);
var paymentReference = CreatePaymentReference(order);
SavePaymentReference(order.bvin, paymentReference);
var request = new PaymentRequest
{
Reference = paymentReference,
Amount = order.TotalGrand,
Currency = order.StoreId.ToString(), // 仅为结构示例,应替换为真实币种
ReturnUrl = returnUrl,
NotifyUrl = notifyUrl
};
var providerUrl = BuildProviderRedirectUrl(request);
return Redirect(providerUrl);
}
币种、金额格式和签名字段必须按照支付平台协议填写,不能直接使用示例中的占位表达式。
如果 Hotcakes 的 ProcessCheckout 不直接返回 MVC ActionResult,可以让它返回支付平台地址或设置重定向结果,再由 Checkout Controller 执行 Redirect(...)。具体实现取决于当前版本提供的支付插件接口。
接收 server-to-server 通知
通知处理最好放在单独的 Controller 或 Web API endpoint 中,不要将支付确认的核心逻辑写进顾客返回时调用的 Checkout action。
示例:
public class PaymentCallbackController : Controller
{
[HttpPost]
[AllowAnonymous]
public ActionResult PaymentNotification()
{
var payload = ReadRequestBody(Request);
var signature = Request.Headers["X-Payment-Signature"];
if (!VerifySignature(payload, signature))
{
return new HttpStatusCodeResult(400, "Invalid signature");
}
var notification = ParseNotification(payload);
var order = FindOrderByPaymentReference(notification.Reference);
if (order == null)
{
return HttpNotFound();
}
if (!AmountsMatch(order, notification))
{
return new HttpStatusCodeResult(400, "Amount mismatch");
}
if (notification.Status == "paid")
{
if (!OrderAlreadyPaid(order))
{
RecordSuccessfulPayment(order, notification);
MarkOrderAsPaid(order);
}
}
else if (notification.Status == "failed")
{
RecordFailedPayment(order, notification);
}
return Content("OK");
}
}
Content("OK") 仅用于说明结构。有些支付平台要求返回指定文本、JSON 内容或 HTTP 状态码,实际响应必须严格遵循其通知协议。
通知 endpoint 通常需要允许匿名访问,因为支付平台不会携带 DNN 用户的登录信息。不过,“允许匿名访问”并不表示可以省略请求验证,至少应检查:
- 通知签名或消息摘要。
- 商户账号是否匹配。
- 订单或支付引用是否存在。
- 金额和币种是否与本地订单一致。
- 支付状态是否属于允许处理的状态。
- 支付平台交易号是否已经处理。
- 时间戳或 nonce 是否符合平台的防重放要求。
如果支付平台支持通过 API 主动查询交易状态,可以在收到通知后再查询一次,并用查询结果确认通知内容。
顾客返回页面的职责
顾客返回时,不能仅凭 query string 中的 status=success 将订单改为已支付。浏览器参数可能被修改,顾客也可能直接关闭页面,导致 return URL 根本没有被访问。
返回 action 应以查询和展示为主:
[HttpGet]
[AllowAnonymous]
public ActionResult PaymentReturn(string orderId)
{
var order = LoadOrder(orderId);
if (order == null)
{
return HttpNotFound();
}
if (!CanCurrentVisitorView(order))
{
return new HttpStatusCodeResult(403);
}
var model = new PaymentReturnViewModel
{
OrderNumber = order.OrderNumber,
IsPaid = OrderAlreadyPaid(order),
StatusMessage = OrderAlreadyPaid(order)
? "付款已确认。"
: "付款结果仍在确认,请稍后刷新。"
};
return View(model);
}
在实际项目中,不宜在公开 URL 中直接使用容易枚举的内部订单标识。可以改用一次性 token、随机支付引用,或者同时校验当前登录用户和订单归属。
server-to-server 通知和浏览器跳转是两个独立请求,无法保证两者的到达顺序。顾客可能先返回,而服务器通知稍后才到,因此返回页面需要支持显示“正在确认付款”。
正确构造绝对 return URL
如果代码运行在 MVC Controller 中,优先使用 Url.Action:
var returnUrl = Url.Action(
"PaymentReturn",
"Checkout",
new { orderId = order.bvin },
Request.Url.Scheme
);
指定 protocol 后,MVC 通常会生成绝对 URL,例如:
https://shop.example.com/checkout/payment-return?orderId=...
通知地址也可以用相同方式生成:
var notifyUrl = Url.Action(
"PaymentNotification",
"PaymentCallback",
null,
Request.Url.Scheme
);
如果当前重载只能生成相对路径,可以基于当前请求创建绝对地址:
var relativeUrl = Url.Action(
"PaymentReturn",
"Checkout",
new { orderId = order.bvin }
);
var returnUrl = new Uri(Request.Url, relativeUrl).AbsoluteUri;
对于 DNN/Hotcakes,还需要确认生成结果是否包含正确的 portal alias、语言路径和虚拟目录。如果网站部署在反向代理之后,也要确认 ASP.NET 能识别原始 HTTPS scheme,否则可能生成错误的 http:// 地址。
生产环境还可以将商店的公开基准地址保存为受控配置,再与路由生成的相对路径组合:
var publicBaseUrl = new Uri(configuredStoreUrl);
var relativeUrl = Url.Action(
"PaymentReturn",
"Checkout",
new { orderId = order.bvin }
);
var returnUrl = new Uri(publicBaseUrl, relativeUrl).AbsoluteUri;
基准地址必须来自可信配置,不能直接采用未经校验的 Host header,否则可能引发 Host Header Injection 风险。
Hotcakes 集成时的注意事项
不要重复记账
如果支付平台请求超时,或没有收到预期响应,可能会多次发送同一条通知。应使用支付平台交易号或事件 ID 进行幂等判断:
if (PaymentTransactionExists(notification.TransactionId))
{
return Content("OK");
}
数据库更新最好放在事务中,防止两个并发通知同时创建付款记录。
不要只修改订单状态文字
支付成功通常不仅会改变订单状态,还涉及付款交易记录、金额、支付方式、平台交易号和 Hotcakes 的后续工作流。应尽量调用当前 Hotcakes 版本提供的订单和支付业务 API,而不是直接修改数据库表。
不同 Hotcakes 版本的支付事务 API 可能并不相同,这部分应以项目实际引用的程序集和示例支付方式为准。
不要依赖 Session
server-to-server 请求不会携带顾客浏览器中的 Session 或认证 Cookie。回调必须能通过支付引用、订单标识或商户订单号独立找到订单。
避免把回调放进标准 Checkout action
直接修改 Hotcakes 自带的 CheckoutController 可能可以工作,但后续升级容易发生冲突。更稳妥的做法是创建独立 Controller,并通过模块或应用的路由注册公开 endpoint。
如果当前 DNN/Hotcakes 安装限制了自定义 MVC Controller 的公开路由,可以改用 DNN Web API endpoint。无论选择哪种方式,都要确认支付平台可以从公网直接访问该 URL,而且请求不会被登录跳转、CSRF 页面、URL 重写或防火墙规则拦截。
正确处理 CSRF 与签名
浏览器表单通常使用 anti-forgery token,但第三方支付平台无法获得这个 token。因此,通知 action 一般不能要求普通的 MVC anti-forgery token,而应使用支付平台规定的签名验证机制。不能为了让请求通过而关闭所有验证。
建议的最终结构
支付集成可以拆分为三个组件:
- 支付方法:创建支付请求并提供跳转地址。
- 通知 endpoint:验证服务器通知,并更新 Hotcakes 订单和支付交易。
- 返回页面:读取订单的最终状态,并向顾客展示结果。
采用这种结构后,即使顾客没有返回商店、支付平台重复发送通知,或者通知与浏览器跳转的到达顺序发生变化,订单仍然可以得到可靠处理。
备注:内容仅供参考。
