iT邦幫忙

2026 iThome 鐵人賽

0
Vibe Coding

從一句話到一個網站:用 Vibe Coding 開發 WordPress Block Theme 的 30 天系列 第 36

實作一個 WooCommerce 金流串接:以藍新 NewebPay 為例

  • 分享至 

  • xImage
  •  

這一篇用藍新金流(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,翻成白話是:

  1. 準備鑰匙 — 從藍新後台複製 Hash Key、Hash IV 與商店代號(Merchant ID)
  2. 組請求字串 — 把訂單參數用 http_build_query() 組成 MerchantID=MS127874575&Amt=30&... 這種形式
  3. 加密 — 用 AES-256-CBC 加密後 bin2hex() 轉十六進位,這個結果叫 TradeInfo
  4. 簽章 — 把 TradeInfo 前後包上金鑰,SHA256 後轉大寫,這個結果叫 TradeSha

最後把 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 付款設定看到相關的選項:

第三步:process_payment 與中繼頁

藍新要求用 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 串接金流的注意事項

AI 可以把加解密工具類別、form_fields 那一大坨設定、表單 markup、把藍新文件的參數表翻成 PHP 陣列做得又快又準。

要特別注意的是整個流程的信任邊界,我實際遇過 AI 產出的版本把訂單更新寫在 handle_return() 裡,程式看起來完全正常,測試也會過(自己測都會乖乖按返回商店),但上線後會出現「客人付了錢訂單卻沒更新」,而且是那種偶發、難以重現的。

所以這一篇的 review 清單我會特別嚴格:

  1. handle_return()沒有任何訂單狀態的更新
  2. handle_notify() 的第一件事是驗章,且失敗直接 exit 或紀錄 log
  3. 驗章用 hash_equals()
  4. 有比對金額
  5. 有重複呼叫檢查
  6. 解密有處理 padding
  7. $_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/


上一篇
WooCommerce 金流串接原理介紹
下一篇
讓金流支援 WooCommerce Checkout Block
系列文
從一句話到一個網站:用 Vibe Coding 開發 WordPress Block Theme 的 30 天38
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言