這一篇用藍新金流(NewebPay)當例子,它是台灣接案常遇到的金流商之一,它的流程涵蓋了金流串接的所有典型環節:加密、表單導向、背景通知、驗章,很適合拿來作為範例,選它單純作為教學用途,絕對沒有業配XD。
這篇先做的是傳統結帳頁能用的版本,不管是短代碼或是區塊結帳,都還是要先把後端 PHP 搞定,而且現在還有非常大量的 WooCommerce 網站還沒有轉換成區塊結帳,或是因為各種原因不能轉換,因此還是要先從傳統結帳的金流做起。
你的網站 藍新 顧客
│ │ │
├─ 加密訂單資料 ─────────────────────────────────→ 看到刷卡頁
│ (AES-256-CBC + SHA256 簽章) │
│ │ │
│ ├ ←─ 交易完成 ────────────┤
│ │ │
│←─ NotifyURL(背景,可信)┤ │
│ └─ ReturnURL(前景)─────→ 導回你的網站
顧客付款完成後藍新會送兩次結果,一次打你的伺服器(NotifyURL),另一次透過使用者瀏覽器回傳(ReturnURL),上一篇提過訂單狀態只在 NotifyURL 處理。
串藍新的第一關不是 WooCommerce,是加密。這一段是純 PHP,跟 WooCommerce 完全無關,但它是整篇最容易卡住的地方 — 因為官方文件一口氣丟出 AES256、SHA256、CheckCode、CheckValue 四個名詞,看起來都是「加密」,實際上做的事完全不同。
先把角色分清楚:
| 名稱 | 它在做什麼 | 白話 |
|---|---|---|
| AES-256-CBC | 加密與解密 | 把訂單資料鎖進保險箱,藍新有同一把鑰匙可以打開 |
| SHA256(TradeSha) | 產生簽章 | 在保險箱外面貼一張封條,被動過就看得出來 |
| CheckCode | 驗證藍新回傳的結果 | 藍新貼給你的封條,你自己重算一次比對 |
| CheckValue | 主動查詢時的簽章 | 你去問藍新「這筆訂單怎麼了」時要附上的封條 |
加密是「別人看不到內容」,簽章是「內容有沒有被改過」,這是兩件事。加密防側錄,簽章防竄改 — 只加密不簽章,中間人雖然看不懂內容但可以亂改;只簽章不加密,內容誰都看得到。藍新兩個都要。
AES-256-CBC
AES 是目前最通用的對稱式加密演算法。「對稱式」的意思是加密跟解密用同一把鑰匙,所以你跟藍新事先各拿一份一樣的,這就是後台那組 Hash Key。「256」是鑰匙長度 256 位元,換算下來剛好 32 個字元。「CBC」是加密模式,它把資料切成一塊一塊處理,而且每一塊都會跟前一塊混在一起算,所以同樣一段文字出現在不同位置,加密出來的結果也不一樣。
Hash IV(初始向量)
CBC 每一塊都跟前一塊混,那第一塊要跟誰混?答案就是 IV。它是一段固定長度的起始值,CBC 的區塊大小是 128 位元,所以 IV 是 16 個字元。IV 不像鑰匙那樣需要保密,但長度錯了會直接噴錯。
串接前先量一下長度,這個習慣可以省很多時間:
echo strlen( $key ); // 必須是 32.
echo strlen( $iv ); // 必須是 16.
不是這兩個數字,八成是從後台複製時漏字或多了空白,這比事後去解讀 openssl 的錯誤訊息快得多。
PKCS7 padding(填充)
CBC 一次處理 16 個位元組,資料長度不是 16 的倍數就得補到剛好。PKCS7 的補法很聰明:缺幾個就補幾個「那個數字」 — 缺 5 個就補 5 個位元組、每個的值都是 5。解密的人看最後一個位元組是 5,就知道要把最後 5 個位元組砍掉。等一下的踩坑就出在這裡。
十六進位(bin2hex)
加密出來的是二進位資料,裡面什麼奇怪的位元組都有,不能直接塞進 HTML 表單。bin2hex() 把每個位元組換成兩個 0-9a-f 的字元,變成純文字就能安全傳輸,代價是長度變兩倍。收到之後用 hex2bin() 換回來。
SHA256(雜湊,藍新文件裡叫「壓碼」)
把任意長度的內容算成一段固定 64 字的指紋。它的特性是單向:有內容可以算出指紋,但拿指紋回推不出內容,而且內容只要動一個字,指紋就整個不一樣。所以拿它來當封條 — 藍新收到你的資料後用同樣規則重算一次指紋,跟你附上的那個比對,一樣就代表路上沒被改過。
官方文件把流程拆成 Step 0 到 Step 4,翻成白話是:
http_build_query() 組成 MerchantID=MS127874575&Amt=30&... 這種形式bin2hex() 轉十六進位,這個結果叫 TradeInfo
最後把 MerchantID(明碼)、TradeInfo(密文)、TradeSha(封條)三個欄位用 HTML form POST 給藍新,顧客就會看到刷卡頁。
class NewebPay_Crypto {
private $key;
private $iv;
public function __construct( $key, $iv ) {
$this->key = $key;
$this->iv = $iv;
}
/**
* AES-256-CBC 加密,回傳十六進位字串,也就是藍新說的 TradeInfo.
*/
public function encrypt( $data ) {
// openssl_encrypt 預設就是 PKCS7 填充,這裡不用自己補.
$encrypted = openssl_encrypt( $data, 'AES-256-CBC', $this->key, OPENSSL_RAW_DATA, $this->iv );
return bin2hex( $encrypted );
}
/**
* 解密藍新回傳的 TradeInfo,任何一步失敗都回傳 false.
*/
public function decrypt( $encrypted_hex ) {
$raw = @hex2bin( $encrypted_hex );
if ( false === $raw ) {
return false; // 收到的根本不是合法的十六進位字串.
}
// 預設行為就是 PKCS7,openssl 會自己去掉填充,填充不合法時回傳 false.
return openssl_decrypt( $raw, 'AES-256-CBC', $this->key, OPENSSL_RAW_DATA, $this->iv );
}
/**
* 產生 TradeSha 簽章:HashKey 在前、HashIV 在後.
*/
public function trade_sha( $trade_info ) {
return strtoupper( hash( 'sha256', "HashKey={$this->key}&{$trade_info}&HashIV={$this->iv}" ) );
}
/**
* 驗章:用同樣規則重算一次比對.
*/
public function verify( $trade_info, $trade_sha ) {
return hash_equals( $this->trade_sha( $trade_info ), (string) $trade_sha );
}
/**
* 產生 CheckCode,用來核對藍新回傳的結果,注意是 HashIV 在前、HashKey 在後.
*
* @param array $fields Amt、MerchantID、MerchantOrderNo、TradeNo 四個欄位.
*/
public function check_code( array $fields ) {
ksort( $fields ); // 依欄位名稱 A 到 Z 排序.
$str = http_build_query( $fields );
return strtoupper( hash( 'sha256', "HashIV={$this->iv}&{$str}&HashKey={$this->key}" ) );
}
/**
* 產生 CheckValue,發動單筆交易查詢時用,前綴是 IV 與 Key 而不是 HashIV 與 HashKey.
*
* @param array $fields Amt、MerchantID、MerchantOrderNo 三個欄位.
*/
public function check_value( array $fields ) {
ksort( $fields );
$str = http_build_query( $fields );
return strtoupper( hash( 'sha256', "IV={$this->iv}&{$str}&Key={$this->key}" ) );
}
}
這種東西不能只看「有沒有噴錯」,因為算錯也不會噴錯,只會在送出去的時候被藍新退件,然後你完全不知道是哪一步錯的。最快的驗證方式是拿官方文件那組測試金鑰跑一次,看結果對不對得上文件印出來的答案:
$key = 'Fs5cX1TGqYM2PpdbE14a9H83YQSQF5jn';
$iv = 'C6AcmfqJILwgnhIP';
$c = new NewebPay_Crypto( $key, $iv );
$data1 = http_build_query(
array(
'MerchantID' => 'MS127874575',
'RespondType' => 'String',
'TimeStamp' => 1695795410,
'Version' => '2.0',
'MerchantOrderNo' => 'Vanespl_ec_1695795410',
'Amt' => '30',
'ItemDesc' => 'test',
'NotifyURL' => 'https://webhook.site/d4db5ad1-2278-466a-9d66-78585c0dbadb',
)
);
$edata1 = $c->encrypt( $data1 );
echo substr( $edata1, 0, 32 ), "\n"; // f79eac33c4f3245d58f17b544c5d38b0
echo $c->trade_sha( $edata1 ), "\n"; // 84E4D9F96537E029F8450BE1E759080F9AF6995921B7F6F9AAFDDD2C36E7B287
echo ( $c->decrypt( $edata1 ) === $data1 ) ? "round-trip OK\n" : "解密對不上\n";
一、不要照抄官方範例的 OPENSSL_ZERO_PADDING。
官方文件的解密範例是「加上 OPENSSL_ZERO_PADDING 關掉自動去填充,再自己寫一個 strippadding() 把尾巴砍掉」,這段是歷史包袱,現在照抄反而會有問題。

PHP openssl_decrypt() 的預設行為就是 PKCS7,跟藍新現行規格(文件寫明「AES-256-CBC 使用 PKCS7 填充」)完全一致。拿官方那組測試金鑰實測不加任何旗標直接解,回來的就是乾淨的原文:
$plain = openssl_decrypt( hex2bin( $edata1 ), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv );
var_dump( $plain === $data1 ); // bool(true)
而且關掉自動處理會順便關掉 OpenSSL 的填充驗證,而那層驗證是免費的錯誤偵測,如果金鑰打錯兩種寫法的差別是:
| 金鑰錯誤時 | 回傳 | 後果 |
|---|---|---|
| 預設 | false |
你馬上知道有問題 |
加 OPENSSL_ZERO_PADDING |
一串亂數位元組 | 照樣往下砍尾巴、parse_str() 得到空陣列,然後在別的地方爆炸 |
所以用預設就好,代價只有一個:記得檢查回傳值是不是 false。
二、驗章用 hash_equals() 而不是 ===。
=== 比對字串是「逐字比,遇到不同就馬上回傳」,所以猜對前幾個字的時候,比對會多花那麼一點點時間。攻擊者可以送出大量請求、量測回應時間的細微差異,一個字元一個字元把正確的簽章猜出來,這叫時序攻擊(timing attack)。hash_equals() 不管內容一不一樣都跑完固定的時間,讓這個時間差消失。凡是比對簽章、token、密碼雜湊,一律用它。
AI 產出的程式碼幾乎都寫 ===,這是 review 時要順手改掉的地方。
三、三種雜湊長得很像,但格式通通不一樣。
這是我覺得藍新文件需要特別注意的地方,三個都是「前綴 + 內容 + 後綴,然後 SHA256 轉大寫」,但前綴後綴的名字跟順序都不同:
| 名稱 | 用在哪 | 組合方式 |
|---|---|---|
| TradeSha | 送出交易時附的簽章 | HashKey=金鑰&加密字串&HashIV=向量 |
| CheckCode | 核對藍新回傳的結果 | HashIV=向量&四個欄位&HashKey=金鑰 |
| CheckValue | 主動查詢單筆交易 | IV=向量&三個欄位&Key=金鑰 |
注意兩件事:TradeSha 是金鑰在前,CheckCode 反過來是向量在前;而 CheckValue 的前綴是 IV= 與 Key=,沒有 Hash 這個字。這種差異抄錯了不會有任何錯誤訊息,只會得到一組長度正確但對不起來的雜湊值。
另外 CheckCode 與 CheckValue 的欄位在串接之前要先照欄位名稱 A 到 Z 排序(程式碼裡那個 ksort()),順序錯了結果也會不一樣。
class WC_Gateway_NewebPay extends WC_Payment_Gateway {
public function __construct() {
$this->id = 'newebpay';
$this->method_title = __( '藍新金流', 'my-plugin' );
$this->method_description = __( '透過藍新金流 MPG 收款。', 'my-plugin' );
$this->has_fields = false;
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option( 'title' );
$this->description = $this->get_option( 'description' );
add_action( 'woocommerce_update_options_payment_gateways_' . $this->id, array( $this, 'process_admin_options' ) );
// 背景通知與前景返回,各自一個端點.
add_action( 'woocommerce_api_newebpay_notify', array( $this, 'handle_notify' ) );
add_action( 'woocommerce_api_newebpay_return', array( $this, 'handle_return' ) );
}
public function init_form_fields() {
$this->form_fields = array(
'enabled' => array(
'title' => __( '啟用', 'my-plugin' ),
'type' => 'checkbox',
'label' => __( '啟用藍新金流', 'my-plugin' ),
'default' => 'no',
),
'title' => array(
'title' => __( '結帳頁顯示名稱', 'my-plugin' ),
'type' => 'text',
'default' => __( '信用卡付款', 'my-plugin' ),
),
'merchant_id' => array(
'title' => __( '商店代號', 'my-plugin' ),
'type' => 'text',
),
'hash_key' => array(
'title' => __( 'HashKey', 'my-plugin' ),
'type' => 'password',
),
'hash_iv' => array(
'title' => __( 'HashIV', 'my-plugin' ),
'type' => 'password',
),
'testmode' => array(
'title' => __( '測試模式', 'my-plugin' ),
'type' => 'checkbox',
'default' => 'yes',
),
);
}
}
form_fields 宣告完,後台設定頁就自動生出來了 — 這是 WC_Settings_API 幫你做的。HashKey 與 HashIV 記得用 password 型別,它們是等同密碼的東西。當金流類別完成後要註冊到 WooCommerce payment gateways 裡面:
add_filter( 'woocommerce_payment_gateways', function ( $gateways ) {
$gateways[] = 'WC_Gateway_NewebPay';
return $gateways;
} );
以上完成後就可以在 WooCommerce 付款設定看到相關的選項:

藍新要求用 POST 表單把加密資料送過去,不是 GET 導向。所以 process_payment() 不能直接把使用者導到藍新,要先導到自己站上的一個中繼頁,那頁再自動送出表單:
public function process_payment( $order_id ) {
$order = wc_get_order( $order_id );
// 標記為待付款,並附上一筆記錄.
$order->update_status( 'pending', __( '等待藍新金流付款結果。', 'my-plugin' ) );
return array(
'result' => 'success',
'redirect' => $order->get_checkout_payment_url( true ), // 導到中繼頁.
);
}
get_checkout_payment_url() 是 WooCommerce 內建的「付款頁」,中繼表單掛在這裡:
add_action( 'woocommerce_receipt_newebpay', array( $this, 'render_payment_form' ) );
public function render_payment_form( $order_id ) {
$order = wc_get_order( $order_id );
$crypto = new NewebPay_Crypto( $this->get_option( 'hash_key' ), $this->get_option( 'hash_iv' ) );
$trade_data = array(
'MerchantID' => $this->get_option( 'merchant_id' ),
'RespondType' => 'JSON',
'TimeStamp' => time(),
'Version' => '2.3',
'MerchantOrderNo' => $order->get_order_number(),
'Amt' => (int) $order->get_total(),
'ItemDesc' => sprintf( __( '訂單 #%s', 'my-plugin' ), $order->get_order_number() ),
'Email' => $order->get_billing_email(),
'NotifyURL' => WC()->api_request_url( 'newebpay_notify' ),
'ReturnURL' => WC()->api_request_url( 'newebpay_return' ),
'CREDIT' => 1,
);
$trade_info = $crypto->encrypt( http_build_query( $trade_data ) );
$trade_sha = $crypto->trade_sha( $trade_info );
$action = ( 'yes' === $this->get_option( 'testmode' ) )
? 'https://ccore.newebpay.com/MPG/mpg_gateway'
: 'https://core.newebpay.com/MPG/mpg_gateway';
?>
<form id="newebpay-form" method="post" action="<?php echo esc_url( $action ); ?>">
<input type="hidden" name="MerchantID" value="<?php echo esc_attr( $this->get_option( 'merchant_id' ) ); ?>">
<input type="hidden" name="TradeInfo" value="<?php echo esc_attr( $trade_info ); ?>">
<input type="hidden" name="TradeSha" value="<?php echo esc_attr( $trade_sha ); ?>">
<input type="hidden" name="Version" value="2.3">
<button type="submit"><?php esc_html_e( '前往付款', 'my-plugin' ); ?></button>
</form>
<?php
// 用 WooCommerce 的佇列輸出自動送出的指令,不要自己寫 inline script 標籤.
wc_enqueue_js( "document.getElementById( 'newebpay-form' ).submit();" );
}
注意 Amt 用 (int) 轉整數 — 藍新的金額不接受小數點,而 get_total() 回傳的是字串型別的數字。台幣沒有小數在這裡剛好,但如果你的店有設定小數位數這裡要先處理,否則簽章會過但金額會被拒。
public function handle_notify() {
$crypto = new NewebPay_Crypto( $this->get_option( 'hash_key' ), $this->get_option( 'hash_iv' ) );
$trade_info = isset( $_POST['TradeInfo'] ) ? sanitize_text_field( wp_unslash( $_POST['TradeInfo'] ) ) : '';
$trade_sha = isset( $_POST['TradeSha'] ) ? sanitize_text_field( wp_unslash( $_POST['TradeSha'] ) ) : '';
// 1. 驗章,不過就什麼都不做.
if ( '' === $trade_info || ! $crypto->verify( $trade_info, $trade_sha ) ) {
status_header( 400 );
exit;
}
$decrypted = $crypto->decrypt( $trade_info );
// 2. 解密失敗就停在這裡,不要拿 false 往下丟.
if ( false === $decrypted ) {
status_header( 400 );
exit;
}
parse_str( $decrypted, $result );
$order = wc_get_order( absint( $result['Result']['MerchantOrderNo'] ?? 0 ) );
if ( ! $order ) {
status_header( 404 );
exit;
}
// 3. 冪等:已經處理過就直接結束.
if ( ! $order->has_status( 'pending' ) ) {
exit;
}
// 4. 比對金額.
if ( (int) $order->get_total() !== (int) ( $result['Result']['Amt'] ?? 0 ) ) {
$order->add_order_note( __( '藍新回傳金額與訂單金額不符,未更新狀態。', 'my-plugin' ) );
exit;
}
// 5. 更新訂單.
if ( 'SUCCESS' === ( $result['Status'] ?? '' ) ) {
$order->payment_complete( $result['Result']['TradeNo'] ?? '' );
$order->add_order_note( __( '藍新金流付款成功。', 'my-plugin' ) );
} else {
$order->update_status( 'failed', $result['Message'] ?? '' );
}
exit;
}
這段的每一個 exit 都是刻意的:驗章失敗、找不到訂單、重複通知、金額不符,任何一種情況都要停在原地,在實務上,我會在這邊用 WC_Logger() 來埋下錯誤記錄,以便事後發生問題時可以進行追查。
前景返回那條就單純多了,它只負責帶使用者去該去的頁面:
public function handle_return() {
// 這裡不要更新任何訂單狀態.
$order_no = isset( $_POST['MerchantOrderNo'] ) ? sanitize_text_field( wp_unslash( $_POST['MerchantOrderNo'] ) ) : '';
$order = wc_get_order( absint( $order_no ) );
wp_safe_redirect( $order ? $this->get_return_url( $order ) : wc_get_checkout_url() );
exit;
}
一、先確認端點活著
部署完先打一次,回 200 表示 hook 有註冊成功,回 400 表示沒有,這一步在寫任何測試訂單之前先做:
$ curl -I https://your-site.com/wc-api/newebpay_notify/
二、背景通知打不到本機
開發時你的站在 localhost 或 .test,藍新的伺服器打不進來,NotifyURL 永遠不會被呼叫,解法是用 ngrok 之類的工具開一個對外網址,或者在測試環境寫一個模擬通知的 WP-CLI 指令,自己組出加密資料打自己的端點。
後者我更常用,因為它可以順便測試重複呼叫的情境,也就是同一筆資料連打三次,看訂單狀態是不是只變一次。
AI 可以把加解密工具類別、form_fields 那一大坨設定、表單 markup、把藍新文件的參數表翻成 PHP 陣列做得又快又準。
要特別注意的是整個流程的信任邊界,我實際遇過 AI 產出的版本把訂單更新寫在 handle_return() 裡,程式看起來完全正常,測試也會過(自己測都會乖乖按返回商店),但上線後會出現「客人付了錢訂單卻沒更新」,而且是那種偶發、難以重現的。
所以這一篇的 review 清單我會特別嚴格:
handle_return() 裡沒有任何訂單狀態的更新handle_notify() 的第一件事是驗章,且失敗直接 exit 或紀錄 loghash_equals()
$_POST 的取值都經過 sanitize_text_field( wp_unslash( … ) )
金流可以收款了,但如果這個站的結帳頁是區塊會不支援,這是我們下一篇要處理的部分。
文章目錄:https://oberonlai.blog/category/2026-ithome/
Hi, 我是 Oberon Lai,十多年前我從一個不懂程式的平面設計師,一頭栽進 WordPress 的世界。從佈景主題到外掛開發,從接案到自研產品,這段旅程讓我深刻理解:好的技術不只是寫出能跑的程式碼,而是真正解決人的問題。
我積極投入參與社群,公開演講紀錄如下:
我專精 WordPress 開發,從企業形象網站的設計與開發、佈景主題客製化,到既有網站的改版升級,提供完整的 WordPress 建置服務。開發面涵蓋外掛開發與維護、區塊編輯器(Gutenberg)客製區塊、ACF 與 Custom Post Type 的資料架構設計,以及 REST API 整合與 Multisite 多站架構建置。同時也協助網站效能改善與 SEO 調校、安全性檢測與強化,並導入自動化部署與版本控制流程,搭配長期的技術顧問與維運支援,讓網站上線後也能穩定運作。
亦提供 WooCommerce 商店的建置與設定,並串接綠界、LINE Pay、藍新等台灣主流金流。可依需求進行結帳頁面客製化、訂單狀態自動化流程設計,以及商品管理與庫存系統的客製開發;也支援 WooCommerce Subscription 定期定額、REST API 應用開發、報表與數據匯出等進階需求。此外,透過購物流程 UX 改善、電商網站效能調校與 HPOS 高效能訂單儲存相容開發,全面提升營運效率,並提供電商營運技術顧問服務。
AI 浪潮席捲而來,我選擇擁抱而非恐懼,我把 AI 融入開發工作流以及客戶的產品中,也持續累積「AI 看不見的部分」:真實踩坑經驗、最新漏洞情報那些只有第一線工程師才看得見的細節,如果你有任何 WordPress 的客製化需求或是 AI 開發相關的問題非常歡迎加入 LINE 官方帳號與我聯繫:
https://page.line.me/vrf7844t?oat_content=url&openQrModal=true
如果想要獲取 AI 開發實戰經驗也能訂閱我的電子報,每週五上午準時出刊:
https://oberonlai.blog/wordpress-newsletter/