Help us improve
Share bugs, ideas, or general feedback.
From payuni
Implements 統一金流 (PAYUNi) webhook endpoints with CheckCode signature verification, replay attack prevention, and order status updates for Next.js, Express, or other backends.
npx claudepluginhub paid-tw/skills --plugin payuniHow this skill is triggered — by the user, by Claude, or both
Slash command
/payuni:payuni-webhookThis skill is limited to the following tools:
The summary Claude sees in its skill listing — used to decide when to auto-load this skill
你的任務是在用戶的專案中實作統一金流 Webhook 接收與處理功能。
Integrates PAYUNi UPP payment gateway using AES-256 encryption, form submission, order creation, and callback handling for Laravel, Next.js, Django, Express, and similar frameworks.
Guides NewebPay (藍新金流) integration: routes to checkout/query/refund skills by need, sets up env vars (Merchant ID, Hash Key/IV), checks frameworks like PHP/Laravel/Node/Python.
Integrates Stripe, PayPal, Square for checkout flows, subscriptions, webhooks, and PCI compliance. Guides secure payment processing, error handling, and best practices for billing features.
Share bugs, ideas, or general feedback.
你的任務是在用戶的專案中實作統一金流 Webhook 接收與處理功能。
用戶輸入: $ARGUMENTS
詢問用戶:
框架類型:
資料庫:用什麼來儲存訂單?
// 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(),
});
}
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;
建立 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(),
}
});
}
使用 ngrok 暴露本地服務
ngrok http 3000
設定 NotifyURL 將 ngrok 提供的 HTTPS URL 設定為 NotifyURL
發起測試付款 在統一金流測試環境發起付款
檢查日誌 確認 Webhook 正確接收並處理
| 參數 | 說明 |
|---|---|
| Status | 付款狀態 SUCCESS / FAIL |
| MerchantOrderNo | 商店訂單編號 |
| TradeNo | PAYUNi 交易編號 |
| TradeAmt | 交易金額 |
| PaymentType | 付款方式 |
| PayTime | 付款時間 |
| CheckCode | 驗證碼 |
1. 將所有參數(除了 CheckCode)按字母順序排序
2. 組成 key=value&key=value 的字串
3. 在開頭加上 HashKey=xxx&,結尾加上 &HashIV=xxx
4. 進行 SHA256 雜湊後轉大寫
timingSafeEqual 防止 timing attack