你將構建咩
完成本教程後,你將擁有一個喺NestJS + Next.js技術棧中可用嘅Dragonpay整合,支援:
- Collect(代收),透過Dragonpay嘅支付頁面接收客戶嘅付款
- Payout(代付),將資金發放去收款人嘅銀行帳戶或電子錢包
本文涵蓋完整流程:生成支付URL、驗證回傳、處理訂單狀態、喺Next.js中搭建輪詢頁面,以及官方文件冇提到嘅關鍵Payout坑點。
前置條件
- 一個Dragonpay商戶帳號(測試用嘅沙盒憑證)
- 已設定好
ConfigService嘅NestJS後端 - Next.js前端(App Router)
- 環境變數:
DRAGONPAY_MERCHANT_ID、DRAGONPAY_PASSWORD、DRAGONPAY_BASE_URL、DRAGONPAY_PAYOUT_URL
第一部分:Dragonpay Collect
第一步:理解Collect流程
喺寫任何代碼之前,先將呢個流程記喺個腦入面:
- 顧客落單,你嘅後端建立一個
pending狀態嘅訂單,並生成一個Dragonpay支付URL - 顧客被重新導向去Dragonpay托管嘅支付頁面
- 顧客完成(或放棄)支付
- Dragonpay向你註冊嘅URL觸發一個GET回傳
- 你嘅後端驗證回傳嘅digest、更新訂單狀態,顧客被重新導向去成功或失敗頁面
關鍵點在於:當顧客開始俾錢嘅時候,你嘅訂單仲未存在。你建立嘅係一個以txnid為鍵嘅購物車快照,只有喺回傳確認成功之後,先將佢轉換成真正嘅訂單。喺寫任何支付代碼之前,先將呢個流程設計好。
第二步:生成Dragonpay支付URL
支付URL需要一個SHA-1摘要(digest)——按特定順序對特定欄位做雜湊,並用你嘅商戶密碼作為鹽值。呢度整錯咗,Dragonpay會悄無聲息咁拒絕呢個請求。
建立dragonpay.service.ts:
import * as crypto from 'crypto';
generateCheckoutUrl(params: {
txnid: string;
amount: number;
currency: string;
description: string;
email: string;
}): string {
const { txnid, amount, currency, description, email } = params;
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
const base = this.configService.get('DRAGONPAY_BASE_URL'); // e.g. https://gw.dragonpay.ph/Pay.aspx
const amountFormatted = amount.toFixed(2);
// Digest order: merchantId:txnid:amount:currency:description:email:password
const digestStr = [merchantId, txnid, amountFormatted, currency, description, email, password].join(':');
const digest = crypto.createHash('sha1').update(digestStr).digest('hex');
const queryParams = new URLSearchParams({
merchantid: merchantId,
txnid,
amount: amountFormatted,
ccy: currency,
description,
email,
digest,
});
return `${base}?${queryParams.toString()}`;
}
注意: description欄位有字符數限制,並會移除特殊字符。保持簡短、純ASCII字符。如果Dragonpay嗰邊將佢截斷咗,佢哋計出嚟嘅摘要同你嘅對唔上,交易就會悄無聲息咁失敗。
第三步:重新導向之前保存購物車快照
喺將顧客重新導向去Dragonpay之前,先將佢哋要買嘅嘢持久化落嚟。等回傳到達嗰陣(可能係顧客離開你網站幾秒後,亦可能係幾分鐘後)你會需要呢份數據。
// In your orders or payments service
async initiateCheckout(cartItems: CartItem[], email: string) {
const txnid = generateUniqueTxnId(); // e.g. ulid() or uuid()
const amount = calculateTotal(cartItems);
// Save cart snapshot keyed by txnid
await this.cartSnapshotRepository.save({ txnid, cartItems, email, status: 'pending' });
const paymentUrl = this.dragonpayService.generateCheckoutUrl({
txnid,
amount,
currency: 'PHP',
description: 'Order payment',
email,
});
return { paymentUrl, txnid };
}
第四步:搭建回傳端點
每筆交易之後,Dragonpay都會向你嘅回傳URL觸發一個GET請求。你嘅端點必須:
- 驗證佢哋發嚟嘅摘要
- 返回字面字串
result=OK或result=error(唔係JSON,唔係HTTP狀態碼,就係呢個精確嘅字串)
喺payments.controller.ts入面:
@Public() // bypass JWT auth - this comes from Dragonpay, not your users
@Get('dragonpay/postback')
async handleDragonpayPostback(@Query() query: DragonpayPostbackDto) {
const { txnid, refno, status, message, digest } = query;
const isValid = this.dragonpayService.validatePostbackDigest({ txnid, refno, status, message, digest });
if (!isValid) {
this.logger.warn(`Invalid postback digest for txnid: ${txnid}`);
return 'result=error';
}
await this.paymentsService.handleDragonpayPostback({ txnid, refno, status });
return 'result=OK';
}
喺dragonpay.service.ts入面,加入摘要驗證器:
validatePostbackDigest(params: {
txnid: string; refno: string; status: string; message: string; digest: string;
}): boolean {
const { txnid, refno, status, message, digest } = params;
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
// Postback digest order: merchantId:txnid:refno:status:message:password
const digestStr = [merchantId, txnid, refno, status, message, password].join(':');
const expected = crypto.createHash('sha1').update(digestStr).digest('hex');
return expected === digest;
}
第五步:冪等咁處理回傳
Dragonpay真係會觸發重複回傳。你嘅處理程式必須可以安全咁對同一個txnid被調用多次。
// payments.service.ts
async handleDragonpayPostback(params: { txnid: string; refno: string; status: string }) {
const { txnid, status } = params;
// S = Success, F = Failure, U = Unknown, V = Void, others exist
if (status !== 'S') {
await this.cartSnapshotRepository.markFailed(txnid);
return;
}
const snapshot = await this.cartSnapshotRepository.findByTxnid(txnid);
if (!snapshot) {
this.logger.error(`No cart snapshot for txnid: ${txnid}`);
return;
}
// Guard: skip if already fulfilled (idempotency)
if (snapshot.status === 'fulfilled') return;
// Create the real order inside a DB transaction
await this.dataSource.transaction(async (manager) => {
const order = await this.ordersService.createFromSnapshot(snapshot, manager);
await this.cartSnapshotRepository.markFulfilled(txnid, order.id, manager);
});
}
第六步:搭建Next.js等待頁面
將顧客重新導向去Dragonpay之後,畀佢哋展示一個「等待確認」嘅畫面。每隔幾秒輪詢一次你嘅後端,直到訂單被確認或失敗。
// app/payment/waiting/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
export default function PaymentWaitingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const txnid = searchParams.get('txnid');
const [message, setMessage] = useState('Waiting for payment confirmation...');
useEffect(() => {
if (!txnid) return;
// Auto-timeout after 10 minutes
const timeout = setTimeout(() => {
clearInterval(interval);
setMessage('Payment confirmation timed out. Please contact support.');
}, 10 * 60 * 1000);
const interval = setInterval(async () => {
const res = await fetch(`/api/payments/status?txnid=${txnid}`);
const data = await res.json();
if (data.status === 'paid') {
clearInterval(interval);
clearTimeout(timeout);
router.push(`/orders/${data.orderId}`);
} else if (data.status === 'failed') {
clearInterval(interval);
clearTimeout(timeout);
router.push('/payment/failed');
}
}, 3000);
return () => {
clearInterval(interval);
clearTimeout(timeout);
};
}, [txnid]);
return {message}
;
}
你嘅後端需要一個狀態端點,將txnid對應去目前訂單狀態。一定要確保呢條路由喺NestJS中註冊喺任何:id萬用字符路由之前,否則會被參數匹配器吞咗。
第二部分:Dragonpay Payout
第七步:先做呢一步——發電郵畀Dragonpay,註冊你嘅回傳URL
呢一步喺邊度都冇文件說明。Dragonpay Payout冇自助式嘅webhook設定。你必須聯絡佢哋嘅支援團隊,叫佢哋喺佢哋嗰邊手動註冊你嘅payout回傳URL。
如果跳過呢一步,唔會有任何錯誤、警告,亦唔會有失敗回應——你嘅payout API調用會成功,但回傳就係永遠唔會到達。呢一點極其容易被忽略。
喺寫任何Payout代碼之前先做呢件事。喺你嘅第一封電郵入面就附上你嘅回傳URL。呢個過程可能需要一啲時間,而且喺完成之前你冇辦法做端到端測試。
第八步:發起一次payout請求
Payout使用嘅身份驗證機制同Collect完全唔同。佢唔係基於摘要嘅URL簽署,而係用你嘅商戶憑證做HTTP Basic Auth。
// dragonpay.service.ts
async initiatePayout(params: {
txnid: string;
amount: number;
currency: string;
description: string;
recipientName: string;
bankCode: string; // Dragonpay processor ID, e.g. 'BPIA', 'GCSH'
accountNumber: string;
}): Promise<{ refno: string; status: string }> {
const merchantId = this.configService.get('DRAGONPAY_MERCHANT_ID');
const password = this.configService.get('DRAGONPAY_PASSWORD');
const baseUrl = this.configService.get('DRAGONPAY_PAYOUT_URL');
const auth = Buffer.from(`${merchantId}:${password}`).toString('base64');
const response = await fetch(`${baseUrl}/api/collect/v1/${merchantId}/disburse`, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
TxnId: params.txnid,
Amount: params.amount.toFixed(2),
Currency: params.currency,
Description: params.description,
ProcId: params.bankCode,
Param1: params.recipientName,
Param2: params.accountNumber,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Dragonpay Payout failed: ${error}`);
}
return response.json();
}
第九步:處理payout回傳
Dragonpay註冊好你嘅回傳URL之後(第七步),每次payout完成都會向嗰個URL觸發一個GET請求,格式同Collect回傳一樣,摘要驗證方式都一樣。
// payments.controller.ts
@Public()
@Get('dragonpay/payout-postback')
async handlePayoutPostback(@Query() query: DragonpayPostbackDto) {
const { txnid, refno, status, message, digest } = query;
const isValid = this.dragonpayService.validatePostbackDigest({ txnid, refno, status, message, digest });
if (!isValid) {
this.logger.warn(`Invalid payout postback digest for txnid: ${txnid}`);
return 'result=error';
}
await this.payoutsService.handlePostback({ txnid, refno, status });
return 'result=OK';
}
要明確處理每一種狀態碼,唔好將非S狀態一律當作失敗處理:
S,成功:標記payout已完成F,失敗:標記為失敗,展示畀操作人員U,未知:記錄日誌,加入人工審核或重試隊列,唔好自動判定為失敗V,作廢:標記為已作廢
參考資料:需要記住嘅關鍵規則
Collect摘要欄位順序(用於生成支付URL):
merchantId : txnid : amount : currency : description : email : password
回傳摘要欄位順序(Collect同Payout通用):
merchantId : txnid : refno : status : message : password
回傳回應字串(必須精確,唔係JSON):
result=OK
result=error
身份驗證嘅區別:
- Collect,摘要放喺URL查詢參數入面
- Payout,HTTP Basic Auth請求標頭
如果唔提前知道會踩坑嘅三件事:
- 喺寫任何Payout代碼之前,先發電郵畀Dragonpay註冊你嘅payout回傳URL
- Collect同Payout並唔對稱,身份驗證、設定方式、坑點都唔一樣
- 喺動手做支付整合之前,先設計好訂單嘅狀態機(pending、confirmed、failed)