PAYATHON 2026

如何设计安全的 Web 支付架构?

支付老李

结论

建议采用“支付服务商托管字段或官方 JavaScript SDK + 支付令牌 + Java 服务端确认支付”的架构:

  1. 浏览器使用支付服务商提供的 Hosted Fields、iframe 或官方 SDK 收集银行卡信息。
  2. 卡号、有效期和 CVV 直接发送给支付服务商,不经过你的 AJAX 接口或 Java 服务器。
  3. 支付服务商返回一次性 token、payment method ID 或 payment intent ID。
  4. 浏览器将该标识提交给 RESTEasy 接口。
  5. Java 服务端读取数据库中的订单金额,并调用支付服务商 API。
  6. 支付的最终状态以通过签名验证的 webhook 为准。

客户端验证可以改善填写体验,却不能代替服务端验证。订单金额、商品价格、优惠、库存、币种、用户权限和支付状态都要由服务端重新核验。

只有在支付服务商不提供安全令牌化方案时,才考虑让 Java 服务端接收并转发原始支付数据。这种做法会显著扩大 PCI DSS 范围,不应只为获得更高的页面定制自由度而优先采用。

推荐的数据流

浏览器
  ├─ 从 Java 服务端获取订单和支付会话
  ├─ 在 Hosted Fields 中输入银行卡信息
  ├─ 银行卡信息 ───────────────> Payment Processor
  ├─ 获得一次性 payment token
  └─ token + orderId ─────────> Java REST API
                                      │
                                      ├─ 校验用户、订单、金额、库存
                                      ├─ 使用服务端密钥调用支付 API
                                      ▼
                               Payment Processor
                                      │
                                      ├─ 返回同步处理结果
                                      └─ 发送签名 webhook
                                               │
                                               ▼
                                        Java Webhook API
                                               │
                                               └─ 更新订单最终状态

这种方式既保留了页面定制能力,也能减少应用直接接触银行卡数据的机会。Hosted Fields 通常允许定制外围布局、字体、颜色和错误提示,真正承载银行卡字段的 iframe 仍由支付服务商控制。

PCI DSS 评估范围能否缩小,要看支付服务商的集成模式、脚本加载方式和当前适用的 PCI DSS 要求。不能因为使用了 iframe,就直接认定系统符合某一种 SAQ。上线前应请收单机构、支付服务商或合格的 PCI 顾问确认。

客户端与服务端分别验证什么

客户端验证

JavaScript 可以检查:

  • 必填项是否为空;
  • 姓名、邮编等字段的基本格式;
  • Hosted Fields 返回的卡号、有效期和 CVV 状态;
  • 用户是否重复点击支付按钮;
  • 用户是否已经同意付款条款。

这些检查用于尽早提示用户。由于客户端数据随时可能被修改,不能据此作出安全决策。

服务端验证

Java 服务端必须检查:

  • 当前用户是否有权支付该订单;
  • 订单是否仍处于可支付状态;
  • 商品价格、税费、运费、折扣和币种;
  • 库存或服务是否仍然可用;
  • payment token 是否属于当前支付流程;
  • 同一订单是否已经支付;
  • 请求是否重复;
  • 支付服务商的返回结果和 webhook 签名。

服务端不能直接采用浏览器提交的金额。浏览器最多提交 orderId 和一次性支付标识,实际扣款金额应根据服务端保存的订单数据计算。

浏览器端示例

不同支付服务商使用的 SDK 名称和参数并不相同。下面只演示通用结构,不能直接作为任何一家支付服务商的生产代码:

<form id="payment-form">
  <div id="card-number"></div>
  <div id="card-expiry"></div>
  <div id="card-cvv"></div>

  <p id="payment-error" role="alert"></p>
  <button id="pay-button" type="submit">确认支付</button>
</form>

<script>
  const form = document.getElementById("payment-form");
  const button = document.getElementById("pay-button");
  const errorBox = document.getElementById("payment-error");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    button.disabled = true;
    errorBox.textContent = "";

    try {
      // processorSdk 仅为占位名称。
      // 银行卡数据应由支付服务商托管的字段直接收集。
      const tokenResult = await processorSdk.createPaymentToken();

      if (tokenResult.error) {
        throw new Error(tokenResult.error.message);
      }

      const response = await fetch("/api/payments/confirm", {
        method: "POST",
        credentials: "same-origin",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": getCsrfToken()
        },
        body: JSON.stringify({
          orderId: "ORDER_ID_FROM_SERVER",
          paymentToken: tokenResult.token
        })
      });

      const result = await response.json();

      if (!response.ok) {
        throw new Error(result.message || "支付请求未完成");
      }

      if (result.actionRequired) {
        // 例如 3-D Secure。具体调用方式由支付服务商 SDK 决定。
        await processorSdk.handleNextAction(result.clientSecret);
      }

      window.location.assign("/orders/" + result.orderId);
    } catch (error) {
      errorBox.textContent = error.message;
      button.disabled = false;
    }
  });
</script>

不要自行读取卡号输入框,再通过普通 AJAX 将数据发送到 Java 接口。否则,应用服务器、反向代理、监控系统和日志系统都有可能接触原始卡数据。

支付页面还应配置严格的 Content Security Policy,并限制第三方脚本。支付页面中的 JavaScript 一旦被篡改,攻击者就可能在令牌化前窃取银行卡信息,因此前端供应链也属于支付安全的一部分。

RESTEasy 服务端示例

下面的示例用于说明各部分的职责。支付接口地址、认证方式和请求字段均为占位内容。实际开发时,应优先使用支付服务商维护的 Java SDK 或参考其官方 API 文档。

import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;

@Path("/payments")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PaymentResource {

    @Inject
    OrderService orderService;

    @Inject
    PaymentService paymentService;

    @POST
    @Path("/confirm")
    public Response confirmPayment(PaymentRequest request) {
        User currentUser = requireAuthenticatedUser();

        Order order = orderService.getPayableOrder(
            request.orderId(),
            currentUser.id()
        );

        // 金额和币种来自服务端订单,不能信任浏览器提交的数据。
        PaymentResult result = paymentService.charge(
            order.id(),
            order.totalAmount(),
            order.currency(),
            request.paymentToken()
        );

        return Response.ok(new PaymentResponse(
            order.id(),
            result.status(),
            result.actionRequired(),
            result.clientSecret()
        )).build();
    }

    private User requireAuthenticatedUser() {
        // 接入现有认证系统。
        throw new UnsupportedOperationException("Application-specific");
    }
}

record PaymentRequest(String orderId, String paymentToken) {}

record PaymentResponse(
    String orderId,
    String status,
    boolean actionRequired,
    String clientSecret
) {}

服务端调用支付服务商时,应设置较短的连接超时,并使用幂等键和明确的错误处理。Java 11 及以上版本可以使用 java.net.http.HttpClient。如果支付服务商提供维护良好的官方 Java SDK,通常应优先使用该 SDK。

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class ProcessorClient {

    private final HttpClient httpClient = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(5))
        .build();

    private final URI paymentEndpoint;
    private final String secretKey;

    public ProcessorClient(URI paymentEndpoint, String secretKey) {
        this.paymentEndpoint = paymentEndpoint;
        this.secretKey = secretKey;
    }

    public HttpResponse<String> createPayment(
        String requestJson,
        String idempotencyKey
    ) throws Exception {

        HttpRequest request = HttpRequest.newBuilder(paymentEndpoint)
            .timeout(Duration.ofSeconds(15))
            .header("Authorization", "Bearer " + secretKey)
            .header("Content-Type", "application/json")
            .header("Idempotency-Key", idempotencyKey)
            .POST(HttpRequest.BodyPublishers.ofString(requestJson))
            .build();

        return httpClient.send(
            request,
            HttpResponse.BodyHandlers.ofString()
        );
    }
}

服务端密钥只能保存在服务器的密钥管理系统或受控配置中,不能写入 JavaScript、HTML、移动端代码或 Git 仓库。

为什么不能只依赖同步响应

支付请求超时不等于支付失败。例如:

  1. Java 服务端将请求发送给支付服务商;
  2. 支付服务商已经完成扣款;
  3. 网络在响应返回前中断;
  4. 网站误将超时判断为失败;
  5. 用户再次点击,导致重复扣款。

每次支付都应使用稳定且唯一的幂等键,例如根据订单和支付尝试生成:

payment:{orderId}:{attemptId}

重试同一次支付尝试时,必须复用相同的键,不能为每次请求生成新值。

订单状态也不能只有“成功”和“失败”,至少应能表达:

CREATED
  -> PAYMENT_PENDING
  -> ACTION_REQUIRED
  -> PAID
  -> PAYMENT_FAILED
  -> CANCELLED
  -> REFUNDED

同步 API 响应可以用来更新页面,但订单的最终状态应由支付服务商发送的签名 webhook 确认。Webhook 处理程序需要:

  • 使用原始请求体验证签名;
  • 检查事件时间戳和重放风险;
  • 根据事件 ID 去重;
  • 正确处理同一事件的重复投递;
  • 正确处理事件乱序到达;
  • 在数据库事务中更新订单;
  • 尽快返回成功响应,并将耗时业务放入队列。

是否可以让 FORM 直接 POST 到支付服务商

只有支付服务商明确提供以下集成方案时,才应采用这种做法:

  • Hosted Payment Page;
  • Hosted Fields;
  • Direct Post;
  • 浏览器端 tokenization;
  • 官方 JavaScript SDK。

不要自行设计普通 HTML FORM,将原始卡数据 POST 到任意支付 API。这种做法常见的问题包括:

  • 浏览器无法安全保存支付服务商的私密 API 密钥;
  • CORS 和响应跳转流程很难正确处理;
  • 页面脚本仍有可能接触银行卡数据;
  • 错误重试容易引发重复支付;
  • 订单状态难以可靠维护;
  • PCI DSS 范围可能大于预期。

如果支付服务商的 Direct Post 模式要求浏览器直接提交卡数据,也必须严格按照其官方流程实现,并确认回调签名、令牌有效期、允许的来源和 PCI DSS 责任边界。

如果必须让 Java 服务端接收卡数据

这种模式通常会使应用进入更严格的 PCI DSS 范围。需要纳入控制的可能不只是支付接口,还包括整个相关环境:

  • Web 服务器和 Java 应用;
  • 负载均衡器、WAF 和反向代理;
  • 日志、APM、错误追踪和请求录制系统;
  • CI/CD、依赖管理和源代码权限;
  • 运维终端和管理员访问;
  • 漏洞扫描、渗透测试和补丁管理;
  • 网络分段、密钥管理和事件响应。

即使采用这种模式,也不能存储 CVV。没有明确业务必要时,也不应保存银行卡号。确需保存时,要采用符合要求的加密、密钥隔离、访问审计和数据保留策略。普通数据库加密或字段哈希并不会自动满足 PCI DSS。

上线前的检查

  • 全站使用 HTTPS,并正确配置安全 Cookie。
  • 支付接口要求用户登录,并执行 CSRF 防护。
  • 服务端重新计算金额、币种、税费、运费和优惠。
  • 使用支付服务商的 Hosted Fields 或官方 tokenization SDK。
  • 确保原始卡号和 CVV 不进入应用日志、异常信息或分析系统。
  • 禁止支付页面加载不必要的第三方脚本。
  • 为支付请求设置连接超时和响应超时。
  • 使用幂等键防止重复扣款。
  • 验证 webhook 签名,对事件去重,并处理乱序事件。
  • 不将浏览器跳转参数视为付款成功的证明。
  • 为退款、撤销、拒付和异步失败建立独立流程。
  • 在支付服务商的测试环境中验证成功、拒绝、超时、重复请求和 3-D Secure 等场景。
  • 请支付服务商或 PCI 专业人员确认实际合规范围。

PHP 示例不是必要条件。HTTPS API、令牌化、签名验证、幂等处理和订单状态管理才是支付流程的主要组成部分,这些都可以使用 RESTEasy 和 Java 完成。需要优先确认的是,支付服务商是否提供官方 Java SDK、Hosted Fields、tokenization、3-D Secure 支持和签名 webhook。

备注:内容仅供参考。