PAYATHON 2026

如何在服务器端验证 Securionpay Checkout 支付成功

支付老李

结论

不要根据 SecurionpayCheckout.success 返回的内容直接把订单标记为已支付。浏览器端数据可能被伪造,success 回调只能表示 Checkout 在前端报告了支付成功。

可靠的处理流程如下:

  1. 前端将支付结果中的 charge ID 发送给服务器。
  2. 服务器使用 SecurionPay 的 secret key 调用服务端 API,重新查询该 charge。
  3. 服务器检查支付状态、金额、币种和订单关联信息。
  4. 检查通过后,以幂等方式把订单标记为已支付。
  5. 同时接入 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 后:

  1. 按照官方机制验证 webhook 的签名或真实性。
  2. 根据事件中的 charge ID 查询或核对支付记录。
  3. 检查金额、币种和订单关联信息。
  4. 以幂等方式更新订单状态。

前端确认接口用于让用户立即看到结果,webhook 用于补偿前端回调遗漏的情况。两条路径可能处理同一个 charge,因此订单更新必须具备幂等性。

安全注意事项

  • pk_test_... 是 publishable key,可以放在前端。
  • secret key 只能保存在服务器端,不能写入 JavaScript、HTML 或日志。
  • 金额和币种必须由服务器根据订单计算。
  • 不要把浏览器返回的完整 result 当作可信的支付记录。
  • 不能只检查 charge 是否存在,还要检查金额、币种、支付状态和订单归属。
  • 为 charge ID 建立唯一约束,避免同一笔付款被用于多个订单。
  • 测试环境与生产环境的 key、订单和 charge 必须严格分开。
  • 不要存储卡号、有效期或 CVC,这些信息应由 Checkout 和支付服务商处理。

success 回调用于通知前端,charge ID 用于定位交易。只有服务器使用 secret key 查询到的 charge,才能作为订单入账的依据。

备注:内容仅供参考。