如何将买家数据从 JSP 页面传输到 payment gateway?
结论
支付金额、商品信息和订单状态都应由商户后端管理,不能直接信任最后一个 JSP 页面提交的数据。
常见的支付流程如下:
- 商户后端根据购物车重新查询商品价格,并计算优惠、税费和运费。
- 创建唯一订单,将最终金额写入订单记录。
- 后端调用支付网关 API 创建支付。
- 浏览器跳转到网关托管的收银台,或通过网关提供的前端 SDK 完成支付。
- 支付网关通过服务器回调(Webhook)通知支付结果。
- 商户后端验证签名,并核对订单号、金额和币种。确认一致后,再更新订单状态。
支付网关通常不需要了解商户的商品目录。双方主要通过商户订单号、支付金额、币种和网关支付编号关联同一笔交易。是否向网关传递商品明细,要看网关是否支持 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 处理至少要完成这些检查:
- 使用网关规定的算法验证签名。
- 检查事件类型是否表示支付成功。
- 根据商户订单号或已保存的网关支付编号查找订单。
- 核对金额和币种。
- 检查订单当前状态。
- 使用事务或条件更新,避免重复通知导致重复发货。
- 按照网关协议返回成功响应。
@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 白名单通常不够。
- 商品库存紧张时,需要设计库存预占、超时释放和支付成功后的库存确认机制。
支付网关负责安全收款并报告交易结果,不负责管理商户的商品目录或购物车。商户要算清买家为什么支付这笔钱,网关则按指定金额完成收款。双方通过唯一订单号、支付编号、金额、币种、签名和回调保持交易状态一致。
备注:内容仅供参考。
