iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0

在前一天我們完成了綠界金流的串接,讓使用者可以透過 API 產生付款頁面並進行支付。但付款完成後,系統要如何知道付款結果?又該如何自動更新訂單狀態並通知顧客呢?

今天我們將完成整個付款流程的最後一哩路:webhook 回調處理訂單狀態通知整合


今天的目標

  1. 建立 POST /payment/webhook 路由接收綠界回調
  2. 驗證 CheckMacValue 簽章,確保資料來自綠界
  3. 根據付款結果更新訂單狀態(PendingCompleted / Failed
  4. 呼叫 enqueueNotify() 將通知任務加入 Redis 佇列
  5. 測試完整流程:付款 → webhook → Worker 推播 LINE 通知

什麼是 webhook?

Webhook 是一種「反向 callback」機制,當第三方服務(如綠界)完成某項操作後,會主動向我們的伺服器發送 HTTP POST 請求,通知我們結果。

流程圖

使用者完成付款 → 綠界 POST /payment/webhook
                          ↓
                  驗證簽章與交易狀態
                          ↓
                  更新訂單狀態 (Pending → Completed)
                          ↓
                  呼叫 enqueueNotify() 加入佇列
                          ↓
                  Redis Worker 推播 LINE 通知給顧客

重點觀念

  1. webhook 的請求來自外部伺服器的 POST 請求
    我們需要提供一個公開的 API endpoint 讓綠界呼叫。

  2. 必須驗證簽章(CheckMacValue)
    防止惡意請求偽造付款結果。

  3. 必須防範 Replay Attack(重放攻擊)
    避免同一筆 webhook 被多次處理導致重複通知。

  4. 快速回應
    綠界要求在 10 秒內回應 1|OK,否則會重送請求。


Step 1:更新 Order Model 加入付款欄位

我們需要在 Order Model 中新增付款相關欄位。

建立或更新 src/models/order.model.js

const mongoose = require("mongoose");

const orderItemSchema = new mongoose.Schema({
  productName: { type: String, required: true },
  quantity: { type: Number, required: true, min: 1 },
  price: { type: Number, required: true, min: 0 },
}, { _id: false });

const orderSchema = new mongoose.Schema({
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', index: true },
  items: { type: [orderItemSchema], default: [] },
  status: {
    type: String,
    enum: ['Pending', 'In Progress', 'Completed', 'Failed'], // 新增 Failed 狀態
    default: 'Pending',
    index: true
  },
  pickup: { type: String, enum: ["內用", "外帶", "外送"], default: "內用" },

  // 新增付款相關欄位
  payment: {
    tradeNo: { type: String, index: true }, // 綠界商店交易編號
    status: {
      type: String,
      enum: ['Pending', 'Completed', 'Failed'],
      default: 'Pending'
    },
    amount: { type: Number },
    paidAt: { type: Date }, // 付款完成時間
  }
}, { timestamps: true });

module.exports = mongoose.model('Order', orderSchema);

重點說明:

  • status:訂單狀態新增 Failed 狀態(付款失敗)
  • payment.tradeNo:綠界商店交易編號(用於查詢訂單)
  • payment.status:付款狀態(獨立於訂單狀態)
  • payment.paidAt:記錄付款完成的時間點

Step 2:建立 webhook 路由

src/routes/payment.js 中新增 webhook 處理:

const express = require("express");
const router = express.Router();
const crypto = require("crypto");
const Order = require("../models/order.model"); // 引用 Order Model
const { enqueueNotify } = require("../queues/notify"); // 引用佇列函式

// ... (保留之前的 getECPayDate 與 generateCheckMacValue 函式)

/**
 * 驗證綠界 webhook 的 CheckMacValue
 * 這個函式會從 webhook 參數中取出 CheckMacValue,驗證是否由綠界發送
 */
function verifyWebhookCheckMac(params, hashKey, hashIV) {
  const { CheckMacValue, ...dataToVerify } = params;
  const computedMac = generateCheckMacValue(dataToVerify, hashKey, hashIV);
  return computedMac === CheckMacValue;
}

/**
 * 處理綠界付款 webhook
 * POST /payment/webhook
 */
router.post("/webhook", async (req, res) => {
  try {
    console.log('收到綠界 webhook:', req.body);

    // Step 1: 驗證簽章
    const isValid = verifyWebhookCheckMac(
      req.body,
      process.env.ECPAY_HASH_KEY,
      process.env.ECPAY_HASH_IV
    );

    if (!isValid) {
      console.error('❌ CheckMacValue 驗證失敗');
      return res.status(400).send('0|CheckMacValue Error');
    }

    // Step 2: 解析參數
    const {
      MerchantTradeNo,  // 商店交易編號
      RtnCode,          // 回傳代碼 (1=成功, 其他=失敗)
      RtnMsg,           // 回傳訊息
      TradeAmt,         // 交易金額
      PaymentDate,      // 付款時間
      TradeNo,          // 綠界交易編號
    } = req.body;

    const isSuccess = RtnCode === '1';

    console.log(`付款結果: ${isSuccess ? '✅ 成功' : '❌ 失敗'} - ${RtnMsg}`);

    // Step 3: 查詢訂單
    const order = await Order.findOne({ 'payment.tradeNo': MerchantTradeNo });

    if (!order) {
      console.error(`查無此訂單: ${MerchantTradeNo}`);
      return res.status(404).send('0|Order Not Found');
    }

    // Step 4: 防範重放攻擊
    if (order.payment.status === 'Completed') {
      console.log('此訂單已處理完成,跳過重複處理');
      return res.send('1|OK'); // 仍回傳成功,避免綠界重送
    }

    // Step 5: 更新訂單狀態
    order.payment.status = isSuccess ? 'Completed' : 'Failed';
    order.payment.paidAt = PaymentDate ? new Date(PaymentDate) : new Date();
    order.status = isSuccess ? 'Completed' : 'Failed';

    await order.save();

    console.log(`✅ 訂單狀態更新: ${order._id} → ${order.status}`);

    // Step 6: 推播通知(僅付款成功時)
    if (isSuccess) {
      await enqueueNotify({
        type: 'payment_success',
        orderId: order._id.toString(),
        userId: order.userId?.toString(),
        amount: TradeAmt,
        tradeNo: MerchantTradeNo,
      });

      console.log('✅ 已加入推播 Redis 佇列');
    }

    // Step 7: 快速回傳「必須」回覆 "1|OK"
    res.send('1|OK');

  } catch (error) {
    console.error('處理 webhook 失敗:', error);
    res.status(500).send('0|Error');
  }
});

// ... (保留之前的 POST /create 路由)

module.exports = router;

重點說明:

  1. 簽章驗證:使用 verifyWebhookCheckMac() 確保資料來自綠界
  2. 防範重放:檢查 order.payment.status 是否已是 Completed
  3. 狀態更新:同時更新 payment.statusorder.status
  4. 加入佇列:呼叫 enqueueNotify() 將通知任務加入 Redis 佇列
  5. 快速回應:必須回覆 "1|OK" 告知綠界收到

Step 3:擴充 Worker 處理付款通知

更新我們的 Redis Worker(假設在 worker.jssrc/queues/worker.js):

const { ensureConnected } = require("./lib/redis");
const { QUEUE_KEY } = require("./queues/notify");
const line = require("@line/bot-sdk");

const client = new line.messagingApi.MessagingApiClient({
  channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
});

async function startWorker() {
  const redis = await ensureConnected();
  console.log('🚀 Worker 啟動,監聽 Redis 佇列...');

  while (true) {
    try {
      // BLPOP:阻塞式從佇列中取出任務(timeout 設為 0 = 永久等待)
      const result = await redis.blPop(QUEUE_KEY, 0);

      if (!result) continue;

      const payload = JSON.parse(result.element);
      console.log('收到任務:', payload);

      // 根據 type 處理不同類型的通知
      switch (payload.type) {
        case 'new_order':
          await handleNewOrder(payload);
          break;

        case 'payment_success': // 新增付款成功的通知處理
          await handlePaymentSuccess(payload);
          break;

        default:
          console.warn('未知的任務類型:', payload.type);
      }

    } catch (error) {
      console.error('Worker 處理失敗:', error);
      await new Promise(resolve => setTimeout(resolve, 3000)); // 錯誤後等待 3 秒
    }
  }
}

async function handleNewOrder(payload) {
  const { userId, orderId, items } = payload;

  const message = {
    type: 'text',
    text: `✅ 您的訂單已建立成功!\n訂單編號:${orderId}\n品項:${items.map(i => i.productName).join(', ')}\n\n請完成付款。`,
  };

  await client.pushMessage({ to: userId, messages: [message] });
  console.log(`✅ 已推播新訂單通知給 ${userId}`);
}

// 新增付款成功通知處理
async function handlePaymentSuccess(payload) {
  const { userId, orderId, amount, tradeNo } = payload;

  const message = {
    type: 'text',
    text: `🎉 付款成功!\n\n訂單編號:${orderId}\n付款金額:NT$ ${amount}\n交易編號:${tradeNo}\n\n您的訂單正在處理中,稍後將為您送達!`,
  };

  await client.pushMessage({ to: userId, messages: [message] });
  console.log(`✅ 已推播付款通知給 ${userId}`);

  // 可選:同時通知店家群組
  if (process.env.LINE_GROUP_ID) {
    await client.pushMessage({
      to: process.env.LINE_GROUP_ID,
      messages: [{
        type: 'text',
        text: `🔔 新訂單已付款\n訂單:${orderId}\n金額:NT$ ${amount}`,
      }],
    });
  }
}

startWorker();

重點說明:

  • 新增 payment_success 類型處理
  • 通知顧客付款成功,並提供訂單資訊
  • 可選:同時通知店家群組,讓店家即時知道訂單

Step 4:測試與驗證

測試步驟

  1. 確認環境變數設定.env):

    ECPAY_RETURN_URL=https://yourdomain.com/payment/webhook
    
  2. 啟動 Worker

    node worker.js
    
  3. 啟動主伺服器

    node index.js
    

測試流程

  1. 建立訂單並產生交易編號

假設我們透過付款 API 建立訂單,並取得 payment.tradeNo

// 假設我們透過付款表單建立訂單並取得訂單 ID
const orderId = "664a21b...";
const amount = 120;

// 呼叫付款 API
fetch("/payment/create", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    orderId,
    amount,
    itemName: "拿鐵咖啡 x2",
  }),
})
  .then((res) => res.text())
  .then((html) => {
    document.open();
    document.write(html);
    document.close();
  });

確保 payment.js 的 /create 路由會更新付款資訊到訂單:

router.post("/create", async (req, res) => {
  try {
    const { orderId, amount, itemName } = req.body;

    // 參數驗證
    if (!orderId || !amount || !itemName) {
      return res.status(400).json({ error: "參數不完整" });
    }

    // 產生唯一商店交易編號
    const tradeNo = `T${Date.now()}${Math.floor(Math.random() * 1000)}`;

    // 更新訂單的交易編號
    const order = await Order.findById(orderId);
    if (!order) {
      return res.status(404).json({ error: "查無訂單" });
    }

    order.payment = {
      tradeNo,
      status: 'Pending',
      amount,
    };
    await order.save();

    // 組裝綠界 API 參數(參考前一天 Day 27)
    const params = {
      MerchantID: process.env.ECPAY_MERCHANT_ID,
      MerchantTradeNo: tradeNo,
      // ... 其他參數
    };

    // ... 產生表單並回傳
  } catch (error) {
    console.error('建立付款失敗:', error);
    res.status(500).json({ error: "建立付款失敗" });
  }
});
  1. 模擬付款成功

使用綠界 Sandbox 測試付款,綠界會自動呼叫 POST 到我們的 webhook。

  1. 檢查 Console 輸出

伺服器端應該會看到:

收到綠界 webhook: { MerchantTradeNo: 'T1728658000000123', RtnCode: '1', ... }
付款結果: ✅ 成功 - 交易成功
✅ 訂單狀態更新: 664a21b... → Completed
✅ 已加入推播 Redis 佇列

[Worker]
收到任務: { type: 'payment_success', orderId: '664a21b...', ... }
✅ 已推播付款通知給 U1234567890abcdef
  1. 檢查 LINE 通知

使用者應該會收到類似以下訊息:

🎉 付款成功!

訂單編號:664a21b...
付款金額:NT$ 120
交易編號:T1728658000000123

您的訂單正在處理中,稍後將為您送達!

重要安全性考量

1. Webhook 簽章驗證

為什麼重要?

  • 防止惡意請求偽造付款成功資料
  • 確保資料真的來自綠界

實作方式:

if (!verifyWebhookCheckMac(req.body, hashKey, hashIV)) {
  return res.status(400).send('0|CheckMacValue Error');
}

2. 防範重放攻擊

為什麼重要?

  • 綠界在 timeout 時會重送 webhook
  • 避免同一筆訂單被重複處理

實作方式:

if (order.payment.status === 'Completed') {
  console.log('此訂單已處理完成,跳過');
  return res.send('1|OK'); // 仍回傳成功
}

3. 快速回應

為什麼重要?

  • 綠界要求在 10 秒內 timeout
  • 超時後會重送 webhook,造成重複處理

實作方式:

// 先快速回傳
res.send('1|OK');

// 耗時任務放在非同步處理(通知或寫 log 等)
setImmediate(async () => {
  await enqueueNotify(...);
  await writeAuditLog(...);
});

4. IP 白名單(選用)

限定只接受綠界特定 IP:

const ECPAY_IPS = ['211.21.xx.xx', '211.21.xx.xx']; // 綠界官方 IP

router.post("/webhook", (req, res, next) => {
  const clientIP = req.ip || req.connection.remoteAddress;

  if (!ECPAY_IPS.includes(clientIP)) {
    console.error(`❌ 非法 IP: ${clientIP}`);
    return res.status(403).send('0|Forbidden');
  }

  next();
}, async (req, res) => {
  // webhook 處理邏輯
});

常見問題排查

1. Webhook 沒有被呼叫

可能原因:

  • .envECPAY_RETURN_URL 設定錯誤
  • 網址不可達(本地開發需使用 ngrok 或類似工具)
  • 防火牆阻擋 POST 請求

解決方式:

  • 測試時使用 ngrok
    ngrok http 3000
    
    將產生的 URL(如 https://abc123.ngrok.io)設定到 .env
    ECPAY_RETURN_URL=https://abc123.ngrok.io/payment/webhook
    

2. CheckMacValue 驗證失敗

可能原因:

  • webhook 參數中包含不該計算的欄位
  • 驗證時的參數順序或格式不對

解決方式:

// 正確:先取出 CheckMacValue
const { CheckMacValue, ...dataToVerify } = req.body;
const computed = generateCheckMacValue(dataToVerify, key, iv);

// 錯誤:直接傳入包含 CheckMacValue 的 req.body 驗證
const computed = generateCheckMacValue(req.body, key, iv);

3. Worker 沒有推播通知

可能原因:

  • Redis 連線失敗
  • Worker 沒有啟動
  • 佇列名稱(QUEUE_KEY)不一致

解決方式:

  • 檢查 Redis 連線狀態
  • 檢查 Worker 有沒有在跑(ps aux | grep worker
  • 使用 Redis CLI 確認佇列:
    redis-cli
    > LLEN queue:notify:v1
    > LRANGE queue:notify:v1 0 -1
    

4. 訂單狀態沒有更新

可能原因:

  • MerchantTradeNo 找不到對應訂單
  • MongoDB 連線失敗
  • payment.tradeNo 沒有正確建立

解決方式:

  • 在付款表單建立時確保 tradeNo 寫入訂單
  • 增加除錯 log:
    console.log('查詢訂單:', MerchantTradeNo);
    const order = await Order.findOne({ 'payment.tradeNo': MerchantTradeNo });
    console.log('找到訂單:', order ? order._id : '無');
    

重點回顧與總結

今天我們完成了系統中非常重要的功能:

  1. Webhook 接收與驗證
    透過 /payment/webhook 接收綠界回調,並驗證簽章。

  2. 訂單狀態自動更新
    根據付款結果更新 payment.statusorder.status

  3. 防範重放攻擊
    避免重複處理訂單。

  4. 非同步通知流程
    呼叫 enqueueNotify() 將通知加入 Redis,由 Worker 負責推播。

  5. 完整測試驗證
    從付款 → 付款 → webhook → 狀態更新 → LINE 通知的完整流程測試。


到這裡,我們的訂單系統已經完成了「付款通知與狀態更新」的閉環整合。

明天我們將進入「錯誤回報、重試機制與監控設計」的主題,
讓系統在可靠性與使用體驗上更加完善!


上一篇
打通付款關鍵的一步:金流串接初探(以綠界 Sandbox 為例
下一篇
讓系統永不漏通知:錯誤回報、重試機制與監控設計全攻略
系列文
用 LINE OA 打造中小企業訂單系統:從零開始的 30 天實作紀錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言