台灣電商的最後一塊拼圖:物流。這篇分兩段,前半是自訂運送方式的通用做法,後半是超商取貨 最難搞的選擇「門市電子地圖」。
自訂運送方式的骨架
跟金流很像,物流也是繼承一個抽象類別:
// ========== 檔案一:my-plugin.php(主外掛檔)==========
// WC_Shipping_Method 要等 WooCommerce 載入後才存在,所以類別檔不能在
// 檔案頂層 require,否則載入順序一變就 fatal error.
add_action(
'plugins_loaded',
function () {
if ( ! class_exists( 'WooCommerce' ) ) {
return;
}
// woocommerce_shipping_init 正好在 WooCommerce 組運送方式清單之前,
// 這時候 WC_Shipping_Method 一定已經載入了.
add_action(
'woocommerce_shipping_init',
function () {
require_once __DIR__ . '/includes/class-my-shipping-cvs.php';
}
);
add_filter(
'woocommerce_shipping_methods',
function ( $methods ) {
$methods['my_cvs'] = 'My_Shipping_CVS';
return $methods;
}
);
},
11
);
// ========== 檔案二:includes/class-my-shipping-cvs.php ==========
// 這是獨立的檔案,不要跟上面那段貼在一起;只有上面那個 hook 會載入它.
class My_Shipping_CVS extends WC_Shipping_Method {
public function __construct( $instance_id = 0 ) {
$this->id = 'my_cvs';
$this->instance_id = absint( $instance_id );
$this->method_title = __( '超商取貨', 'my-plugin' );
$this->method_description = __( '讓顧客選擇超商門市取貨。', 'my-plugin' );
// 宣告這個方法可以加進運送區域,並支援區域層級的設定.
$this->supports = array(
'shipping-zones',
'instance-settings',
'instance-settings-modal',
);
$this->init();
}
public function init() {
$this->init_form_fields();
$this->init_settings();
$this->title = $this->get_option( 'title', __( '超商取貨', 'my-plugin' ) );
add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
}
public function init_form_fields() {
$this->instance_form_fields = array(
'title' => array(
'title' => __( '顯示名稱', 'my-plugin' ),
'type' => 'text',
'default' => __( '超商取貨', 'my-plugin' ),
),
'cost' => array(
'title' => __( '運費', 'my-plugin' ),
'type' => 'price',
'default' => '60',
),
'free_over' => array(
'title' => __( '免運門檻', 'my-plugin' ),
'type' => 'price',
'description' => __( '訂單金額達此數字免運,留空表示不提供免運。', 'my-plugin' ),
'default' => '',
),
);
}
/**
* 這個方法決定「結帳頁要出現哪些運送選項、各多少錢」.
*/
public function calculate_shipping( $package = array() ) {
$cost = (float) $this->get_option( 'cost', 0 );
$free_over = $this->get_option( 'free_over' );
if ( '' !== $free_over && $package['contents_cost'] >= (float) $free_over ) {
$cost = 0;
}
$this->add_rate(
array(
'id' => $this->get_rate_id(),
'label' => $this->title,
'cost' => $cost,
'package' => $package,
)
);
}
}
完成後就能看到 WooCommerce 的運送方式多了以下頁面:

三個重點:
instance_form_fields 而不是 form_fields。 加了 shipping-zones 支援之後,設定是掛在「運送區域」底下的,每個區域可以有不同的運費。用錯的話設定會出現在錯的地方。
calculate_shipping() 每次購物車變動都會跑。 使用者改數量、換地址都會重算,所以這裡面不要打外部 API — 如果運費要跟物流商即時查詢,一定要快取,否則購物車會慢到不能用。
add_rate() 的 meta_data。 這是很好用但少人知道的參數:
$this->add_rate(
array(
'id' => $this->get_rate_id(),
'label' => $this->title,
'cost' => $cost,
'meta_data' => array(
'store_id' => $store_id,
'store_name' => $store_name,
),
)
);
放在這裡的資料會跟著運送方式存進訂單,這正是等一下超商門市資訊的去處。
超商取貨:那個電子地圖
台灣的超商取貨流程長這樣,跟一般運送方式完全不同:
結帳頁 物流商電子地圖 你的網站
│ │ │
├─ 點「選擇門市」──────────────→ 開啟門市地圖 │
│ │ │
│ ├── 使用者選好門市 ─────→ POST 門市資料到你的 callback
│ │ │
│←──────────────── 導回結帳頁,帶著門市資訊 ───────────┤
關鍵在於:門市資料是物流商用 POST 打回你的網站的,不是前端 JavaScript 給你的。所以你需要一個接收端點,做法跟金流的背景通知一樣:
// 產生 callback 網址:https://your-site.com/wc-api/my_cvs_map/
$callback = WC()->api_request_url( 'my_cvs_map' );
add_action( 'woocommerce_api_my_cvs_map', 'my_plugin_handle_cvs_map' );
/**
* 把物流商回傳的 EncryptData 解回陣列.
*
* 回傳只有 EncryptData 一個欄位,內容是 AES-256-CBC 加密後再轉成的
* 十六進位字串,沒有明文的門市欄位可以讀.
*/
function my_plugin_decrypt( $encrypted, $key, $iv ) {
// hex2bin() 收到非十六進位字串會發警告並回傳 false,先擋掉.
if ( 0 !== strlen( $encrypted ) % 2 || ! preg_match( '/^[0-9a-fA-F]+$/', $encrypted ) ) {
return array();
}
$raw = openssl_decrypt(
hex2bin( $encrypted ),
'AES-256-CBC',
$key,
// 關掉 PHP 的自動 padding 處理,改成照技術文件自己拆.
OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING,
$iv
);
if ( false === $raw || '' === $raw ) {
return array();
}
// 最後一個位元組的 ord 值就是填充長度,不拆掉 JSON 會解不開.
$pad = ord( substr( $raw, -1 ) );
// 區塊長度是 16,填充長度超出這個範圍表示解出來的東西不對.
if ( $pad < 1 || $pad > 16 || $pad >= strlen( $raw ) ) {
return array();
}
$data = json_decode( substr( $raw, 0, -$pad ), true );
return is_array( $data ) ? $data : array();
}
function my_plugin_handle_cvs_map() {
$encrypted = isset( $_POST['EncryptData'] ) ? sanitize_text_field( wp_unslash( $_POST['EncryptData'] ) ) : '';
$payload = my_plugin_decrypt( $encrypted, MY_PLUGIN_HASH_KEY, MY_PLUGIN_HASH_IV );
$store_id = isset( $payload['StoreID'] ) ? sanitize_text_field( $payload['StoreID'] ) : '';
$store_name = isset( $payload['StoreName'] ) ? sanitize_text_field( $payload['StoreName'] ) : '';
$store_addr = isset( $payload['StoreAddr'] ) ? sanitize_text_field( $payload['StoreAddr'] ) : '';
if ( '' === $store_id ) {
wp_safe_redirect( wc_get_checkout_url() );
exit;
}
// 暫存到 session,結帳建立訂單時再寫進訂單.
WC()->session->set(
'my_plugin_cvs_store',
array(
'id' => $store_id,
'name' => $store_name,
'address' => $store_addr,
)
);
wp_safe_redirect( wc_get_checkout_url() );
exit;
}
有幾個實務上會踩到的點:
一、資料要先進 session,不能直接進訂單。 使用者選門市的時候訂單還不存在(訂單是按下單才建立的)。所以先存 session,等訂單建立時再搬過去:
add_action( 'woocommerce_checkout_create_order', function ( $order ) {
$store = WC()->session->get( 'my_plugin_cvs_store' );
if ( $store ) {
$order->update_meta_data( '_cvs_store_id', $store['id'] );
$order->update_meta_data( '_cvs_store_name', $store['name'] );
$order->update_meta_data( '_cvs_store_address', $store['address'] );
}
} );
二、選完門市會離開結帳頁再回來,購物車不能掉。 因為是整頁跳轉到物流商再跳回來,區塊結帳頁填到一半的資料會消失。這是超商取貨在區塊結帳頁最惱人的地方。目前的解法是在跳轉前把已填的資料存起來(Store API 的 update-customer 端點會把地址存進 session),或者用彈出視窗而不是整頁跳轉。
三、SameSite cookie 的坑。 物流商 POST 回來是跨站請求,如果你的 session cookie 是 SameSite=Strict,那個請求會拿不到購物車。WooCommerce 預設是 Lax,POST 跨站在 Lax 下也不會帶 cookie — 所以上面那段程式碼裡的 WC()->session 有可能是空的。
實務的做法是在 callback 網址帶一個自己產生的一次性 token,用它找回對應的 session 或直接存進 transient。這個坑很隱蔽,因為在同站測試(自己模擬 POST)完全正常,只有接上真的物流商才會出事。
顯示與出貨
門市資訊存進訂單之後,至少要出現在三個地方:後台訂單頁、訂單確認頁、通知信。
// 後台訂單頁顯示.
add_action( 'woocommerce_admin_order_data_after_shipping_address', function ( $order ) {
$store_name = $order->get_meta( '_cvs_store_name' );
if ( $store_name ) {
echo '<p><strong>' . esc_html__( '取貨門市', 'my-plugin' ) . ':</strong> ' . esc_html( $store_name ) . '</p>';
}
} );

出貨階段則是反過來:把訂單資料送去物流商建立托運單,拿回物流編號存進訂單:
add_action( 'woocommerce_order_status_processing', function ( $order_id ) {
if ( ! as_next_scheduled_action( 'my_plugin_create_shipment', array( $order_id ) ) ) {
as_schedule_single_action( time() + 5, 'my_plugin_create_shipment', array( $order_id ) );
}
} );
寫到這裡你應該有感覺了:金流、發票、物流三者的程式結構高度相似,都是「訂單狀態變化 → 呼叫外部 API → 結果寫回訂單」,差別只在 API 規格與觸發時機。掌握了這個模式,換一家金流、換一家發票商,改的只是最裡面那層。
這一篇 AI 幫得上與幫不上的
幫得上:WC_Shipping_Method 的骨架、instance_form_fields 設定、運費計算邏輯、後台顯示的 hook。這些都是標準結構。
幫不上:超商電子地圖那一整段。這是台灣特有的流程,國外的訓練資料裡沒有,AI 通常會給你一個「用 JavaScript 開一個視窗然後接收 postMessage」的想像版本 — 那不是物流商實際的運作方式。這一段只能照物流商的技術文件自己寫,或參考現成的台灣物流外掛。
而 SameSite 那個坑,我沒看過任何 AI 主動提醒。你要自己知道,然後在提示裡明講:
callback 是物流商跨站 POST 回來的,session cookie 可能拿不到,用一次性 token 對應 transient 來取回購物車。
台灣電商三件套到這裡都齊了。最後一篇我們把這些散落的客製收成一個外掛,處理相依宣告、HPOS 相容性,並用 everyting-wp:review 來做程式碼審核。