PAYATHON 2026

如何在 Java Struts 项目中实现 payment gateway?

支付小周

结论

在 Java Struts 项目中接入支付网关,主要工作应放在服务端,并形成完整的支付流程:

  1. 创建本地订单;
  2. 调用支付网关创建支付会话;
  3. 将用户跳转到网关托管的收银台;
  4. 接收并验证支付回调;
  5. 根据服务端查询结果或已验签的异步通知更新订单;
  6. 处理退款、重复通知、超时和其他异常。

建议优先使用支付机构提供的 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() 内部一般会执行这些操作:

  1. 查询订单并校验订单归属;
  2. 检查订单当前是否仍可支付;
  3. 从数据库读取金额和币种;
  4. 生成唯一的支付请求号;
  5. 调用网关创建支付会话;
  6. 保存网关交易号;
  7. 返回收银台 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 应按以下顺序处理:

  1. 验证签名和时间戳;
  2. 解析通知内容;
  3. 确认商户号属于当前系统;
  4. 根据本地订单号查询订单;
  5. 核对金额、币种和交易号;
  6. 通过事务和条件更新将订单改为 PAID;
  7. 返回网关要求的确认响应。

示意代码如下:

@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 重复发送和乱序到达;
  • 回调签名错误;
  • 回调金额或币种不匹配;
  • 网关请求超时但交易实际成功;
  • 同一订单被重复点击支付;
  • 退款成功、退款失败和部分退款;
  • 服务重启后订单状态能够正确补偿。

此外,还需要配置支付日志、订单对账和异常告警。日志可以记录本地订单号、网关交易号、事件类型和处理结果,但敏感字段必须经过脱敏处理。

备注:内容仅供参考。