PAYATHON 2026

云端无法验证支付时,如何安全保存订单?

支付老李

结论

如果支付处理器既不提供服务端交易查询 API,也不发送带有可验证签名的 webhook 或支付凭证,服务端就无法可靠确认支付是否真的发生。

手机是不可信环境。App 提交的 paid: true、交易编号、hash、HMAC,甚至加密后的数据,都不能单独作为出票依据。服务端必须从支付处理器处获得独立且可验证的支付结果。若现有方案无法提供这种能力,就应更换支付方案,或将订单保留为“待人工核验”,不能自动出票。

还要注意,Card.io 通常用于扫描和识别银行卡信息,本身未必是负责扣款和结算的 payment processor。支付流程是否安全,最终取决于实际执行扣款的支付服务商。

为什么当前 hash 方案无效

普通 hash 只能检查数据是否意外损坏,不能证明数据来自可信客户端。攻击者修改明文后,可以自行重新计算 hash:

hash("orderId=123&paid=true")

即使换成带密钥的 HMAC:

HMAC-SHA256(secret, message)

只要 secret 存在于移动 App 中,攻击者就可能通过反编译、动态调试、hook 或读取运行时内存取得它。有了密钥,攻击者便能为任意数据生成合法 HMAC。

HTTPS 仍然必不可少,但它保护的是传输过程:

  • 防止网络中间人窃听或篡改请求;
  • 验证客户端连接的是正确服务器;
  • 无法阻止用户控制自己的手机并伪造 API 请求;
  • 无法证明支付处理器确实完成了扣款。

算法是否公开并不重要。现代密码系统通常默认算法是公开的,安全性取决于密钥是否保密。这里的问题在于,客户端无法安全保存一个对所有订单都有授权能力的长期密钥。

推荐的支付确认流程

可靠的支付确认流程如下:

App → Server:创建订单
Server → Payment Processor:创建 payment intent / transaction
Server → App:返回短期支付参数
App → Payment Processor:完成支付交互
Payment Processor → Server:发送签名 webhook
Server → Payment Processor:必要时再次查询交易
Server:核对订单信息并标记为已支付
App → Server:查询订单最终状态

具体可以分为以下步骤。

1. 服务端创建订单

商品、价格、币种和库存都应由服务端确定,不能接受客户端直接指定的最终应付金额。

订单的初始状态应为:

PENDING_PAYMENT

App 报告“支付成功”时,服务端也不能直接把订单改成 PAID。

2. 服务端创建支付交易

服务端调用支付处理器的 API,创建一笔与订单绑定的交易,并保存:

  • 内部 order_id;
  • 支付处理器的 payment_id;
  • 预期金额;
  • 币种;
  • 当前支付状态;
  • 创建时间和过期时间。

如果支付处理器支持 metadata,可以把内部订单号写入交易 metadata,但不能只依赖客户端传回的订单号。

3. App 只负责支付交互

App 可以使用支付处理器提供的 SDK 展示支付页面、执行银行卡验证或完成 3-D Secure 流程。

客户端回调中的 success 只能用来改善界面体验,例如显示“支付结果确认中”,不能直接触发出票:

async function onClientPaymentSuccess() {
  showMessage("支付结果确认中");
  await refreshOrderStatus();
}

客户端也可以通知服务端“请尽快检查这笔交易”,但这个请求只能触发查询,不能作为支付证明。

4. 服务端通过 webhook 确认支付

支付处理器应直接把支付结果发送给服务端。服务端需要按照服务商文档验证 webhook 签名,并使用原始请求体参与验签。

下面是一个通用结构。实际使用时,header 名称和签名算法必须替换为支付处理器官方规定的格式:

import express from "express";
import crypto from "node:crypto";

const app = express();

app.post(
  "/webhooks/payment",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const rawBody = req.body;
    const signature = req.get("X-Payment-Signature");

    if (!signature || !verifyWebhook(rawBody, signature)) {
      return res.sendStatus(400);
    }

    const event = JSON.parse(rawBody.toString("utf8"));

    // 先记录 event.id,并建立唯一约束,避免重复处理 webhook。
    const accepted = await registerEventOnce(event.id);
    if (!accepted) {
      return res.sendStatus(200);
    }

    if (event.type === "payment.succeeded") {
      await confirmPaidOrder(event.data);
    }

    return res.sendStatus(200);
  }
);

function verifyWebhook(rawBody, providedSignature) {
  const expected = crypto
    .createHmac("sha256", process.env.PAYMENT_WEBHOOK_SECRET)
    .update(rawBody)
    .digest("hex");

  const provided = Buffer.from(providedSignature, "hex");
  const calculated = Buffer.from(expected, "hex");

  return (
    provided.length === calculated.length &&
    crypto.timingSafeEqual(provided, calculated)
  );
}

这里的 HMAC 是有效的,因为 PAYMENT_WEBHOOK_SECRET 只保存在支付处理器和服务端,不会下发到手机。如果服务商使用非对称签名,应使用其公开密钥验签,不要自行设计签名协议。

5. 核对交易,而不只是核对签名

签名有效只能说明事件来自支付处理器。更新订单前,还要核对以下信息:

  • payment_id 是否与订单绑定;
  • 支付状态是否为处理器定义的最终成功状态;
  • 实付金额是否等于订单金额;
  • 币种是否一致;
  • 商户账户是否正确;
  • 交易是否已经退款、撤销或冲正;
  • 同一个 payment_id 是否已被其他订单使用;
  • webhook 是否重复发送。

状态更新应使用事务和条件更新:

UPDATE orders
SET
    status = 'PAID',
    paid_at = CURRENT_TIMESTAMP,
    payment_id = :payment_id
WHERE id = :order_id
  AND status = 'PENDING_PAYMENT'
  AND amount_minor = :amount_minor
  AND currency = :currency;

金额建议使用最小货币单位的整数,例如用分表示,不要使用浮点数:

{
  "amount_minor": 1999,
  "currency": "CNY"
}

只有成功更新一行后,才能进入出票流程。还应为 payment_id 和 webhook 的 event_id 建立唯一约束,以保证幂等性。

webhook 延迟时如何处理

客户端完成支付后,webhook 可能不会立刻到达。此时应返回“处理中”,不能提前把订单标记为已支付:

{
  "order_id": "ord_123",
  "status": "PAYMENT_PROCESSING"
}

App 可以轮询订单状态,也可以通过 WebSocket、Server-Sent Events 或推送通知接收更新。服务端还可以调用支付处理器的查询 API 主动核验,但查询结果仍须来自服务端与支付处理器之间的可信通信。

如果只能在手机上完成支付

只有满足以下任一条件,服务端才可能验证客户端提交的支付结果:

  1. 支付处理器签发了包含交易编号、金额、币种和商户信息的数字签名凭证,服务端可以使用处理器的公钥验证;
  2. 移动平台提供收据,服务端可以通过平台官方接口验证;
  3. 支付处理器提供服务端交易查询 API;
  4. 支付处理器向服务端发送带有可验证签名的 webhook。

服务端必须验证由支付处理器或平台签发的证明,而不是 App 自己签发的数据。

如果这些能力都不存在,那么握手、证书固定、代码混淆、设备指纹、root/jailbreak 检测和 App Attestation 都无法证明钱已经到账。这些措施可以增加攻击成本,减少自动化滥用,但不能代替支付验证。

还需要注意的安全问题

  • 不要自行存储银行卡号、有效期或 CVV,优先使用支付服务商提供的 tokenization。
  • 不要把支付处理器的服务端密钥放进 App。
  • 不要让客户端决定价格、折扣、库存或支付状态。
  • webhook 必须验证签名,并按照服务商要求检查时间戳和重放窗口。
  • 出票流程必须支持幂等处理,避免重复 webhook 导致重复出票。
  • 支付成功、出票成功和订单完成应使用不同状态,以便处理出票失败、退款和冲正。
  • 如果业务涉及银行卡数据,应确认所选集成方式对应的 PCI DSS 责任范围。仅使用 HTTPS 并不代表已经满足合规要求。

服务端不能依赖用户控制的设备来证明用户已经付款。订单能否出票,必须以支付处理器、支付平台或银行提供的服务端可验证事实为准。

备注:内容仅供参考。