架構概覽
- Request:商戶 → 99Bill (用戶付款頁)
- Background Return (bgUrl):99Bill → 商戶後台 (Server-to-Server,異步通知)
- Page Return (pageUrl):99Bill → 商戶前台 (用戶瀏覽器重導向,同步通知)
核心參數說明
| 參數 | 說明 | 必要性 |
| merchantAcctId | 商戶號 (11位+01 或 16位) | 必填 |
| bgUrl | 後台通知地址 (絕對 URL) | 必填 |
| orderId | 商戶訂單號 (唯一) | 必填 |
| orderAmount | 訂單金額 (分為單位) | 必填 |
| orderTime | 訂單時間 (yyyyMMddHHmmss) | 必填 |
| signType | 簽名類型 (4=PKI) | 必填 |
| signMsg | 簽名字串 (PKI 簽名) | 必填 |
| payType | 支付方式 (00=全部) | 必填 |
| inputCharset | 編碼 (1=UTF-8) | 必填 |
| version | 網關版本 (v2.0) | 必填 |
| language | 語言 (1=中文) | 必填 |
| ext1, ext2 | 擴展欄位 (原樣返回) | 選填 |
Model: BillRequest (發送請求建構)
<?php
class Application_Model_BillRequest
{
public $merchantAcctId = '1001011111101';
public $bgUrl = 'https://yourdomain.com/payment/99bill/notify';
public $orderId;
public $orderAmount;
public $orderTime;
public $payerName = '';
public $payerContactType = '';
public $payerContact = '';
public $productName = 'TOLPC';
public $productNum = '1';
public $productId;
public $productDesc = '';
public $payType = '00';
public $inputCharset = '1';
public $version = 'v2.0';
public $language = '1';
public $signType = '4';
public $pageUrl = '';
public $ext1;
public $ext2;
public $bankId = '';
public $redoFlag = '';
public $pid = '';
public $signMsg;
public function __construct(array $orderData)
{
// 映射訂單資料
$this->orderId = 'TOLPC' . sprintf('%09d', $orderData['orderId']);
$this->orderAmount = $orderData['orderAmount']; // 分
$this->orderTime = date('YmdHis', $orderData['orderTime']);
$this->productId = $orderData['ets_license'];
$this->ext1 = $orderData['orderId']; // 回傳辨識用
$this->ext2 = $orderData['orderTime'];
// 產生簽名
$this->signMsg = $this->generateSignMsg();
}
private function generateSignMsg(): string
{
// 1. 收集非空參數
$params = [];
foreach ($this as $key => $value) {
if ($key === 'signMsg') continue;
if ($value !== '' && $value !== null) {
$params[$key] = $value;
}
}
// 2. 依鍵名 ASCII 排序 (99Bill 要求)
ksort($params);
// 3. 組裝查詢字串
$queryString = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
$queryString = urldecode($queryString); // 99Bill 要求不編碼
// 4. PKI 簽名 (私鑰 SHA1 簽名 + Base64)
$privKey = openssl_get_privatekey(file_get_contents('99bill-rsa.pem'));
openssl_sign($queryString, $signature, $privKey, OPENSSL_ALGO_SHA1);
openssl_free_key($privKey);
return base64_encode($signature);
}
public function toArray(): array
{
$arr = [];
foreach ($this as $key => $value) {
if ($value !== '' && $value !== null) {
$arr[$key] = $value;
}
}
return $arr;
}
}
Controller: 發起支付
<?php
class PaymentController extends Zend_Controller_Action
{
public function payAction()
{
// 1. 取得訂單資料 (從 DB、Session 等)
$orderData = [
'orderId' => 100000125,
'orderAmount' => 31100, // 分
'orderTime' => time(),
'ets_license' => '1',
];
// 2. 建構請求
$billRequest = new Application_Model_BillRequest($orderData);
// 3. 渲染自動提交表單
$this->view->billRequest = $billRequest->toArray();
}
}
View: 自動提交表單 (payment/pay.phtml)
<form name="kqPay" action="https://www.99bill.com/gateway/recvMerchantInfoAction.htm" method="post">
<?php foreach ($this->billRequest as $key => $val): ?>
<input type="hidden" name="<?= $key ?>" value="<?= htmlspecialchars($val) ?>" />
<?php endforeach; ?>
<noscript><input type="submit" value="前往付款" /></noscript>
</form>
<script>document.kqPay.submit();</script>
Model: BillResponse (接收通知驗證)
<?php
class Application_Model_BillResponse
{
public $merchantAcctId, $version, $language, $signType, $payType, $bankId;
public $orderId, $orderTime, $orderAmount, $dealId, $bankDealId, $dealTime;
public $payAmount, $fee, $ext1, $ext2, $payResult, $errCode, $signMsg;
public function __construct(array $data)
{
foreach ($data as $key => $value) {
if (property_exists($this, $key)) {
$this->$key = $value;
}
}
}
public function verifySign(): bool
{
// 1. 收集非空參數 (不含 signMsg)
$params = [];
foreach ($this as $key => $value) {
if ($key === 'signMsg') continue;
if ($value !== '' && $value !== null) {
$params[$key] = $value;
}
}
ksort($params);
$queryString = urldecode(http_build_query($params, '', '&', PHP_QUERY_RFC3986));
// 2. 公鑰驗證
$pubKey = openssl_get_publickey(file_get_contents('99bill-rsa.cer'));
$result = openssl_verify($queryString, base64_decode($this->signMsg), $pubKey);
openssl_free_key($pubKey);
return $result === 1;
}
public function isSuccess(): bool
{
return $this->payResult === '10'; // 10=成功, 11=失敗
}
public function getOrderId(): string
{
return str_replace('TOLPC', '', $this->orderId);
}
}
Controller: 接收異步通知 (bgUrl)
<?php
class PaymentController extends Zend_Controller_Action
{
public function notifyAction()
{
// 1. 接收原始 POST
$response = new Application_Model_BillResponse($_POST);
// 2. 驗證簽名
if (!$response->verifySign()) {
error_log('99Bill 簽名驗證失敗: ' . json_encode($_POST));
echo '0https://yourdomain.com/payment/fail';
return;
}
// 3. 判斷支付結果
if ($response->isSuccess()) {
$orderId = $response->getOrderId();
// 4. 業務處理 (更新訂單狀態、發貨等) - 注意冪等性
$this->processPaymentSuccess($orderId, $response->toArray());
// 5. 回應 99Bill (XML 格式)
echo '1https://yourdomain.com/payment/success';
} else {
// 支付失敗處理
$this->processPaymentFail($response->getOrderId(), $response->errCode);
echo '1https://yourdomain.com/payment/fail';
}
}
private function processPaymentSuccess(string $orderId, array $data)
{
// 使用資料庫交易 + 鎖防止重複處理
// 檢查訂單狀態、金額核對、更新狀態、記錄支付流水
}
}
Controller: 同步跳轉 (pageUrl)
<?php
public function returnAction()
{
$response = new Application_Model_BillResponse($_GET); // pageUrl 為 GET
if ($response->verifySign() && $response->isSuccess()) {
$this->view->message = '支付成功,訂單處理中...';
$this->render('success');
} else {
$this->view->message = '支付失敗或驗證失敗';
$this->render('fail');
}
}
關鍵安全與可靠性提醒
- 🔐 簽名驗證不可省略:防止偽造通知、金額竄改
- 🔐 私鑰 (99bill-rsa.pem) 嚴禁洩露:放在 Web 根目錄外、限制檔案權限
- 🔁 異步通知冪等性:同一訂單可能收到多次通知,需檢查狀態避免重複發貨
- 💰 金額核對:驗證
payAmount === orderAmount,防止惡意修改金額
- 🕐 時間同步:伺服器時間需與標準時間同步 (NTP),避免簽名時間誤差
- 📝 完整日誌:記錄所有通知原始資料、驗證結果、業務處理結果
- 🧪 測試環境:使用 99Bill 測試環境網關
https://sandbox.99bill.com/... 及測試商戶號
憑證檔案說明
99bill-rsa.pem:商戶私鑰 (簽名用),對應公鑰在 99Bill 端
99bill-rsa.cer:99Bill 公鑰 (驗證用),對應私鑰在 99Bill 端
- 兩者非同一組金鑰對,各自保管自己的私鑰,交換公鑰