PAYATHON 2026

如何在 Java 项目中集成 Payment gateway?

支付小周

结论

Java 项目接入 Payment gateway(支付网关)时,通常不直接处理银行卡信息,而是通过支付服务商提供的 SDK 或 HTTPS API 完成支付流程:

  1. 服务端创建支付订单;
  2. 前端跳转到托管收银台,或调用服务商提供的支付组件;
  3. 支付服务商异步通知支付结果;
  4. 服务端验签并查询订单,确认最终状态;
  5. 根据确认结果发货、开通服务或更新账务。

实际项目应优先使用服务商的官方 Java SDK 和托管收银台,减少敏感支付数据进入业务系统,从而降低安全和合规成本。接口地址、签名算法和字段名称由服务商定义,应以其当前官方文档为准。

Payment gateway 是什么

Payment gateway 位于商户系统与银行、银行卡组织、电子钱包或收单机构之间,主要用于:

  • 创建和管理支付交易;
  • 安全传输支付请求;
  • 对支付参数进行签名和验证;
  • 处理支付授权、扣款、退款和撤销;
  • 返回同步结果并发送异步通知;
  • 提供交易查询、对账和风险控制能力。

支付网关不一定负责最终的资金结算。有些服务商同时提供网关、收单和结算服务,有些则只提供其中一部分。

常见支付网关类型

托管收银台

用户从商户页面跳转到支付服务商的页面完成付款,随后再跳回商户网站。

这种方式接入较简单,银行卡等敏感信息通常不会经过商户服务器,适合大多数普通项目。不过,支付页面的定制空间可能比较有限。

嵌入式支付组件

支付服务商提供 JavaScript SDK、移动端 SDK 或安全输入组件,用户不必离开当前页面。

这种方式的支付过程更连贯,但前端集成、域名配置、安全策略和合规要求往往更复杂。

服务端 API 直连

商户后端直接调用支付 API,自行控制订单创建、确认、退款和查询流程。

这种方式更灵活,但不意味着商户可以自行接收或保存完整银行卡号、CVV 等敏感信息。除非业务已经满足相应的支付安全和合规要求,否则仍应使用网关提供的 tokenization 或托管输入组件。

聚合支付网关

聚合网关通过统一接口接入多种支付渠道,例如银行卡、电子钱包、扫码支付和本地转账。

它能减少多渠道适配工作,但也增加了一层依赖。选择时应关注费率、结算周期、退款能力、服务可用性和渠道覆盖范围。

推荐的系统结构

可以把支付逻辑封装成独立接口,避免业务代码与某一家服务商紧密绑定:

import java.math.BigDecimal;

public interface PaymentGateway {

    PaymentResult createPayment(PaymentRequest request);

    PaymentStatus queryPayment(String gatewayPaymentId);

    RefundResult refund(String gatewayPaymentId, BigDecimal amount);

    WebhookEvent verifyWebhook(String payload, String signature);
}

请求和结果对象可以采用业务内部的统一模型:

import java.math.BigDecimal;

public record PaymentRequest(
        String orderId,
        BigDecimal amount,
        String currency,
        String description,
        String returnUrl
) {
}

public record PaymentResult(
        String gatewayPaymentId,
        String checkoutUrl,
        String status
) {
}

public record PaymentStatus(
        String gatewayPaymentId,
        String status
) {
}

public record RefundResult(
        String refundId,
        String status
) {
}

public record WebhookEvent(
        String eventId,
        String gatewayPaymentId,
        String status
) {
}

这样,业务层只依赖 PaymentGateway。以后更换支付服务商时,主要修改对应的实现类即可。

典型集成步骤

1. 在业务系统中创建订单

先生成本地订单,将状态设为 PENDING,然后再请求支付网关。不要在本地订单尚未创建时直接发起付款,否则后续收到通知时很难找到对应订单。

金额建议使用 BigDecimal,不要使用 double:

BigDecimal amount = new BigDecimal("99.90");

部分网关要求金额使用最小货币单位,例如人民币的分或美元的美分。转换时必须遵循相应币种的规则:

long amountInMinorUnit = amount.movePointRight(2).longValueExact();

这段代码只适用于有两位小数的币种。并非所有币种都采用两位小数,实际规则应以支付服务商和币种标准为准。

2. 调用支付网关创建支付

如果服务商没有合适的 Java SDK,可以使用 Java 11 及以上版本的 HttpClient 调用 HTTPS API。下面只展示基本结构,其中的接口地址和 JSON 字段都是占位内容,不能直接用于真实服务商:

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

public class ExampleGatewayClient {

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

    private final String apiBaseUrl;
    private final String apiKey;

    public ExampleGatewayClient(String apiBaseUrl, String apiKey) {
        this.apiBaseUrl = apiBaseUrl;
        this.apiKey = apiKey;
    }

    public String createPayment(String requestJson, String idempotencyKey)
            throws Exception {

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(apiBaseUrl + "/payments"))
                .timeout(Duration.ofSeconds(20))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .header("Idempotency-Key", idempotencyKey)
                .POST(HttpRequest.BodyPublishers.ofString(requestJson))
                .build();

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

        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            throw new PaymentGatewayException(
                    "Gateway request failed, HTTP status: " + response.statusCode()
            );
        }

        return response.body();
    }
}

异常类可以保持简单:

public class PaymentGatewayException extends RuntimeException {

    public PaymentGatewayException(String message) {
        super(message);
    }

    public PaymentGatewayException(String message, Throwable cause) {
        super(message, cause);
    }
}

生产环境还要安全地解析 JSON、校验响应字段,并区分可重试错误和不可重试错误。不要通过字符串拼接生成 JSON,建议使用 Jackson 等成熟库完成序列化和反序列化。

3. 将用户引导到支付页面

网关成功创建支付后,通常会返回支付编号、客户端令牌或收银台 URL。服务端应保存网关支付编号,再把前端需要的信息返回给客户端。

如果网关返回支付 URL,应限制允许跳转的域名。不要无条件跳转到任意外部地址,否则可能形成开放重定向漏洞。

4. 接收并验证 Webhook

支付完成后,服务商通常会通过 Webhook 异步通知业务系统。更新订单状态应以 Webhook 为重要依据,不能只依赖浏览器回跳页面。用户可能在跳转前关闭页面,也可能伪造回跳参数。

处理 Webhook 时,必须使用原始请求体验签:

@PostMapping("/webhooks/payment")
public ResponseEntity<Void> handleWebhook(
        @RequestBody byte[] rawBody,
        @RequestHeader("X-Payment-Signature") String signature) {

    WebhookEvent event = paymentGateway.verifyWebhook(
            new String(rawBody, StandardCharsets.UTF_8),
            signature
    );

    paymentService.processWebhook(event);
    return ResponseEntity.ok().build();
}

这里的 X-Payment-Signature 只是示例名称。真实的请求头、签名算法、时间戳校验方式和密钥格式,都必须按照对应网关的文档实现。

5. 幂等地更新订单

Webhook 可能重复发送,也可能不按事件产生的顺序到达,因此处理逻辑必须支持幂等:

@Transactional
public void processWebhook(WebhookEvent event) {
    if (webhookEventRepository.existsByEventId(event.eventId())) {
        return;
    }

    PaymentOrder order =
            paymentOrderRepository.findByGatewayPaymentIdForUpdate(
                    event.gatewayPaymentId()
            );

    if ("SUCCEEDED".equals(event.status()) && !order.isPaid()) {
        order.markPaid();
    }

    webhookEventRepository.save(event.eventId());
}

除了对事件去重,还应在数据库层建立唯一约束,例如:

  • 业务订单号唯一;
  • 网关支付编号唯一;
  • Webhook 事件编号唯一。

不同服务商使用的状态名称可能不同。应将它们映射为系统内部的统一状态,不要让第三方状态值散落在业务代码中。

为什么不能只看前端返回结果

用户支付后,浏览器通常会跳转到 returnUrl。这个页面可以显示”支付处理中”,但不能据此直接把订单改为已支付。

可靠的确认流程是:

  1. 验证支付网关的 Webhook;
  2. 必要时由服务端调用交易查询接口;
  3. 核对商户订单号、金额、币种和商户身份;
  4. 确认网关状态允许履约;
  5. 更新本地订单。

即使签名有效,也必须核对金额和币种,避免把另一笔合法交易错误地关联到当前订单。

安全与稳定性注意事项

不要保存敏感支付数据

除非已经具备必要的安全能力并满足合规要求,否则不要接收或保存完整银行卡号、CVV、支付密码等数据。优先使用支付服务商提供的 token、托管收银台或安全输入组件。

密钥不能写进源码

API Key、Webhook Secret 和证书密码应存放在环境变量或密钥管理系统中:

String apiKey = System.getenv("PAYMENT_API_KEY");

if (apiKey == null || apiKey.isBlank()) {
    throw new IllegalStateException("PAYMENT_API_KEY is not configured");
}

日志也不能输出密钥、完整令牌或敏感支付信息。

使用幂等键

创建支付、扣款和退款时,应尽量携带幂等键。网络超时不代表服务商没有收到请求,贸然重试可能导致重复支付或重复退款。

幂等键可以根据业务操作生成,但同一次操作在重试时必须使用相同的值:

String idempotencyKey = "pay:" + orderId;

正确处理超时和重试

只对明确可以重试的错误执行有限次数的重试,例如部分网络异常或 5xx 响应。认证失败、参数错误等 4xx 响应通常无法靠重试解决。

请求超时后,应先使用业务订单号或幂等键查询原交易状态,再决定是否重新提交。

不要相信 Webhook 的到达顺序

系统可能先收到”支付成功”事件,随后又收到较早产生的”处理中”事件。因此,更新状态时要定义合法的状态转换,防止已支付订单退回处理中状态。

做好对账

支付通知可能因网络、配置或系统故障而丢失。除了处理 Webhook,还应定期调用查询接口或账单接口,对比本地订单与网关记录,处理金额不一致、状态不一致和遗漏交易。

测试建议

正式上线前,至少要覆盖以下场景:

  • 支付成功、失败、取消和超时;
  • 同一订单重复点击支付;
  • 创建支付请求超时后重试;
  • Webhook 重复、延迟和乱序;
  • 非法签名和过期时间戳;
  • 支付金额或币种不一致;
  • 全额退款和部分退款;
  • 网关暂时不可用;
  • 数据库更新失败后重新处理通知;
  • 测试环境与生产环境密钥隔离。

应使用服务商提供的 sandbox 或测试模式完成联调,不要用真实支付数据代替必要的测试环境。

选择支付网关时要考虑什么

除了是否提供 Java SDK,还应检查:

  • 是否支持目标国家、币种和支付方式;
  • 交易费率、退款费用和结算周期;
  • 是否支持部分退款、撤销和争议处理;
  • Webhook 是否包含签名、时间戳和事件 ID;
  • 是否提供 sandbox、交易查询和对账文件;
  • API 的限流、超时和可用性说明;
  • 数据存储区域是否符合业务的合规要求;
  • Java SDK 是否仍在维护,以及支持哪些 JDK 版本。

对于一般的 Java Web 项目,可以优先采用官方 Java SDK 和托管收银台,在服务端维护订单状态,通过验签后的 Webhook 确认支付结果,并使用定时对账处理遗漏通知。这套方案能控制实现复杂度,也便于以后增加退款、补单和多支付渠道支持。

备注:内容仅供参考。