如何在 JSF backing bean 中向虚拟 POS 发起 POST 支付
结论
不要让 backing bean 改用 URLConnection 调用银行,再尝试把响应“重定向”到新窗口。正确的做法是让浏览器完成中转:
- 用户点击按钮后,在新窗口中请求应用自己的支付启动端点。
- 服务端从 session 或数据库读取交易数据,重新校验订单状态,并生成银行要求的签名参数。
- 服务端向新窗口返回一个仅含隐藏字段的 HTML 表单。
- 页面自动以
POST方式将表单提交到银行 POS。 - 后续认证和支付页面直接在银行域名下运行。
服务端仍然掌握订单金额、签名密钥等关键数据,浏览器则按银行要求进入支付流程。
为什么不应使用 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,服务端在发起支付前也要再次检查订单状态。
支付结果不能依赖浏览器返回
银行一般提供两类返回地址:
- 浏览器返回地址:支付完成后将用户带回商城页面;
- 服务端通知地址:由银行服务器主动通知支付结果。
最终支付状态应以银行的服务端通知或主动查询接口为准,不能只相信浏览器跳转参数。通知处理端点通常需要:
- 验证银行签名;
- 检查商户号、订单号、金额和币种;
- 保证幂等,避免重复通知导致重复扣减库存或重复记账;
- 只允许合法的订单状态转换;
- 记录银行流水号和必要的审计信息;
- 按照银行协议返回确认响应。
安全注意事项
由服务端生成表单,并不意味着所有字段都能对用户隐藏。浏览器需要提交给银行的字段,用户始终可以通过开发者工具或网络请求查看。安全边界应建立在这些措施上:
- 商户密钥和私钥绝不能发送给浏览器;
- 金额和订单信息必须由服务端从可信数据源读取;
- 使用银行规定的签名或 MAC 验证参数完整性;
- 为支付请求使用一次性流水号,防止重复提交;
- 对支付启动端点实施 CSRF 防护;
- 全程使用 HTTPS;
- 不记录银行卡号、CVV、完整认证数据或密钥;
- 不把银行返回的任意 HTML 直接嵌入自己的站点;
- 对回调执行签名验证、金额核对和幂等处理;
- 生产环境应使用银行提供的正式域名白名单和证书要求。
职责边界应当清楚:backing bean 处理可信的业务逻辑,浏览器将签名后的表单 POST 到银行,新窗口承载银行后续页面。这符合 JSF Web 应用与第三方支付跳转的正常工作方式。
备注:内容仅供参考。
