如何在服务器端验证 Securionpay Checkout 支付成功
结论
不要根据 SecurionpayCheckout.success 返回的内容直接把订单标记为已支付。浏览器端数据可能被伪造,success 回调只能表示 Checkout 在前端报告了支付成功。
可靠的处理流程如下:
- 前端将支付结果中的 charge ID 发送给服务器。
- 服务器使用 SecurionPay 的 secret key 调用服务端 API,重新查询该 charge。
- 服务器检查支付状态、金额、币种和订单关联信息。
- 检查通过后,以幂等方式把订单标记为已支付。
- 同时接入 webhook,处理用户关闭页面、网络中断等情况。
你的服务器不需要,也不应该接触银行卡信息。
为什么不能信任 success 回调
SecurionpayCheckout.success 运行在用户的浏览器中。攻击者可以绕过 Checkout,直接调用订单接口并提交伪造的 result。
因此,下面的处理方式并不安全:
SecurionpayCheckout.success = function (result) {
fetch('/orders/mark-as-paid', {
method: 'POST',
body: JSON.stringify(result)
});
};
问题并不取决于 result 中是否包含自定义随机变量。如果服务器只检查浏览器提交的数据,那么随机变量乃至整个请求都可以被复制或伪造。
服务器应该以 SecurionPay 服务端 API 返回的数据为准,而不是信任浏览器。
推荐的支付流程
创建订单时,服务器应先保存以下信息:
- 内部订单号
- 应付金额
- 币种
- 当前状态,例如
pending - 与支付关联的唯一标识
- 已处理的 charge ID,用于防止重复入账
随后由服务器生成签名后的 checkoutRequest。金额和币种必须根据服务器中的商品和订单数据计算,不能直接采用浏览器提交的价格。
支付完成后,前端只需把 charge ID 发送给服务器:
SecurionpayCheckout.success = async function (result) {
// 具体字段位置取决于 Checkout 返回对象的实际结构。
// 某些版本可能直接返回 charge,某些版本可能将其放在嵌套字段中。
const chargeId = result.id || result.charge?.id;
if (!chargeId) {
console.error('Checkout result does not contain a charge ID');
return;
}
const response = await fetch('/api/orders/confirm-payment', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'same-origin',
body: JSON.stringify({
orderId: CURRENT_ORDER_ID,
chargeId
})
});
const confirmation = await response.json();
if (confirmation.paid) {
window.location.href = `/orders/${CURRENT_ORDER_ID}/success`;
}
};
CURRENT_ORDER_ID 也不能证明支付已经完成。它只用于告诉服务器需要核对哪一笔订单。
服务器端如何验证
服务器收到 orderId 和 chargeId 后,应使用仅保存在服务器端的 SecurionPay secret key 查询 charge。下面是与具体语言和 SDK 无关的示意代码:
async function confirmPayment(orderId, chargeId, currentUser) {
const order = await orders.findById(orderId);
if (!order || order.userId !== currentUser.id) {
throw new Error('Order not found');
}
// 使用 SecurionPay 服务端 SDK 和 secret key 查询,不信任前端提交的 charge 内容
const charge = await securionPay.charges.retrieve(chargeId);
if (!charge) {
throw new Error('Charge not found');
}
const paymentSucceeded =
charge.id === chargeId &&
charge.amount === order.amount &&
charge.currency === order.currency &&
charge.captured === true &&
charge.refunded !== true;
if (!paymentSucceeded) {
throw new Error('Payment verification failed');
}
// 必须确保同一个 charge 不能支付多个订单
const existingOrder = await orders.findByChargeId(charge.id);
if (existingOrder && existingOrder.id !== order.id) {
throw new Error('Charge already belongs to another order');
}
// 应放在数据库事务中,并保证重复调用不会重复发货
await orders.markPaidOnce(order.id, {
chargeId: charge.id,
paidAmount: charge.amount,
paidCurrency: charge.currency
});
return { paid: true };
}
这些字段只用于说明验证逻辑。表示成功、授权、捕获或退款状态的实际字段,应以当前使用的 SecurionPay API 或 SDK 返回结构为准。例如,如果 Checkout 创建的 charge 只完成了授权,尚未捕获,就不能把“已授权”直接视为“已收款”。
如何把支付和订单关联起来
生成一个变量并存入订单可以用来关联支付,但不能单独证明支付成功。
可以按以下方式处理:
- 在服务端生成订单号或不可预测的支付关联值。
- 如果 Checkout 的 charge 请求支持
metadata,将内部订单号写入 metadata。 - 查询 charge 后,由服务器检查 metadata 中的订单号是否匹配。
- 无论是否使用 metadata,都要检查金额和币种。
示意:
const checkoutData = {
charge: {
amount: order.amount,
currency: order.currency,
description: `Order ${order.id}`,
metadata: {
orderId: order.id
}
}
};
只有当前 SecurionPay API 版本明确支持相应的 metadata 字段时,才能采用这种方式。metadata 用于关联支付并辅助核对,支付状态仍需通过 SecurionPay 服务端查询确认。
为什么还需要 webhook
如果只依赖前端回调,一些正常完成的支付可能无法及时写入订单:
- 支付成功后,用户直接关闭了窗口。
- 浏览器断网,无法调用确认接口。
- 前端请求超时。
- 支付状态后来发生变化,例如退款或争议。
生产环境应将 webhook 作为同步支付状态的重要入口。收到 webhook 后:
- 按照官方机制验证 webhook 的签名或真实性。
- 根据事件中的 charge ID 查询或核对支付记录。
- 检查金额、币种和订单关联信息。
- 以幂等方式更新订单状态。
前端确认接口用于让用户立即看到结果,webhook 用于补偿前端回调遗漏的情况。两条路径可能处理同一个 charge,因此订单更新必须具备幂等性。
安全注意事项
pk_test_...是 publishable key,可以放在前端。- secret key 只能保存在服务器端,不能写入 JavaScript、HTML 或日志。
- 金额和币种必须由服务器根据订单计算。
- 不要把浏览器返回的完整
result当作可信的支付记录。 - 不能只检查 charge 是否存在,还要检查金额、币种、支付状态和订单归属。
- 为 charge ID 建立唯一约束,避免同一笔付款被用于多个订单。
- 测试环境与生产环境的 key、订单和 charge 必须严格分开。
- 不要存储卡号、有效期或 CVC,这些信息应由 Checkout 和支付服务商处理。
success 回调用于通知前端,charge ID 用于定位交易。只有服务器使用 secret key 查询到的 charge,才能作为订单入账的依据。
备注:内容仅供参考。
