如何在 Java Struts 项目中实现 payment gateway?
结论
在 Java Struts 项目中接入支付网关,主要工作应放在服务端,并形成完整的支付流程:
- 创建本地订单;
- 调用支付网关创建支付会话;
- 将用户跳转到网关托管的收银台;
- 接收并验证支付回调;
- 根据服务端查询结果或已验签的异步通知更新订单;
- 处理退款、重复通知、超时和其他异常。
建议优先使用支付机构提供的 Java SDK 和托管收银台。银行卡信息和 API 密钥不能保存在 Struts Action、JSP 或浏览器中,也不能仅凭支付后的页面跳转判断付款成功。
以下示例基于 Struts 2 和通用支付网关。文中的接口名称仅用于说明调用方式,实际参数、签名算法和回调格式应以所选支付机构的官方文档为准。
接入前的准备
接入前,需要先在支付机构完成商户注册,并取得测试环境所需的配置,例如:
merchantId- API Key 或客户端证书
- 支付接口地址
- Webhook 签名密钥
- 支付成功后的返回地址
- 异步通知地址
测试环境和生产环境的配置应分开维护:
payment.api-base-url=https://sandbox.example-pay.com
payment.merchant-id=your-sandbox-merchant-id
payment.api-key=your-sandbox-api-key
payment.webhook-secret=your-webhook-secret
payment.return-url=https://shop.example.com/payment/result
payment.notify-url=https://shop.example.com/payment/notify
密钥应通过环境变量、密钥管理服务或受控配置中心注入,不能提交到 Git 仓库。
设计订单状态
支付状态应保存在数据库中,不能依赖 Session。简单的状态模型可以这样设计:
CREATED -> PAYING -> PAID
-> FAILED
-> CLOSED
PAID -> REFUNDING -> REFUNDED
订单表至少需要记录以下字段:
order_no
user_id
amount_minor
currency
status
gateway_transaction_id
created_at
paid_at
version
金额建议按最小货币单位保存。例如,人民币 19.90 元应保存为 1990 分,以免使用 double 时产生精度误差。
创建支付服务
网关调用应封装在独立的 Service 中,不要把签名、HTTP 请求和订单更新全部写进 Struts Action。
public interface PaymentService {
PaymentSession createPayment(String orderNo);
void handleNotification(
String requestBody,
Map<String, String> headers
);
PaymentStatus queryPayment(String orderNo);
}
支付会话可以包含支付页面地址和网关交易号:
public final class PaymentSession {
private final String transactionId;
private final String checkoutUrl;
public PaymentSession(String transactionId, String checkoutUrl) {
this.transactionId = transactionId;
this.checkoutUrl = checkoutUrl;
}
public String getTransactionId() {
return transactionId;
}
public String getCheckoutUrl() {
return checkoutUrl;
}
}
在 Struts Action 中发起支付
Action 应接收本地订单号,而不是浏览器提交的最终金额。收到请求后,服务端必须从数据库重新读取订单和金额。
import com.opensymphony.xwork2.ActionSupport;
import org.apache.struts2.ServletActionContext;
public class CreatePaymentAction extends ActionSupport {
private String orderNo;
private PaymentService paymentService;
@Override
public String execute() throws Exception {
PaymentSession session = paymentService.createPayment(orderNo);
ServletActionContext.getResponse().sendRedirect(
session.getCheckoutUrl()
);
return NONE;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
对应的 Struts 2 配置示例如下:
<action name="createPayment"
class="com.example.payment.CreatePaymentAction">
</action>
createPayment() 内部一般会执行这些操作:
- 查询订单并校验订单归属;
- 检查订单当前是否仍可支付;
- 从数据库读取金额和币种;
- 生成唯一的支付请求号;
- 调用网关创建支付会话;
- 保存网关交易号;
- 返回收银台 URL。
如果支付机构支持幂等键,应传入稳定且唯一的值,例如:
payment:{orderNo}
这样可以减少用户重复点击后重复扣款或创建多笔交易的风险。
调用支付网关
以下代码只用于展示调用结构,并不对应某家支付机构的真实 API:
public PaymentSession createPayment(String orderNo) {
Order order = orderRepository.findByOrderNo(orderNo);
if (order == null) {
throw new IllegalArgumentException("Order not found");
}
if (!OrderStatus.CREATED.equals(order.getStatus())) {
throw new IllegalStateException("Order cannot be paid");
}
GatewayPaymentRequest request = new GatewayPaymentRequest();
request.setMerchantOrderNo(order.getOrderNo());
request.setAmount(order.getAmountMinor());
request.setCurrency(order.getCurrency());
request.setReturnUrl(paymentConfig.getReturnUrl());
request.setNotifyUrl(paymentConfig.getNotifyUrl());
GatewayPaymentResponse response = gatewayClient.createPayment(
request,
"payment:" + order.getOrderNo()
);
orderRepository.markPaying(
order.getOrderNo(),
response.getTransactionId()
);
return new PaymentSession(
response.getTransactionId(),
response.getCheckoutUrl()
);
}
生产环境需要为网关请求设置合理的连接超时、读取超时和重试策略。如果请求已经超时,但网关可能已经受理,不要立即创建另一笔支付。应先根据订单号或幂等键查询原交易的状态。
接收异步通知
支付结果通常通过 Webhook 或服务器异步通知送达。通知入口必须读取原始请求体,因为许多网关会直接对原始字节进行签名。
import com.opensymphony.xwork2.ActionSupport;
import org.apache.struts2.ServletActionContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.Map;
public class PaymentNotifyAction extends ActionSupport {
private PaymentService paymentService;
@Override
public String execute() throws Exception {
HttpServletRequest request =
ServletActionContext.getRequest();
HttpServletResponse response =
ServletActionContext.getResponse();
byte[] bodyBytes = request.getInputStream().readAllBytes();
String body = new String(bodyBytes, StandardCharsets.UTF_8);
paymentService.handleNotification(body, extractHeaders(request));
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("success");
return NONE;
}
private Map<String, String> extractHeaders(HttpServletRequest request) {
Map<String, String> headers = new HashMap<>();
Enumeration<String> names = request.getHeaderNames();
if (names == null) {
return Collections.emptyMap();
}
while (names.hasMoreElements()) {
String name = names.nextElement();
headers.put(name, request.getHeader(name));
}
return headers;
}
public void setPaymentService(PaymentService paymentService) {
this.paymentService = paymentService;
}
}
InputStream.readAllBytes() 需要 Java 9 或更高版本。较旧的 Java 环境可以通过缓冲区循环读取,也可以使用项目已有的流处理工具。
Webhook 应按以下顺序处理:
- 验证签名和时间戳;
- 解析通知内容;
- 确认商户号属于当前系统;
- 根据本地订单号查询订单;
- 核对金额、币种和交易号;
- 通过事务和条件更新将订单改为
PAID; - 返回网关要求的确认响应。
示意代码如下:
@Transactional
public void handleNotification(
String requestBody,
Map<String, String> headers) {
if (!gatewaySigner.verify(requestBody, headers)) {
throw new SecurityException("Invalid payment signature");
}
GatewayNotification notification =
gatewayClient.parseNotification(requestBody);
if (!"PAYMENT_SUCCEEDED".equals(notification.getEventType())) {
return;
}
Order order = orderRepository.findByOrderNo(
notification.getMerchantOrderNo()
);
if (order == null) {
throw new IllegalStateException("Order not found");
}
if (order.getAmountMinor() != notification.getAmountMinor()
|| !order.getCurrency().equals(notification.getCurrency())) {
throw new SecurityException("Payment amount mismatch");
}
if (OrderStatus.PAID.equals(order.getStatus())) {
return;
}
int updated = orderRepository.markPaidIfUnpaid(
order.getOrderNo(),
notification.getTransactionId()
);
if (updated == 1) {
orderService.fulfillAfterPayment(order.getOrderNo());
}
}
在实际业务中,发货、充值或开通会员等后续操作最好由事务消息或任务队列触发。即使回调处理期间出现短暂故障,系统也能通过重试完成补偿。
处理支付结果页
用户支付后返回商城页面,只能说明浏览器完成了跳转,不能证明支付已经成功。结果页应根据本地订单状态展示内容:
public class PaymentResultAction extends ActionSupport {
private String orderNo;
private OrderService orderService;
private Order order;
@Override
public String execute() {
order = orderService.findVisibleOrder(orderNo);
return SUCCESS;
}
public Order getOrder() {
return order;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
}
如果异步通知还没有到达,服务端可以主动查询一次网关状态,也可以让前端在短时间内轮询本地订单接口。返回地址中的 status=success、金额和交易号都不能直接信任。
必须注意的安全问题
- 全程使用 HTTPS。
- API Key、私钥和 Webhook Secret 只能保存在服务端。
- 不要记录银行卡号、CVV、完整令牌、签名密钥等敏感信息。
- 创建支付接口需要登录校验、订单归属校验和 CSRF 防护。
- 严格验证回调签名、时间戳、商户号、金额、币种和订单号。
- 使用参数化 SQL,防止 SQL 注入。
- Webhook 必须能够处理重复通知,订单状态更新和业务履约也要保证幂等。
- 不要简单地允许任意来源绕过 Struts 拦截器。Webhook 可以不依赖用户 Session,但仍要通过网关签名认证。
- 如果系统直接接触银行卡数据,还需要评估 PCI DSS 等合规要求。使用网关托管收银台通常可以缩小合规范围,但具体责任仍需向支付机构或合规人员确认。
上线前检查
上线前,至少要在支付机构的沙箱环境中验证以下场景:
- 正常支付成功;
- 用户取消支付;
- 支付失败;
- 支付页面超时;
- Webhook 重复发送和乱序到达;
- 回调签名错误;
- 回调金额或币种不匹配;
- 网关请求超时但交易实际成功;
- 同一订单被重复点击支付;
- 退款成功、退款失败和部分退款;
- 服务重启后订单状态能够正确补偿。
此外,还需要配置支付日志、订单对账和异常告警。日志可以记录本地订单号、网关交易号、事件类型和处理结果,但敏感字段必须经过脱敏处理。
备注:内容仅供参考。
