你将构建什么
完成本教程后,你将拥有一个在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)