在前一天我們完成了綠界金流的串接,讓使用者可以透過 API 產生付款頁面並進行支付。但付款完成後,系統要如何知道付款結果?又該如何自動更新訂單狀態並通知顧客呢?
今天我們將完成整個付款流程的最後一哩路:webhook 回調處理與訂單狀態通知整合。
POST /payment/webhook 路由接收綠界回調Pending → Completed / Failed)enqueueNotify() 將通知任務加入 Redis 佇列Webhook 是一種「反向 callback」機制,當第三方服務(如綠界)完成某項操作後,會主動向我們的伺服器發送 HTTP POST 請求,通知我們結果。
使用者完成付款 → 綠界 POST /payment/webhook
↓
驗證簽章與交易狀態
↓
更新訂單狀態 (Pending → Completed)
↓
呼叫 enqueueNotify() 加入佇列
↓
Redis Worker 推播 LINE 通知給顧客
webhook 的請求來自外部伺服器的 POST 請求
我們需要提供一個公開的 API endpoint 讓綠界呼叫。
必須驗證簽章(CheckMacValue)
防止惡意請求偽造付款結果。
必須防範 Replay Attack(重放攻擊)
避免同一筆 webhook 被多次處理導致重複通知。
快速回應
綠界要求在 10 秒內回應 1|OK,否則會重送請求。
我們需要在 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:記錄付款完成的時間點在 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;
重點說明:
verifyWebhookCheckMac() 確保資料來自綠界order.payment.status 是否已是 Completed
payment.status 與 order.status
enqueueNotify() 將通知任務加入 Redis 佇列"1|OK" 告知綠界收到更新我們的 Redis Worker(假設在 worker.js 或 src/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 類型處理確認環境變數設定(.env):
ECPAY_RETURN_URL=https://yourdomain.com/payment/webhook
啟動 Worker:
node worker.js
啟動主伺服器:
node index.js
假設我們透過付款 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: "建立付款失敗" });
}
});
使用綠界 Sandbox 測試付款,綠界會自動呼叫 POST 到我們的 webhook。
伺服器端應該會看到:
收到綠界 webhook: { MerchantTradeNo: 'T1728658000000123', RtnCode: '1', ... }
付款結果: ✅ 成功 - 交易成功
✅ 訂單狀態更新: 664a21b... → Completed
✅ 已加入推播 Redis 佇列
[Worker]
收到任務: { type: 'payment_success', orderId: '664a21b...', ... }
✅ 已推播付款通知給 U1234567890abcdef
使用者應該會收到類似以下訊息:
🎉 付款成功!
訂單編號:664a21b...
付款金額:NT$ 120
交易編號:T1728658000000123
您的訂單正在處理中,稍後將為您送達!
為什麼重要?
實作方式:
if (!verifyWebhookCheckMac(req.body, hashKey, hashIV)) {
return res.status(400).send('0|CheckMacValue Error');
}
為什麼重要?
實作方式:
if (order.payment.status === 'Completed') {
console.log('此訂單已處理完成,跳過');
return res.send('1|OK'); // 仍回傳成功
}
為什麼重要?
實作方式:
// 先快速回傳
res.send('1|OK');
// 耗時任務放在非同步處理(通知或寫 log 等)
setImmediate(async () => {
await enqueueNotify(...);
await writeAuditLog(...);
});
限定只接受綠界特定 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 處理邏輯
});
可能原因:
.env 的 ECPAY_RETURN_URL 設定錯誤解決方式:
ngrok http 3000
將產生的 URL(如 https://abc123.ngrok.io)設定到 .env:
ECPAY_RETURN_URL=https://abc123.ngrok.io/payment/webhook
可能原因:
解決方式:
// 正確:先取出 CheckMacValue
const { CheckMacValue, ...dataToVerify } = req.body;
const computed = generateCheckMacValue(dataToVerify, key, iv);
// 錯誤:直接傳入包含 CheckMacValue 的 req.body 驗證
const computed = generateCheckMacValue(req.body, key, iv);
可能原因:
解決方式:
ps aux | grep worker)redis-cli
> LLEN queue:notify:v1
> LRANGE queue:notify:v1 0 -1
可能原因:
MerchantTradeNo 找不到對應訂單payment.tradeNo 沒有正確建立解決方式:
tradeNo 寫入訂單console.log('查詢訂單:', MerchantTradeNo);
const order = await Order.findOne({ 'payment.tradeNo': MerchantTradeNo });
console.log('找到訂單:', order ? order._id : '無');
今天我們完成了系統中非常重要的功能:
Webhook 接收與驗證
透過 /payment/webhook 接收綠界回調,並驗證簽章。
訂單狀態自動更新
根據付款結果更新 payment.status 與 order.status。
防範重放攻擊
避免重複處理訂單。
非同步通知流程
呼叫 enqueueNotify() 將通知加入 Redis,由 Worker 負責推播。
完整測試驗證
從付款 → 付款 → webhook → 狀態更新 → LINE 通知的完整流程測試。
到這裡,我們的訂單系統已經完成了「付款通知與狀態更新」的閉環整合。
明天我們將進入「錯誤回報、重試機制與監控設計」的主題,
讓系統在可靠性與使用體驗上更加完善!