payuni-webhook
SKILL.md
統一金流 Webhook 處理任務
你的任務是在用戶的專案中實作統一金流 Webhook 接收與處理功能。
串接 Checklist
- 框架確認 - 確認使用的框架
- 端點建立 - 建立 Webhook 接收端點
- 簽名驗證 - 實作 CheckCode 驗證
- 防重放 - 實作重複請求檢測
- 狀態更新 - 更新訂單狀態
- 測試驗證 - 驗證 Webhook 處理流程
Step 1: 確認專案環境
用戶輸入: $ARGUMENTS
詢問用戶:
-
框架類型:
- Next.js (App Router / Pages Router)
- Express / Fastify
- NestJS
- 其他
-
資料庫:用什麼來儲存訂單?
- PostgreSQL / MySQL
- MongoDB
- Prisma / Drizzle
- Supabase
- 其他
Step 2: 建立 Webhook 端點
Next.js App Router
// app/api/webhooks/payuni/route.ts
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const config = {
hashKey: process.env.PAYUNI_HASH_KEY!,
hashIV: process.env.PAYUNI_HASH_IV!,
};
// 簽名驗證(使用 constant-time 比較防止 timing attack)
function verifyCheckCode(params: Record<string, string>): boolean {
const { CheckCode, ...otherParams } = params;
if (!CheckCode) return false;
const sortedKeys = Object.keys(otherParams).sort();
const paramStr = sortedKeys.map(k => `${k}=${otherParams[k]}`).join('&');
const signStr = `HashKey=${config.hashKey}&${paramStr}&HashIV=${config.hashIV}`;
const calculated = crypto
.createHash('sha256')
.update(signStr)
.digest('hex')
.toUpperCase();
try {
return crypto.timingSafeEqual(
Buffer.from(calculated),
Buffer.from(CheckCode)
);
} catch {
return false;
}
}
export async function POST(request: NextRequest) {
try {
// 解析請求
const contentType = request.headers.get('content-type');
let params: Record<string, string>;
if (contentType?.includes('application/json')) {
params = await request.json();
} else {
const formData = await request.formData();
params = Object.fromEntries(formData.entries()) as Record<string, string>;
}
// 驗證簽名
if (!verifyCheckCode(params)) {
console.error('[Webhook] Invalid signature');
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// 取得訂單資訊
const { Status, MerchantOrderNo, TradeNo, TradeAmt } = params;
// TODO: 檢查是否已處理過此 TradeNo(防重放攻擊)
// const exists = await db.webhookLog.findUnique({ where: { tradeNo: TradeNo } });
// if (exists) return NextResponse.json({ success: true, message: 'Already processed' });
if (Status === 'SUCCESS') {
// TODO: 更新訂單狀態為已付款
// await db.order.update({
// where: { id: MerchantOrderNo },
// data: { status: 'paid', paymentDetails: { tradeNo: TradeNo, amount: TradeAmt } }
// });
console.log('[Webhook] Payment success:', MerchantOrderNo);
} else {
// TODO: 更新訂單狀態為失敗
console.log('[Webhook] Payment failed:', MerchantOrderNo);
}
// TODO: 記錄 webhook 請求(防重放)
// await db.webhookLog.create({ data: { tradeNo: TradeNo, processedAt: new Date() } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('[Webhook] Error:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
// 健康檢查端點
export async function GET() {
return NextResponse.json({
message: 'PAYUNi Webhook endpoint',
timestamp: new Date().toISOString(),
});
}
Express
import express from 'express';
import crypto from 'crypto';
const router = express.Router();
router.post('/webhooks/payuni', async (req, res) => {
try {
const params = req.body;
// 驗證簽名
if (!verifyCheckCode(params)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { Status, MerchantOrderNo, TradeNo } = params;
if (Status === 'SUCCESS') {
// 更新訂單狀態
console.log('Payment success:', MerchantOrderNo);
}
res.json({ success: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(500).json({ error: 'Internal error' });
}
});
export default router;
Step 3: 實作防重放攻擊
建立 webhook 請求記錄表:
CREATE TABLE webhook_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider VARCHAR(50) NOT NULL DEFAULT 'payuni',
trade_no VARCHAR(100) UNIQUE,
merchant_order_no VARCHAR(100),
checksum VARCHAR(64),
status VARCHAR(20) DEFAULT 'processing',
processed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_webhook_trade_no ON webhook_requests(trade_no);
CREATE INDEX idx_webhook_checksum ON webhook_requests(checksum);
防重放檢查邏輯
async function checkDuplicate(tradeNo: string): Promise<boolean> {
const existing = await db.webhookRequest.findUnique({
where: { tradeNo }
});
return !!existing;
}
async function markProcessed(tradeNo: string): Promise<void> {
await db.webhookRequest.create({
data: {
tradeNo,
provider: 'payuni',
processedAt: new Date(),
}
});
}
Step 4: 測試 Webhook
本地測試方法
-
使用 ngrok 暴露本地服務
ngrok http 3000 -
設定 NotifyURL 將 ngrok 提供的 HTTPS URL 設定為 NotifyURL
-
發起測試付款 在統一金流測試環境發起付款
-
檢查日誌 確認 Webhook 正確接收並處理
回調參數說明
成功付款通知參數
| 參數 | 說明 |
|---|---|
| Status | 付款狀態 SUCCESS / FAIL |
| MerchantOrderNo | 商店訂單編號 |
| TradeNo | PAYUNi 交易編號 |
| TradeAmt | 交易金額 |
| PaymentType | 付款方式 |
| PayTime | 付款時間 |
| CheckCode | 驗證碼 |
CheckCode 計算規則
1. 將所有參數(除了 CheckCode)按字母順序排序
2. 組成 key=value&key=value 的字串
3. 在開頭加上 HashKey=xxx&,結尾加上 &HashIV=xxx
4. 進行 SHA256 雜湊後轉大寫
安全注意事項
- 使用 HTTPS - 正式環境必須使用 HTTPS
- 驗證簽名 - 每次都要驗證 CheckCode
- 防重放攻擊 - 記錄已處理的 TradeNo
- Constant-time 比較 - 使用
timingSafeEqual防止 timing attack - 記錄日誌 - 記錄所有 Webhook 請求供除錯
詳細參考文件
Weekly Installs
9
Repository
paid-tw/skillsGitHub Stars
216
First Seen
Jan 30, 2026
Security Audits
Installed on
codex9
gemini-cli9
cursor9
opencode9
amp8
antigravity8