PAYATHON 2026

如何将买家数据从 JSP 页面传输到 payment gateway?

支付阿杰

结论

支付金额、商品信息和订单状态都应由商户后端管理,不能直接信任最后一个 JSP 页面提交的数据。

常见的支付流程如下:

  1. 商户后端根据购物车重新查询商品价格,并计算优惠、税费和运费。
  2. 创建唯一订单,将最终金额写入订单记录。
  3. 后端调用支付网关 API 创建支付。
  4. 浏览器跳转到网关托管的收银台,或通过网关提供的前端 SDK 完成支付。
  5. 支付网关通过服务器回调(Webhook)通知支付结果。
  6. 商户后端验证签名,并核对订单号、金额和币种。确认一致后,再更新订单状态。

支付网关通常不需要了解商户的商品目录。双方主要通过商户订单号、支付金额、币种和网关支付编号关联同一笔交易。是否向网关传递商品明细,要看网关是否支持 line_items、description、metadata 等字段,以及业务是否需要这些信息。

JSP 页面在支付流程中的角色

JSP 主要用于展示页面。即使页面中包含金额、商品 ID 或用户 ID,这些值仍属于客户端输入。用户可以通过浏览器开发者工具修改它们,也可以自行构造 HTTP 请求。

下面的设计并不安全:

<form action="/pay" method="post">
    <input type="hidden" name="productId" value="P1001">
    <input type="hidden" name="amount" value="999.00">
    <input type="hidden" name="userId" value="12345">
    <button type="submit">支付</button>
</form>

攻击者可以把 amount 改成 0.01,也可以把 userId 换成其他账户。隐藏字段只是不会显示在页面上,并不能防止篡改。

页面可以提交购物车标识、订单号或一次性操作令牌,但后端仍要检查:

  • 当前登录用户是否拥有该购物车或订单;
  • 商品是否存在,是否允许购买;
  • 商品价格是否仍然有效;
  • 库存、优惠券、税费和运费是否正确;
  • 订单是否已经支付或关闭。

支付网关如何识别订单

支付网关通常保存支付交易,不负责保存商户完整的商品业务模型。一笔支付请求一般包含以下信息:

信息用途
商户订单号将网关交易关联到商户订单
金额指定本次需要收取的金额
币种防止相同数字被解释为不同货币
支付描述在网关后台或账单中辅助识别
回调地址支付完成后通知商户服务器
返回地址支付页面结束后将买家带回商户页面
幂等键避免网络重试创建重复支付
元数据保存内部订单号等非敏感辅助信息
商品明细仅在网关支持且业务需要时提供

例如,商户可以向网关提交:

{
  "merchant_order_id": "ORD-20260901-000123",
  "amount": 25900,
  "currency": "CNY",
  "description": "订单 ORD-20260901-000123",
  "metadata": {
    "internal_order_id": "84621"
  }
}

这里假设 amount 使用最小货币单位,所以 25900 表示 259.00 CNY。实际字段名、金额单位和签名方式,应以所选支付网关的官方文档为准。

网关不需要知道 P1001 是课程、会员还是实体商品。它只需按照协议收款,并在支付结果中返回双方约定的订单标识。商品 ID、定价规则、会员权益和库存变化仍由商户系统负责。

推荐的完整流程

1. 在商户后端核算订单

买家点击结算后,后端应从数据库读取购物车,不能直接使用 JSP 传来的单价和总金额。

商品小计
- 优惠金额
+ 税费
+ 运费
= 最终应付金额

计算金额时,应使用精确的十进制类型或最小货币单位,不能用 double 处理货币。

BigDecimal subtotal = new BigDecimal("299.00");
BigDecimal discount = new BigDecimal("50.00");
BigDecimal shipping = new BigDecimal("10.00");

BigDecimal payableAmount = subtotal
        .subtract(discount)
        .add(shipping)
        .setScale(2, RoundingMode.HALF_UP);

在 Java 中,也可以把金额统一转换为分:

long amountInCents = payableAmount
        .movePointRight(2)
        .longValueExact();

2. 创建待支付订单

商户数据库应保存订单快照,包括结算时的商品名称、单价、数量、优惠和最终金额。不能只保存商品 ID,等支付完成后再用可能已经变化的新价格重新计算。

订单的初始状态可以是:

PENDING_PAYMENT

订单还应有一个不可预测且全局唯一的商户订单号,例如:

ORD-20260901-000123

不要直接把连续递增的数据库主键作为对外使用的唯一凭证。

3. 由后端创建网关支付

下面是一个与具体厂商无关的 Java 示例。API 地址、认证头和字段名称只用于说明结构,实际接入时需要替换为支付网关规定的格式。

public PaymentSession createPayment(Order order) throws Exception {
    Map<String, Object> requestBody = new LinkedHashMap<>();
    requestBody.put("merchant_order_id", order.getOrderNumber());
    requestBody.put("amount", order.getAmountInMinorUnit());
    requestBody.put("currency", order.getCurrency());
    requestBody.put("description", "订单 " + order.getOrderNumber());
    requestBody.put(
        "return_url",
        "https://shop.example.com/payment/return"
    );
    requestBody.put(
        "webhook_url",
        "https://shop.example.com/api/payment/webhook"
    );

    String json = objectMapper.writeValueAsString(requestBody);

    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(paymentGatewayCreatePaymentUrl))
            .header("Authorization", "Bearer " + paymentGatewaySecret)
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", order.getOrderNumber())
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

    HttpResponse<String> response = httpClient.send(
            request,
            HttpResponse.BodyHandlers.ofString()
    );

    if (response.statusCode() < 200 || response.statusCode() >= 300) {
        throw new IllegalStateException(
                "创建支付失败,HTTP status: " + response.statusCode()
        );
    }

    return objectMapper.readValue(response.body(), PaymentSession.class);
}

密钥只能保存在后端的安全配置中,不能写入 JSP、JavaScript、HTML 或客户端应用。

网关返回的数据通常包含:

{
  "payment_id": "pay_xxxxx",
  "checkout_url": "https://gateway.example/checkout/xxxxx",
  "status": "pending"
}

商户应保存 payment_id 与订单号的对应关系,再让浏览器跳转到 checkout_url。

4. 买家在网关页面完成付款

使用托管收银台时,银行卡号等敏感数据会直接提交给支付网关,不经过商户服务器。这通常可以大幅缩小商户需要承担的支付卡数据安全范围。

protected void doPost(
        HttpServletRequest request,
        HttpServletResponse response
) throws IOException {
    User user = requireAuthenticatedUser(request);

    Order order = orderService.createOrderFromCart(user.getId());
    PaymentSession payment = paymentService.createPayment(order);

    orderService.attachGatewayPayment(
            order.getId(),
            payment.getPaymentId()
    );

    response.sendRedirect(payment.getCheckoutUrl());
}

如果使用嵌入式支付组件,也应通过网关官方 SDK 将银行卡数据转换为 token,再把 token 交给后端。不要自行接收或记录完整卡号、安全码等敏感信息。

支付结果应以 Webhook 为准

买家付款后,网关可能将浏览器重定向到:

https://shop.example.com/payment/return

但不能只根据返回页面确认支付成功。用户可能关闭浏览器,网络也可能中断,返回地址中的参数还可能被伪造。

更可靠的方式是由网关向商户后端发送 Webhook:

Payment Gateway
      |
      | HTTPS Webhook
      v
Merchant Backend
      |
      | 验签、核对订单和金额
      v
Update Order Status

Webhook 处理至少要完成这些检查:

  1. 使用网关规定的算法验证签名。
  2. 检查事件类型是否表示支付成功。
  3. 根据商户订单号或已保存的网关支付编号查找订单。
  4. 核对金额和币种。
  5. 检查订单当前状态。
  6. 使用事务或条件更新,避免重复通知导致重复发货。
  7. 按照网关协议返回成功响应。
@PostMapping("/api/payment/webhook")
public ResponseEntity<Void> handleWebhook(
        @RequestHeader("X-Gateway-Signature") String signature,
        @RequestBody byte[] rawBody
) {
    if (!gatewaySignatureVerifier.verify(rawBody, signature)) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    PaymentEvent event = paymentEventParser.parse(rawBody);

    if (!"payment.succeeded".equals(event.getType())) {
        return ResponseEntity.ok().build();
    }

    paymentService.confirmPayment(
            event.getMerchantOrderId(),
            event.getPaymentId(),
            event.getAmount(),
            event.getCurrency()
    );

    return ResponseEntity.ok().build();
}

X-Gateway-Signature、事件名称和验签算法都只是示例。实际实现必须严格按照网关文档,使用原始请求体、时间戳、证书或密钥进行验证。某些签名算法要求直接验证原始字节。如果先解析 JSON,再重新序列化,可能导致验签失败。

确认支付时,可以采用下面这样的条件更新:

UPDATE orders
SET status = 'PAID',
    gateway_payment_id = ?,
    paid_at = CURRENT_TIMESTAMP
WHERE order_number = ?
  AND status = 'PENDING_PAYMENT'
  AND amount_minor = ?
  AND currency = ?;

如果更新行数为 0,需要判断通知是否重复、订单状态是否已经改变,或金额和币种是否不匹配。在查明原因前不能直接发货。

是否需要把完整购物车发送给网关

商户需要在自己的系统中准备并核验完整购物车,但不一定要把全部购物车信息发送给支付网关。

两边需要保存的信息不同:

  • **商户系统必须保存:**完整商品明细、成交价格、优惠规则、库存、税费、运费、买家与收货信息。
  • **网关通常必须接收:**订单号、最终金额、币种,以及支付所需的其他必要参数。

有些网关会要求提供商品名称、数量或分类信息,用于电子钱包界面、分期付款、税务、风控或争议处理。在这种情况下可以发送商品明细,但这些数据仍不能代替商户自己的订单记录。

如果网关支持购物车明细,明细合计必须与最终支付金额一致:

Σ(商品单价 × 数量) - 优惠 + 税费 + 运费 = 支付金额

折扣分摊和舍入规则也要固定,否则商户与网关之间可能出现一分钱之类的差异。

用户账户信息应如何处理

不要把商户系统的密码、登录凭据或完整账户资料发送给支付网关。

通常只需提供完成交易所必需的信息,例如:

  • 商户内部订单号;
  • 买家的网关客户编号;
  • 必要的姓名、邮箱或手机号;
  • 账单地址或收货地址;
  • 风控所需的有限信息。

如果需要让网关记住买家的支付方式,应使用网关返回的 customer token 或 payment method token。商户数据库只保存 token 和允许保存的脱敏信息,不保存银行卡安全码。除非已经具备相应的合规条件,否则也不应自行保存完整卡号。

metadata 中也不能写入密码、银行卡号、身份证号或其他不必要的敏感数据。这些字段可能会出现在网关后台、日志或对账文件中。

安全和可靠性注意事项

  • 所有支付页面、接口和 Webhook 地址都必须使用 HTTPS。
  • 支付密钥只保存在服务器端,并按网关要求定期轮换。
  • 不信任 JSP 表单中的金额、折扣、用户 ID 和支付状态。
  • 创建支付和处理回调时都要实现幂等机制。
  • 支付成功前不要发货或开通服务。
  • 同时核对订单号、网关支付编号、金额和币种。
  • 分开处理浏览器返回结果和服务器支付通知。
  • 不直接拼接 JSON、URL 或 SQL,应使用序列化库、URI 构造器和参数化 SQL。
  • 日志中应屏蔽密钥、签名、支付凭据和个人敏感信息。
  • 为订单设置合理的支付有效期,并处理取消、超时、退款和部分退款。
  • 定期通过网关的订单查询或对账接口补偿遗漏的 Webhook。
  • 发起支付前使用 CSRF 防护,并确认订单属于当前登录用户。
  • Webhook 必须验签,只依赖来源 IP 白名单通常不够。
  • 商品库存紧张时,需要设计库存预占、超时释放和支付成功后的库存确认机制。

支付网关负责安全收款并报告交易结果,不负责管理商户的商品目录或购物车。商户要算清买家为什么支付这笔钱,网关则按指定金额完成收款。双方通过唯一订单号、支付编号、金额、币种、签名和回调保持交易状态一致。

备注:内容仅供参考。