如何在 Java 项目中集成 Payment gateway?
结论
Java 项目接入 Payment gateway(支付网关)时,通常不直接处理银行卡信息,而是通过支付服务商提供的 SDK 或 HTTPS API 完成支付流程:
- 服务端创建支付订单;
- 前端跳转到托管收银台,或调用服务商提供的支付组件;
- 支付服务商异步通知支付结果;
- 服务端验签并查询订单,确认最终状态;
- 根据确认结果发货、开通服务或更新账务。
实际项目应优先使用服务商的官方 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。这个页面可以显示”支付处理中”,但不能据此直接把订单改为已支付。
可靠的确认流程是:
- 验证支付网关的 Webhook;
- 必要时由服务端调用交易查询接口;
- 核对商户订单号、金额、币种和商户身份;
- 确认网关状态允许履约;
- 更新本地订单。
即使签名有效,也必须核对金额和币种,避免把另一笔合法交易错误地关联到当前订单。
安全与稳定性注意事项
不要保存敏感支付数据
除非已经具备必要的安全能力并满足合规要求,否则不要接收或保存完整银行卡号、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 确认支付结果,并使用定时对账处理遗漏通知。这套方案能控制实现复杂度,也便于以后增加退款、补单和多支付渠道支持。
备注:内容仅供参考。
