如何设计安全的 Web 支付架构?
结论
建议采用“支付服务商托管字段或官方 JavaScript SDK + 支付令牌 + Java 服务端确认支付”的架构:
- 浏览器使用支付服务商提供的 Hosted Fields、iframe 或官方 SDK 收集银行卡信息。
- 卡号、有效期和 CVV 直接发送给支付服务商,不经过你的 AJAX 接口或 Java 服务器。
- 支付服务商返回一次性 token、payment method ID 或 payment intent ID。
- 浏览器将该标识提交给 RESTEasy 接口。
- Java 服务端读取数据库中的订单金额,并调用支付服务商 API。
- 支付的最终状态以通过签名验证的 webhook 为准。
客户端验证可以改善填写体验,却不能代替服务端验证。订单金额、商品价格、优惠、库存、币种、用户权限和支付状态都要由服务端重新核验。
只有在支付服务商不提供安全令牌化方案时,才考虑让 Java 服务端接收并转发原始支付数据。这种做法会显著扩大 PCI DSS 范围,不应只为获得更高的页面定制自由度而优先采用。
推荐的数据流
浏览器
├─ 从 Java 服务端获取订单和支付会话
├─ 在 Hosted Fields 中输入银行卡信息
├─ 银行卡信息 ───────────────> Payment Processor
├─ 获得一次性 payment token
└─ token + orderId ─────────> Java REST API
│
├─ 校验用户、订单、金额、库存
├─ 使用服务端密钥调用支付 API
▼
Payment Processor
│
├─ 返回同步处理结果
└─ 发送签名 webhook
│
▼
Java Webhook API
│
└─ 更新订单最终状态
这种方式既保留了页面定制能力,也能减少应用直接接触银行卡数据的机会。Hosted Fields 通常允许定制外围布局、字体、颜色和错误提示,真正承载银行卡字段的 iframe 仍由支付服务商控制。
PCI DSS 评估范围能否缩小,要看支付服务商的集成模式、脚本加载方式和当前适用的 PCI DSS 要求。不能因为使用了 iframe,就直接认定系统符合某一种 SAQ。上线前应请收单机构、支付服务商或合格的 PCI 顾问确认。
客户端与服务端分别验证什么
客户端验证
JavaScript 可以检查:
- 必填项是否为空;
- 姓名、邮编等字段的基本格式;
- Hosted Fields 返回的卡号、有效期和 CVV 状态;
- 用户是否重复点击支付按钮;
- 用户是否已经同意付款条款。
这些检查用于尽早提示用户。由于客户端数据随时可能被修改,不能据此作出安全决策。
服务端验证
Java 服务端必须检查:
- 当前用户是否有权支付该订单;
- 订单是否仍处于可支付状态;
- 商品价格、税费、运费、折扣和币种;
- 库存或服务是否仍然可用;
- payment token 是否属于当前支付流程;
- 同一订单是否已经支付;
- 请求是否重复;
- 支付服务商的返回结果和 webhook 签名。
服务端不能直接采用浏览器提交的金额。浏览器最多提交 orderId 和一次性支付标识,实际扣款金额应根据服务端保存的订单数据计算。
浏览器端示例
不同支付服务商使用的 SDK 名称和参数并不相同。下面只演示通用结构,不能直接作为任何一家支付服务商的生产代码:
<form id="payment-form">
<div id="card-number"></div>
<div id="card-expiry"></div>
<div id="card-cvv"></div>
<p id="payment-error" role="alert"></p>
<button id="pay-button" type="submit">确认支付</button>
</form>
<script>
const form = document.getElementById("payment-form");
const button = document.getElementById("pay-button");
const errorBox = document.getElementById("payment-error");
form.addEventListener("submit", async (event) => {
event.preventDefault();
button.disabled = true;
errorBox.textContent = "";
try {
// processorSdk 仅为占位名称。
// 银行卡数据应由支付服务商托管的字段直接收集。
const tokenResult = await processorSdk.createPaymentToken();
if (tokenResult.error) {
throw new Error(tokenResult.error.message);
}
const response = await fetch("/api/payments/confirm", {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCsrfToken()
},
body: JSON.stringify({
orderId: "ORDER_ID_FROM_SERVER",
paymentToken: tokenResult.token
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.message || "支付请求未完成");
}
if (result.actionRequired) {
// 例如 3-D Secure。具体调用方式由支付服务商 SDK 决定。
await processorSdk.handleNextAction(result.clientSecret);
}
window.location.assign("/orders/" + result.orderId);
} catch (error) {
errorBox.textContent = error.message;
button.disabled = false;
}
});
</script>
不要自行读取卡号输入框,再通过普通 AJAX 将数据发送到 Java 接口。否则,应用服务器、反向代理、监控系统和日志系统都有可能接触原始卡数据。
支付页面还应配置严格的 Content Security Policy,并限制第三方脚本。支付页面中的 JavaScript 一旦被篡改,攻击者就可能在令牌化前窃取银行卡信息,因此前端供应链也属于支付安全的一部分。
RESTEasy 服务端示例
下面的示例用于说明各部分的职责。支付接口地址、认证方式和请求字段均为占位内容。实际开发时,应优先使用支付服务商维护的 Java SDK 或参考其官方 API 文档。
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/payments")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class PaymentResource {
@Inject
OrderService orderService;
@Inject
PaymentService paymentService;
@POST
@Path("/confirm")
public Response confirmPayment(PaymentRequest request) {
User currentUser = requireAuthenticatedUser();
Order order = orderService.getPayableOrder(
request.orderId(),
currentUser.id()
);
// 金额和币种来自服务端订单,不能信任浏览器提交的数据。
PaymentResult result = paymentService.charge(
order.id(),
order.totalAmount(),
order.currency(),
request.paymentToken()
);
return Response.ok(new PaymentResponse(
order.id(),
result.status(),
result.actionRequired(),
result.clientSecret()
)).build();
}
private User requireAuthenticatedUser() {
// 接入现有认证系统。
throw new UnsupportedOperationException("Application-specific");
}
}
record PaymentRequest(String orderId, String paymentToken) {}
record PaymentResponse(
String orderId,
String status,
boolean actionRequired,
String clientSecret
) {}
服务端调用支付服务商时,应设置较短的连接超时,并使用幂等键和明确的错误处理。Java 11 及以上版本可以使用 java.net.http.HttpClient。如果支付服务商提供维护良好的官方 Java SDK,通常应优先使用该 SDK。
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public final class ProcessorClient {
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final URI paymentEndpoint;
private final String secretKey;
public ProcessorClient(URI paymentEndpoint, String secretKey) {
this.paymentEndpoint = paymentEndpoint;
this.secretKey = secretKey;
}
public HttpResponse<String> createPayment(
String requestJson,
String idempotencyKey
) throws Exception {
HttpRequest request = HttpRequest.newBuilder(paymentEndpoint)
.timeout(Duration.ofSeconds(15))
.header("Authorization", "Bearer " + secretKey)
.header("Content-Type", "application/json")
.header("Idempotency-Key", idempotencyKey)
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
return httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
);
}
}
服务端密钥只能保存在服务器的密钥管理系统或受控配置中,不能写入 JavaScript、HTML、移动端代码或 Git 仓库。
为什么不能只依赖同步响应
支付请求超时不等于支付失败。例如:
- Java 服务端将请求发送给支付服务商;
- 支付服务商已经完成扣款;
- 网络在响应返回前中断;
- 网站误将超时判断为失败;
- 用户再次点击,导致重复扣款。
每次支付都应使用稳定且唯一的幂等键,例如根据订单和支付尝试生成:
payment:{orderId}:{attemptId}
重试同一次支付尝试时,必须复用相同的键,不能为每次请求生成新值。
订单状态也不能只有“成功”和“失败”,至少应能表达:
CREATED
-> PAYMENT_PENDING
-> ACTION_REQUIRED
-> PAID
-> PAYMENT_FAILED
-> CANCELLED
-> REFUNDED
同步 API 响应可以用来更新页面,但订单的最终状态应由支付服务商发送的签名 webhook 确认。Webhook 处理程序需要:
- 使用原始请求体验证签名;
- 检查事件时间戳和重放风险;
- 根据事件 ID 去重;
- 正确处理同一事件的重复投递;
- 正确处理事件乱序到达;
- 在数据库事务中更新订单;
- 尽快返回成功响应,并将耗时业务放入队列。
是否可以让 FORM 直接 POST 到支付服务商
只有支付服务商明确提供以下集成方案时,才应采用这种做法:
- Hosted Payment Page;
- Hosted Fields;
- Direct Post;
- 浏览器端 tokenization;
- 官方 JavaScript SDK。
不要自行设计普通 HTML FORM,将原始卡数据 POST 到任意支付 API。这种做法常见的问题包括:
- 浏览器无法安全保存支付服务商的私密 API 密钥;
- CORS 和响应跳转流程很难正确处理;
- 页面脚本仍有可能接触银行卡数据;
- 错误重试容易引发重复支付;
- 订单状态难以可靠维护;
- PCI DSS 范围可能大于预期。
如果支付服务商的 Direct Post 模式要求浏览器直接提交卡数据,也必须严格按照其官方流程实现,并确认回调签名、令牌有效期、允许的来源和 PCI DSS 责任边界。
如果必须让 Java 服务端接收卡数据
这种模式通常会使应用进入更严格的 PCI DSS 范围。需要纳入控制的可能不只是支付接口,还包括整个相关环境:
- Web 服务器和 Java 应用;
- 负载均衡器、WAF 和反向代理;
- 日志、APM、错误追踪和请求录制系统;
- CI/CD、依赖管理和源代码权限;
- 运维终端和管理员访问;
- 漏洞扫描、渗透测试和补丁管理;
- 网络分段、密钥管理和事件响应。
即使采用这种模式,也不能存储 CVV。没有明确业务必要时,也不应保存银行卡号。确需保存时,要采用符合要求的加密、密钥隔离、访问审计和数据保留策略。普通数据库加密或字段哈希并不会自动满足 PCI DSS。
上线前的检查
- 全站使用 HTTPS,并正确配置安全 Cookie。
- 支付接口要求用户登录,并执行 CSRF 防护。
- 服务端重新计算金额、币种、税费、运费和优惠。
- 使用支付服务商的 Hosted Fields 或官方 tokenization SDK。
- 确保原始卡号和 CVV 不进入应用日志、异常信息或分析系统。
- 禁止支付页面加载不必要的第三方脚本。
- 为支付请求设置连接超时和响应超时。
- 使用幂等键防止重复扣款。
- 验证 webhook 签名,对事件去重,并处理乱序事件。
- 不将浏览器跳转参数视为付款成功的证明。
- 为退款、撤销、拒付和异步失败建立独立流程。
- 在支付服务商的测试环境中验证成功、拒绝、超时、重复请求和 3-D Secure 等场景。
- 请支付服务商或 PCI 专业人员确认实际合规范围。
PHP 示例不是必要条件。HTTPS API、令牌化、签名验证、幂等处理和订单状态管理才是支付流程的主要组成部分,这些都可以使用 RESTEasy 和 Java 完成。需要优先确认的是,支付服务商是否提供官方 Java SDK、Hosted Fields、tokenization、3-D Secure 支持和签名 webhook。
备注:内容仅供参考。
