PAYATHON 2026

如何在 JSF backing bean 中向虚拟 POS 发起 POST 支付

支付阿杰

结论

不要让 backing bean 改用 URLConnection 调用银行,再尝试把响应“重定向”到新窗口。正确的做法是让浏览器完成中转:

  1. 用户点击按钮后,在新窗口中请求应用自己的支付启动端点。
  2. 服务端从 session 或数据库读取交易数据,重新校验订单状态,并生成银行要求的签名参数。
  3. 服务端向新窗口返回一个仅含隐藏字段的 HTML 表单。
  4. 页面自动以 POST 方式将表单提交到银行 POS。
  5. 后续认证和支付页面直接在银行域名下运行。

服务端仍然掌握订单金额、签名密钥等关键数据,浏览器则按银行要求进入支付流程。

为什么不应使用 URLConnection

如果 backing bean 使用 URLConnection 或 HttpClient 请求银行,实际通信链路是:

应用服务器 -> 银行服务器

支付页面需要的链路则是:

用户浏览器 -> 银行服务器

服务端可以取得银行返回的 HTML,但普通 HTTP 重定向无法把服务器端 POST 及其上下文交给浏览器继续处理,原因包括:

  • 302、303 等重定向通常会让浏览器重新发起 GET,原来的 POST body 不会保留。
  • 银行可能设置 Cookie、会话标识或安全头,这些内容需要直接发送给用户浏览器。
  • 返回页面中的相对 URL、脚本、图片和后续表单都需要以银行域名为运行环境。
  • 如果把银行响应代理到自己的页面,可能破坏同源策略、安全策略和支付流程的完整性。
  • 新窗口只能由客户端打开,服务端无法直接创建浏览器窗口。

因此,backing bean 可以准备交易数据并生成签名,但最终提交给银行的 POST 应由浏览器发出。

推荐实现流程

可以增加一个本地支付启动端点,例如:

/payment/start

JSF 页面上的按钮在新窗口中打开这个端点。端点负责:

  • 检查用户登录状态和 CSRF 防护;
  • 根据 session 中的订单编号读取订单;
  • 在服务端重新计算金额、币种和商户信息;
  • 生成一次性支付流水号;
  • 使用服务端保存的密钥生成签名;
  • 输出一个会自动提交到银行的 HTML 表单。

完整流程如下:

JSF 页面
   └─ POST /payment/start(新窗口)
          └─ 服务端生成签名参数
                 └─ 返回自动提交表单
                        └─ 浏览器 POST 到银行 POS

JSF 页面

最简单的方式是使用普通 HTML 表单请求本地支付端点,并设置 target="_blank":

<form method="post"
      action="#{request.contextPath}/payment/start"
      target="_blank">
    <input type="hidden"
           name="csrfToken"
           value="#{paymentBean.csrfToken}" />

    <button type="submit">前往银行支付</button>
</form>

提交给本地端点的参数越少越好。通常只需传递 CSRF token。订单信息应由服务端根据当前登录用户和 session 查询,不要信任浏览器提交的金额、商户号或支付状态。

如果页面样式必须使用 JSF 组件,也可以先通过 JavaScript 打开一个命名窗口,再把表单提交到该窗口。不过,直接使用 target="_blank" 通常更可靠,也不容易被浏览器当作弹窗拦截。

普通 <form> 不要嵌套在 <h:form> 中,因为 HTML 不允许嵌套表单。

支付启动 Servlet 示例

下面以 Servlet 为例。具体 API 包名应根据项目使用的 Jakarta EE 或旧版 Java EE 调整。

Jakarta EE 使用:

import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

旧项目则可能使用 javax.servlet.*。

@WebServlet("/payment/start")
public class PaymentStartServlet extends HttpServlet {

    @Override
    protected void doPost(
            HttpServletRequest request,
            HttpServletResponse response)
            throws ServletException, IOException {

        HttpSession session = request.getSession(false);

        if (session == null || session.getAttribute("userId") == null) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
            return;
        }

        String submittedCsrfToken = request.getParameter("csrfToken");
        String expectedCsrfToken =
                (String) session.getAttribute("paymentCsrfToken");

        if (!constantTimeEquals(submittedCsrfToken, expectedCsrfToken)) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN);
            return;
        }

        String userId = (String) session.getAttribute("userId");
        String orderId = (String) session.getAttribute("pendingOrderId");

        // 必须从可信存储读取,不要采用浏览器提交的金额。
        PaymentOrder order = paymentService.findPayableOrder(orderId, userId);

        if (order == null || !order.isPayable()) {
            response.sendError(
                    HttpServletResponse.SC_BAD_REQUEST,
                    "订单不存在或当前状态不允许支付");
            return;
        }

        Map<String, String> bankParameters =
                paymentService.createBankParameters(order);

        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
        response.setContentType("text/html;charset=UTF-8");
        response.setHeader("Cache-Control", "no-store");

        try (PrintWriter writer = response.getWriter()) {
            writer.println("<!doctype html>");
            writer.println("<html lang=\"zh-CN\">");
            writer.println("<head>");
            writer.println("<meta charset=\"UTF-8\">");
            writer.println("<title>正在前往银行支付</title>");
            writer.println("</head>");
            writer.println("<body>");
            writer.println("<p>正在连接银行支付页面,请稍候……</p>");

            writer.printf(
                    "<form id=\"bankPaymentForm\" method=\"post\" action=\"%s\">%n",
                    htmlAttributeEscape(paymentService.getBankPaymentUrl())
            );

            for (Map.Entry<String, String> parameter
                    : bankParameters.entrySet()) {
                writer.printf(
                        "<input type=\"hidden\" name=\"%s\" value=\"%s\">%n",
                        htmlAttributeEscape(parameter.getKey()),
                        htmlAttributeEscape(parameter.getValue())
                );
            }

            writer.println("<noscript>");
            writer.println(
                    "<button type=\"submit\">继续前往银行支付</button>");
            writer.println("</noscript>");
            writer.println("</form>");

            writer.println("<script>");
            writer.println(
                    "document.getElementById('bankPaymentForm').submit();");
            writer.println("</script>");

            writer.println("</body>");
            writer.println("</html>");
        }
    }
}

paymentService.createBankParameters(order) 负责按照银行规范构造字段,例如订单号、金额、币种、通知地址和签名。字段名称和签名算法必须严格遵循银行接口文档,不能自行推测。

HTML 属性必须转义。可以使用项目现有的 HTML escaping 工具,不要直接拼接未经处理的订单数据。

使用 JSF backing bean 准备支付

如果必须先执行 backing bean 中的业务逻辑,可以让 bean 只创建支付意图或一次性 token,不要由它直接请求银行:

@Named
@RequestScoped
public class PaymentBean {

    @Inject
    private PaymentService paymentService;

    @Inject
    private HttpServletRequest request;

    private String paymentToken;

    public void preparePayment() {
        String userId = getCurrentUserId();
        String orderId = getPendingOrderId();

        paymentToken = paymentService.createSingleUseToken(
                userId,
                orderId
        );
    }

    public String getPaymentToken() {
        return paymentToken;
    }
}

更稳妥的设计是:

  • backing bean 创建一个一次性、短时有效的支付 token;
  • 新窗口只携带该 token 请求 /payment/start;
  • /payment/start 校验 token,读取对应订单,并立即将 token 标记为已使用;
  • 服务端生成银行参数和签名;
  • 浏览器自动 POST 到银行。

不要把可修改的金额直接写入 token。即使使用带签名的 token,服务端在发起支付前也要再次检查订单状态。

支付结果不能依赖浏览器返回

银行一般提供两类返回地址:

  • 浏览器返回地址:支付完成后将用户带回商城页面;
  • 服务端通知地址:由银行服务器主动通知支付结果。

最终支付状态应以银行的服务端通知或主动查询接口为准,不能只相信浏览器跳转参数。通知处理端点通常需要:

  1. 验证银行签名;
  2. 检查商户号、订单号、金额和币种;
  3. 保证幂等,避免重复通知导致重复扣减库存或重复记账;
  4. 只允许合法的订单状态转换;
  5. 记录银行流水号和必要的审计信息;
  6. 按照银行协议返回确认响应。

安全注意事项

由服务端生成表单,并不意味着所有字段都能对用户隐藏。浏览器需要提交给银行的字段,用户始终可以通过开发者工具或网络请求查看。安全边界应建立在这些措施上:

  • 商户密钥和私钥绝不能发送给浏览器;
  • 金额和订单信息必须由服务端从可信数据源读取;
  • 使用银行规定的签名或 MAC 验证参数完整性;
  • 为支付请求使用一次性流水号,防止重复提交;
  • 对支付启动端点实施 CSRF 防护;
  • 全程使用 HTTPS;
  • 不记录银行卡号、CVV、完整认证数据或密钥;
  • 不把银行返回的任意 HTML 直接嵌入自己的站点;
  • 对回调执行签名验证、金额核对和幂等处理;
  • 生产环境应使用银行提供的正式域名白名单和证书要求。

职责边界应当清楚:backing bean 处理可信的业务逻辑,浏览器将签名后的表单 POST 到银行,新窗口承载银行后续页面。这符合 JSF Web 应用与第三方支付跳转的正常工作方式。

备注:内容仅供参考。