payuni-webhook
8
总安装量
4
周安装量
#35444
全站排名
安装命令
npx skills add https://github.com/paid-tw/skills --skill payuni-webhook
Agent 安装分布
trae
4
gemini-cli
4
antigravity
4
claude-code
4
cursor
4
Skill 文档
çµ±ä¸éæµ 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 è«æ±ä¾é¤é¯