PHP Ecommerce API 如何处理支付流程?
结论
这类支付无法由 Ecommerce API 通过一次请求完成。API 负责创建支付、保存支付状态、验证网关通知并更新订单;用户则通过浏览器或 App WebView 跳转到 CCAvenue、PayU 等支付页面,在银行页面完成身份验证。
完整流程可以分为以下几个阶段:
- 客户端通过 Ecommerce API 创建订单。
- API 创建支付记录,生成支付网关要求的签名或加密参数。
- 客户端跳转到支付网关,或向网关提交表单。
- 用户在支付网关和银行页面完成验证。
- 支付网关将用户重定向到商城的返回地址。
- 支付网关发送服务端通知,或者商城主动查询支付结果。
- API 验证结果,并以幂等方式更新支付和订单状态。
浏览器重定向只能用来恢复页面流程,不能单独作为支付成功的依据。
推荐的数据模型
订单和支付应当分别保存,因为同一个订单可能有多次支付尝试。
orders
- id
- order_no
- user_id
- amount
- currency
- status
- created_at
payments
- id
- payment_no
- order_id
- gateway
- gateway_transaction_id
- amount
- currency
- status
- request_payload
- response_payload
- created_at
- updated_at
订单状态可以设计为:
pending_payment
paid
processing
completed
cancelled
refunded
支付状态可以设计为:
created
pending
authorized
paid
failed
cancelled
refunded
unknown
不要把支付状态和订单状态简单合并。一次支付失败后,用户可能换一种支付方式再次尝试,而订单本身仍然有效。
完整支付流程
1. 创建订单
客户端先调用订单接口:
POST /api/orders
Content-Type: application/json
Authorization: Bearer <token>
{
"items": [
{
"product_id": 101,
"quantity": 2
}
],
"currency": "INR"
}
服务端必须根据数据库中的商品价格重新计算金额,不能信任客户端提交的单价或总金额。
返回示例:
{
"order_id": 12345,
"order_no": "ORD-20260913-0001",
"amount": "1499.00",
"currency": "INR",
"status": "pending_payment"
}
金额最好使用最小货币单位的整数保存。例如,用 149900 表示 1499.00 INR,以免出现浮点数精度问题。
2. 创建支付会话
客户端选择支付渠道后,调用支付初始化接口:
POST /api/orders/12345/payments
Content-Type: application/json
Authorization: Bearer <token>
{
"gateway": "ccavenue"
}
服务端需要完成以下操作:
- 确认订单属于当前用户。
- 确认订单仍然可以支付。
- 再次核对金额、币种和库存。
- 创建唯一的
payment_no。 - 创建支付记录,并将状态保存为
created。 - 生成网关要求的参数、签名或密文。
- 返回支付跳转信息。
有些网关接受普通跳转 URL,有些则要求浏览器通过 POST 表单提交,因此 API 应支持这两种返回形式。
{
"payment_id": 9876,
"payment_no": "PAY-20260913-0001",
"method": "POST",
"gateway_url": "https://gateway.example/payment",
"fields": {
"merchant_id": "merchant-id",
"order_id": "PAY-20260913-0001",
"enc_request": "<encrypted-value>",
"access_code": "<access-code>"
}
}
这些字段仅用于说明返回结构。实际字段名、加密算法和网关地址必须以当前接入网关的官方文档为准。
3. 浏览器提交到支付网关
Web 前端收到响应后,可以动态创建表单:
function redirectToGateway(payment) {
const form = document.createElement('form');
form.method = payment.method;
form.action = payment.gateway_url;
for (const [name, value] of Object.entries(payment.fields)) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
银行卡密码、OTP、3-D Secure 等身份验证信息必须直接输入银行或支付网关页面,不能经过商城 API。
移动 App 可以使用系统浏览器、支付网关 SDK,或者符合网关要求的 WebView。支付完成后,再通过 Universal Link、App Link 或自定义 URL Scheme 返回 App。
4. 处理浏览器返回
支付完成或取消后,网关通常会将用户重定向到预先配置的地址,例如:
https://shop.example.com/payment/return
这个页面主要用于恢复用户体验,例如显示”正在确认支付结果”,而不是直接宣布支付成功。
前端可以通过 payment_no 查询服务端状态:
GET /api/payments/PAY-20260913-0001
Authorization: Bearer <token>
{
"payment_no": "PAY-20260913-0001",
"status": "pending"
}
如果服务端还没有收到可信的支付结果,页面可以在短时间内轮询,但要设置合理的间隔和超时时间,不能无限高频请求。
服务端通知和支付验证
如果支付网关提供 webhook、server callback 或 transaction status API,应优先通过这些机制确认支付结果。
处理通知时,至少要检查:
- 签名、MAC 或加密响应是否合法。
- 商户号是否属于当前系统。
- 商户支付单号是否存在。
- 通知中的金额和币种是否与本地记录一致。
- 网关交易号是否有效,以及是否已经绑定到其他支付。
- 支付状态是否来自可信字段。
- 必要时,是否已通过网关查询接口进行二次确认。
下面是一个简化的 PHP 处理结构:
<?php
final class PaymentWebhookController
{
public function handle(
PaymentGateway $gateway,
PaymentRepository $payments,
OrderRepository $orders,
array $payload
): void {
$notification = $gateway->parseAndVerifyNotification($payload);
if (!$notification->isValid()) {
http_response_code(400);
echo 'invalid notification';
return;
}
$payment = $payments->findByPaymentNo(
$notification->getPaymentNo()
);
if ($payment === null) {
http_response_code(404);
echo 'payment not found';
return;
}
if (
$payment->getAmountMinor() !== $notification->getAmountMinor()
|| $payment->getCurrency() !== $notification->getCurrency()
) {
http_response_code(400);
echo 'amount or currency mismatch';
return;
}
$payments->transaction(function () use (
$payments,
$orders,
$payment,
$notification
): void {
$lockedPayment = $payments->lockById($payment->getId());
// 重复通知直接返回成功,避免重复发货或重复记账。
if ($lockedPayment->isPaid()) {
return;
}
if ($notification->isSuccessful()) {
$payments->markAsPaid(
$lockedPayment->getId(),
$notification->getGatewayTransactionId(),
$notification->getRawPayload()
);
$orders->markAsPaid($lockedPayment->getOrderId());
return;
}
$payments->markAsFailed(
$lockedPayment->getId(),
$notification->getRawPayload()
);
});
http_response_code(200);
echo 'OK';
}
}
parseAndVerifyNotification() 必须按照相应网关的规定完成验签或解密,不能只读取类似 status=success 的字段。
使用网关适配器隔离差异
CCAvenue、PayU 等网关使用的字段、签名方式和通知格式各不相同,可以先定义一个统一接口:
<?php
interface PaymentGateway
{
public function createPayment(PaymentRequest $request): RedirectData;
public function parseAndVerifyNotification(array $payload): PaymentNotification;
public function queryPayment(string $gatewayTransactionId): PaymentResult;
public function refund(RefundRequest $request): RefundResult;
}
再为不同网关分别实现:
final class CcavenueGateway implements PaymentGateway
{
// CCAvenue 的参数生成、加密、通知解密和交易查询
}
final class PayuGateway implements PaymentGateway
{
// PayU 的签名、通知验证和交易查询
}
业务层只依赖 PaymentGateway 接口,不直接处理各家网关的具体字段。以后增加新的支付方式时,订单的核心逻辑也不需要随之修改。
如果没有收到回调怎么办
用户可能在银行页面关闭浏览器,网络也可能在支付成功后中断,所以”没有跳转回来”不等于支付失败。
可以通过以下机制进行补偿:
- 支付页面定期查询 Ecommerce API。
- 当支付状态不确定时,由 API 调用网关的交易查询接口。
- 使用定时任务扫描长时间处于
pending或unknown状态的支付。 - 根据网关返回的查询结果修正本地状态。
- 每天通过网关结算文件或对账接口核对交易。
状态尚未明确时,应继续保留为 pending 或 unknown,不要立即允许再次扣款。如果允许用户重新支付,需要创建一条新的支付记录,并处理原支付稍后成功的情况。
安全和一致性注意事项
- 商户密钥、加密密钥和 API Secret 只能保存在服务端。
- 所有请求都应使用 HTTPS。
return_url和callback_url应使用固定的白名单地址。- 不要记录银行卡号、CVV、OTP、密码等敏感信息。
- 保存网关原始响应前,应先对数据进行脱敏。
- 每次支付都要使用唯一且不可预测的支付单号。
- webhook 必须允许重复调用,并保证处理过程幂等。
- 支付状态和订单状态应在同一个数据库事务中更新。
- 发货、积分、优惠券核销等操作应由”支付成功事件”触发,并分别进行幂等控制。
- 已经处于
paid状态的支付不能改回failed。 - 用户访问成功页面时,不能直接修改订单状态。
- 超时和失败页面可以允许用户安全重试,但每次尝试都要创建独立的支付记录。
- 上线前,应在支付网关提供的测试环境中验证成功、失败、取消、超时、重复通知和金额不一致等场景。
API 不需要代替用户完成银行身份验证。它负责创建一笔可追踪的支付,引导客户端进入支付网关,并在用户离开商城期间维护订单状态。收到网关提供的可信结果后,API 再以安全、幂等的方式完成订单。
备注:内容仅供参考。