前后端分离 Web 项目如何通过支付宝支付跳转至 Vue.js 前端?
结论
前后端分离项目接入支付宝时,Vue.js 不能直接生成签名,也不能调用需要商户私钥的支付接口。完整流程如下:
- Vue 向后端提交订单号。
- 后端校验订单、金额和用户身份,调用支付宝 SDK 生成支付表单。
- Vue 提交支付表单,进入支付宝收银台。
- 支付宝通过
notify_url将支付结果异步通知给后端。 - 用户完成支付后,经
return_url回到后端,再由后端重定向至 Vue 页面。 - Vue 携带订单号查询后端,确认最终支付状态。
更新订单状态应以 notify_url 为准,不能仅根据前端跳转参数认定支付成功。
推荐的支付流程
Vue 创建支付请求
↓
后端校验订单并生成支付宝支付表单
↓
浏览器提交表单,跳转到支付宝
↓
支付宝完成支付
├── 异步通知 notify_url → 后端验签并更新订单
└── 同步跳转 return_url → 后端验签后 302 跳转 Vue
↓
Vue 查询订单支付状态
建议让 return_url 先进入后端,不要直接指向 Vue。后端可以在重定向前校验参数,前端也不会误把支付宝返回的查询参数当作可信的支付结果。
后端生成支付表单
电脑网站支付通常使用 alipay.trade.page.pay。如果是在手机浏览器中进行 H5 支付,则要根据支付宝产品配置使用 alipay.trade.wap.pay。
以下 Java 代码只展示核心结构。SDK 依赖、构造方法和证书模式配置,需要以项目实际使用的支付宝 SDK 版本为准。
@PostMapping(value = "/api/payment/alipay", produces = "text/html;charset=UTF-8")
public String createAlipayPayment(@RequestBody PayRequest payRequest) throws Exception {
// 必须从当前登录用户和数据库中查询订单
Order order = orderService.getPayableOrder(
payRequest.getOrderNo(),
getCurrentUserId()
);
if (order == null || order.isPaid()) {
throw new IllegalArgumentException("订单不存在或不可支付");
}
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setNotifyUrl("https://api.example.com/api/payment/alipay/notify");
request.setReturnUrl("https://api.example.com/api/payment/alipay/return");
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(order.getOrderNo());
model.setTotalAmount(order.getAmount().setScale(2).toPlainString());
model.setSubject(order.getSubject());
model.setProductCode("FAST_INSTANT_TRADE_PAY");
request.setBizModel(model);
// pageExecute 通常返回一段可自动提交的 HTML form
return alipayClient.pageExecute(request).getBody();
}
请求对象只需提交订单号:
public class PayRequest {
private String orderNo;
public String getOrderNo() {
return orderNo;
}
public void setOrderNo(String orderNo) {
this.orderNo = orderNo;
}
}
金额、商品名称和订单状态都必须由后端根据订单号查询,不能采用前端提交的金额,否则用户可能篡改支付金额。
Vue 跳转到支付宝收银台
后端返回支付宝生成的 HTML 表单后,可以将表单写入新页面并自动提交:
import axios from 'axios'
async function payWithAlipay(orderNo) {
const payWindow = window.open('', '_blank')
try {
const response = await axios.post(
'/api/payment/alipay',
{ orderNo },
{
responseType: 'text',
headers: {
'Content-Type': 'application/json'
}
}
)
if (!payWindow) {
throw new Error('支付窗口被浏览器拦截,请允许本站打开新窗口')
}
payWindow.document.open()
payWindow.document.write(response.data)
payWindow.document.close()
} catch (error) {
if (payWindow) {
payWindow.close()
}
throw error
}
}
支付窗口需要在用户点击按钮时立即创建。如果等接口请求完成后再调用 window.open(),浏览器可能会将其视为弹窗并拦截。
也可以替换当前文档,让支付流程在当前页面中继续:
async function payWithAlipay(orderNo) {
const response = await axios.post(
'/api/payment/alipay',
{ orderNo },
{ responseType: 'text' }
)
document.open()
document.write(response.data)
document.close()
}
采用这种方式后,用户会离开当前 Vue 应用,并在支付结束后通过 return_url 返回。
支付宝响应中包含可执行的支付表单,因此页面只能写入由自己后端生成的内容。不要将任意第三方 HTML 写入页面,以免产生 XSS 风险。
处理支付宝异步通知
支付宝会向 notify_url 发送表单格式的异步通知。后端收到通知后,需要:
- 读取全部通知参数。
- 使用支付宝 SDK 验证签名。
- 校验
app_id、商户身份、订单号和支付金额。 - 检查交易状态。
- 通过数据库事务幂等更新订单。
- 处理成功后返回纯文本
success。
示例:
@PostMapping(
value = "/api/payment/alipay/notify",
consumes = "application/x-www-form-urlencoded"
)
@ResponseBody
public String handleNotify(HttpServletRequest request) throws Exception {
Map<String, String> params = extractAlipayParams(request);
boolean valid = AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
"UTF-8",
"RSA2"
);
if (!valid) {
return "failure";
}
String orderNo = params.get("out_trade_no");
String tradeNo = params.get("trade_no");
String tradeStatus = params.get("trade_status");
String totalAmount = params.get("total_amount");
String appId = params.get("app_id");
if (!configuredAppId.equals(appId)) {
return "failure";
}
Order order = orderService.getByOrderNo(orderNo);
if (order == null || !order.amountEquals(totalAmount)) {
return "failure";
}
if ("TRADE_SUCCESS".equals(tradeStatus)
|| "TRADE_FINISHED".equals(tradeStatus)) {
// 内部必须保证幂等,同一通知重复到达时不能重复发货或重复记账
orderService.markPaid(orderNo, tradeNo);
}
return "success";
}
通知参数可以这样提取:
private Map<String, String> extractAlipayParams(HttpServletRequest request) {
Map<String, String> result = new HashMap<>();
request.getParameterMap().forEach((key, values) -> {
result.put(key, String.join(",", values));
});
return result;
}
实际项目还需要校验支付宝返回的商户标识等字段。字段名称取决于使用的支付宝产品和接入方式,具体应以对应接口文档为准。
notify_url 必须是支付宝服务器可以访问的公网 HTTPS 地址,不能填写 localhost、内网地址或 Vue 路由地址。
支付完成后跳转回 Vue
建议将 return_url 配置为后端接口。后端完成同步参数验签后,再通过 HTTP 302 跳转到 Vue:
@GetMapping("/api/payment/alipay/return")
public void handleReturn(
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
Map<String, String> params = extractAlipayParams(request);
boolean valid = AlipaySignature.rsaCheckV1(
params,
alipayPublicKey,
"UTF-8",
"RSA2"
);
String orderNo = params.get("out_trade_no");
String redirectUrl;
if (valid && orderNo != null) {
redirectUrl = "https://www.example.com/payment/result?orderNo="
+ URLEncoder.encode(orderNo, StandardCharsets.UTF_8);
} else {
redirectUrl = "https://www.example.com/payment/result?invalid=1";
}
response.sendRedirect(redirectUrl);
}
同步跳转可能早于异步通知到达。即使同步参数已经验签成功,Vue 页面也不能立即显示”支付成功”,而要查询后端保存的订单状态。
Vue 支付结果页面
可以在 Vue Router 中配置支付结果页面:
const routes = [
{
path: '/payment/result',
name: 'PaymentResult',
component: () => import('@/views/PaymentResult.vue')
}
]
结果页面根据订单号查询后端:
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
const route = useRoute()
const status = ref('checking')
const message = ref('正在确认支付结果')
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
async function checkPayment() {
const orderNo = route.query.orderNo
if (!orderNo) {
status.value = 'error'
message.value = '缺少订单号'
return
}
// 异步通知可能稍晚到达,进行有限次数的轮询
for (let i = 0; i < 10; i++) {
const { data } = await axios.get(
`/api/orders/${encodeURIComponent(orderNo)}/payment-status`
)
if (data.status === 'PAID') {
status.value = 'success'
message.value = '支付成功'
return
}
if (data.status === 'CLOSED' || data.status === 'FAILED') {
status.value = 'failed'
message.value = '订单未支付'
return
}
await sleep(1500)
}
status.value = 'pending'
message.value = '支付结果确认中,请稍后刷新'
}
onMounted(checkPayment)
</script>
<template>
<main class="payment-result">
<h1>{{ message }}</h1>
</main>
</template>
订单状态接口仍要校验当前登录用户是否有权查看该订单,不能仅凭订单号返回订单信息。
跨域和路由配置
如果前端和后端使用不同域名,例如:
前端:https://www.example.com
后端:https://api.example.com
还需要处理以下配置:
- 后端应为 Vue 所在域名设置准确的 CORS 白名单。
- 使用 Cookie 登录时,要正确配置
SameSite、Secure和跨域凭证。 notify_url不依赖用户登录状态,后端应通过支付宝签名和订单数据完成身份校验。- Vue 使用 History 模式时,Web 服务器必须将不存在的前端路径回退到
index.html。
Nginx 可以这样配置:
location / {
try_files $uri $uri/ /index.html;
}
如果没有这项配置,支付宝跳回 /payment/result 后,直接访问该地址可能出现 404。
注意事项
- 商户私钥只能保存在后端,绝不能写入 Vue 代码、前端环境变量或浏览器存储。
- 订单金额必须以后端数据库中的数据为准。
- 支付成功、发货和充值等业务动作,只能由可信的后端状态触发。
- 支付宝可能重复发送异步通知,因此订单更新必须具备幂等性。
- 不要依赖通知的到达顺序,同步跳转可能早于异步通知。
- 验签成功不代表订单一定支付成功,还要检查交易状态、订单金额和商户信息。
return_url用于改善用户体验。即使用户关闭支付页面,notify_url也应能独立完成订单处理。- 开发环境可以使用支付宝沙箱,但网关、应用标识、密钥和账号必须全部使用沙箱配置,不能与正式环境混用。
备注:内容仅供参考。
