Zend Framework 如何无表单 POST 到外部支付网关?
结论
HTTP 重定向不能携带一组自定义 POST 数据。$this->_redirect() 只能让浏览器跳转到目标地址,不能替浏览器构造新的 POST 请求。
一般有两种处理方式:
- 按照支付网关的要求,输出包含隐藏字段的表单,再用 JavaScript 自动提交。
- 由服务器调用网关接口创建支付会话,取得支付 URL 或 token 后,再将浏览器重定向过去。
浏览器端的隐藏字段仍然可以被修改,这是客户端数据的固有特性。不能依靠 hidden、readonly 或禁用开发者工具来保证安全。订单数据应保存在服务端,并通过请求签名和支付结果回调验证来防止篡改。
方案一:生成并自动提交 POST 表单
如果支付网关要求用户的浏览器直接向指定地址发送 POST 请求,就需要让浏览器提交表单。这个过程可以自动完成,不必让用户手动点击按钮。
下面以 Zend Framework 1 为例。控制器从服务端订单记录中读取数据并生成参数:
public function payAction()
{
$this->_helper->layout()->disableLayout();
$order = $this->getOrderFromDatabase();
if (!$order || $order->status !== 'pending') {
throw new Zend_Controller_Action_Exception('Invalid order', 400);
}
$fields = array(
'merchant_id' => 'merchant-123',
'order_id' => (string) $order->id,
'amount' => number_format($order->amount, 2, '.', ''),
'currency' => 'CNY',
'return_url' => 'https://example.com/payment/return',
'notify_url' => 'https://example.com/payment/notify',
'nonce' => bin2hex(random_bytes(16)),
);
// 签名算法和字段顺序必须严格遵循支付网关文档。
$canonical = implode('|', array(
$fields['merchant_id'],
$fields['order_id'],
$fields['amount'],
$fields['currency'],
$fields['nonce'],
));
$fields['signature'] = hash_hmac(
'sha256',
$canonical,
$this->getInvokeArg('bootstrap')
->getOption('payment')['signingSecret']
);
$this->view->gatewayUrl = 'https://payment.example.com/checkout';
$this->view->fields = $fields;
}
对应的视图脚本负责生成并自动提交表单:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>正在跳转到支付页面</title>
</head>
<body>
<p>正在连接支付网关,请稍候……</p>
<form id="payment-form"
method="post"
action="<?= $this->escape($this->gatewayUrl) ?>">
<?php foreach ($this->fields as $name => $value): ?>
<input
type="hidden"
name="<?= $this->escape($name) ?>"
value="<?= $this->escape($value) ?>"
>
<?php endforeach; ?>
<noscript>
<button type="submit">继续支付</button>
</noscript>
</form>
<script>
document.getElementById('payment-form').submit();
</script>
</body>
</html>
这里使用 hidden 只是为了不在页面上显示字段,并不能提供安全保护。用户仍然可以修改这些字段,但只要支付网关验证签名,经过篡改的关键参数就无法通过校验。
示例中的签名字段、编码方式、参数顺序和摘要算法不能直接套用,具体实现必须遵循支付网关的协议。有些网关使用证书签名、加密报文或专用 SDK,此时不能自行改用 hash_hmac()。
方案二:服务器创建支付会话后再重定向
如果网关提供“创建支付会话”接口,通常更适合由服务器直接提交订单数据。网关返回一次性支付地址后,再通过普通重定向把用户送到该地址。
在 Zend Framework 1 中,可以使用 Zend_Http_Client 发起服务端请求:
$client = new Zend_Http_Client('https://payment.example.com/api/sessions');
$client->setMethod(Zend_Http_Client::POST);
$client->setHeaders('Accept', 'application/json');
$client->setParameterPost(array(
'merchant_id' => 'merchant-123',
'order_id' => (string) $order->id,
'amount' => number_format($order->amount, 2, '.', ''),
'currency' => 'CNY',
));
$response = $client->request();
if (!$response->isSuccessful()) {
throw new RuntimeException('Unable to create payment session');
}
$data = Zend_Json::decode($response->getBody());
if (empty($data['payment_url'])) {
throw new RuntimeException('Payment URL was not returned');
}
$this->_redirect($data['payment_url']);
采用这种方式时,订单金额等数据不会经过用户浏览器。Zend_Http_Client 发出的 POST 请求发生在你的服务器和支付网关之间,不会自动把用户浏览器带到网关页面。创建会话成功后,还需要使用接口返回的支付 URL 完成第二次跳转。
请求字段、认证头、响应格式和错误处理方式都应以网关文档为准。
为什么不能用重定向直接 POST
常见的 302 或 303 响应只能向浏览器提供一个新的 URL,浏览器随后通常会用 GET 请求访问该地址。HTTP 响应没有标准机制,可以在这类重定向中附带一组新构造的 POST 表单字段。
307 和 308 可以要求浏览器保留原请求的方法和请求体,但只能重复原来的请求,不能把当前请求安全地转换成任意构造的支付 POST 数据,因此也不适合这个场景。
安全注意事项
- 金额、商品、币种和订单归属必须从服务端订单记录中读取,不能信任浏览器提交的值。
- 签名密钥只能保存在服务器端,不能写进 HTML 或 JavaScript。
- 签名应覆盖所有会影响交易结果的字段,通常还应包含 nonce、时间戳或一次性 token。
- 收到网关回调后,必须验证签名、订单号、金额、币种和交易状态,并做好幂等处理。
- 用户从支付页面返回
return_url,并不代表支付已经成功。最终结果应以验证通过的服务端通知或主动查询结果为准。 - 不要通过自建隐藏表单传递信用卡号、CVV 等敏感数据,除非系统明确满足相应的合规要求。通常应由支付网关托管这些信息的输入。
- 支付网关地址应来自可信的服务端配置,不能直接采用请求参数提供的 URL,否则可能造成开放重定向或数据泄露。
所以,浏览器通常无法在完全不使用 form 的情况下,携带自定义 POST 数据跳转到另一个地址。如果网关要求浏览器 POST,应使用自动提交表单;如果网关支持支付会话 API,则优先由服务器创建会话,再将浏览器重定向到返回的支付地址。
备注:内容仅供参考。
