payuni-webhook

你的任務是在用戶的專案中實作統一金流 Webhook 接收與處理功能。

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "payuni-webhook" with this command: npx skills add paid-tw/skills/paid-tw-skills-payuni-webhook

統一金流 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}&#x26;${paramStr}&#x26;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&#x3C;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 請求供除錯

詳細參考文件

  • 程式碼範例

  • 回應參數說明

  • 疑難排解

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

newebpay

No summary provided by upstream source.

Repository SourceNeeds Review
General

ecpay

No summary provided by upstream source.

Repository SourceNeeds Review
General

newebpay-checkout

No summary provided by upstream source.

Repository SourceNeeds Review
General

newebpay-query

No summary provided by upstream source.

Repository SourceNeeds Review