Vue 前后端分离项目支付成功后如何回调并跳转页面?
结论
支付成功后的页面跳转,应交给支付平台的同步返回地址或前端结果页处理。订单是否真的支付成功,则要以后端收到的异步通知为准。
建议按以下流程处理:
- 创建支付订单时,把同步返回地址配置为后端接口。
- 用户完成支付后,支付平台让浏览器访问该同步接口。
- 后端验签并获取订单号,再返回
302或303,把浏览器重定向到 Vue 支付结果页。 - Vue 结果页调用后端订单查询接口,显示订单的实际支付状态。
- 后端异步通知接口负责验签、核对金额和更新订单,不能依赖前端跳转完成这些业务操作。
同步通知接口不能直接把订单标记为支付成功,也不能通过未经验证的 URL 参数把支付结果交给前端。
为什么要区分回调和页面跳转
支付流程通常会用到两类地址,名称因支付平台而异:
- 同步返回地址,例如
return_url,由用户浏览器访问,用于页面跳转。 - 异步通知地址,例如
notify_url,由支付平台服务器请求,用于确认支付结果。
同步返回不够可靠。用户可能在支付后直接关闭页面,网络也可能中断,回跳地址还可能被手动伪造。因此,支付状态必须通过异步通知或后端主动查询来确认。
异步通知是服务器之间的请求,虽然适合确认支付结果,却无法直接控制用户已经打开的 Vue 页面。即使异步接口返回重定向响应,支付平台服务器也不会替用户的浏览器完成跳转。
推荐的处理流程
假设系统使用以下地址:
- Vue 站点:
https://www.example.com - 后端服务:
https://api.example.com - 同步返回接口:
https://api.example.com/payment/return - 异步通知接口:
https://api.example.com/payment/notify - Vue 支付结果页:
https://www.example.com/payment/result
完整流程如下:
Vue 创建订单
↓
后端生成支付参数
↓
Vue 跳转到支付平台
↓
用户完成支付
├── 支付平台异步通知后端,后端更新订单
└── 用户浏览器访问同步返回接口
↓
后端重定向到 Vue
↓
Vue 查询后端订单状态
↓
展示支付结果
异步通知和浏览器同步返回的先后顺序并不固定。Vue 打开结果页时,后端可能还没有处理完异步通知,所以前端需要在短时间内轮询订单状态。
后端同步返回接口
下面以 Spring Boot 为例。参数名称、验签方式和回调请求方法,都需要根据实际支付平台的文档调整。
@RestController
@RequestMapping("/payment")
public class PaymentController {
@GetMapping("/return")
public ResponseEntity<Void> paymentReturn(
@RequestParam Map<String, String> params) {
// 按支付平台规则验证同步返回参数
boolean valid = paymentService.verifyReturnSignature(params);
if (!valid) {
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(
"https://www.example.com/payment/result?error=invalid_signature"
))
.build();
}
String orderNo = params.get("out_trade_no");
// 同步返回只能用于辅助展示,最终状态仍从数据库或支付平台查询
String redirectUrl =
"https://www.example.com/payment/result?orderNo="
+ URLEncoder.encode(orderNo, StandardCharsets.UTF_8);
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create(redirectUrl))
.build();
}
}
如果支付平台通过 POST 请求同步返回接口,建议返回 303 See Other,让浏览器随后使用 GET 请求进入 Vue 页面:
@PostMapping("/return")
public ResponseEntity<Void> paymentReturnByPost(
@RequestParam Map<String, String> params) {
if (!paymentService.verifyReturnSignature(params)) {
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create(
"https://www.example.com/payment/result?error=invalid_signature"
))
.build();
}
String orderNo = params.get("out_trade_no");
String redirectUrl =
"https://www.example.com/payment/result?orderNo="
+ URLEncoder.encode(orderNo, StandardCharsets.UTF_8);
return ResponseEntity.status(HttpStatus.SEE_OTHER)
.location(URI.create(redirectUrl))
.build();
}
如果支付平台允许把同步返回地址直接配置为前端路由,也可以设置为:
https://www.example.com/payment/result
不过,前端仍然只能根据订单号向后端查询状态,不能直接相信支付平台带回的 status=success 等参数。
后端异步通知接口
异步通知接口负责更新订单的真实支付状态,处理时需要:
- 验证支付平台签名。
- 校验商户号、订单号、支付金额和币种。
- 判断支付状态是否成功。
- 幂等更新订单。
- 按照支付平台的要求返回确认内容。
@PostMapping("/notify")
public ResponseEntity<String> paymentNotify(
@RequestParam Map<String, String> params) {
if (!paymentService.verifyNotifySignature(params)) {
return ResponseEntity.badRequest().body("failure");
}
String orderNo = params.get("out_trade_no");
String transactionId = params.get("trade_no");
String tradeStatus = params.get("trade_status");
// 必须同时校验订单归属、商户号、金额、币种等信息
if (!paymentService.validateNotifyData(params)) {
return ResponseEntity.badRequest().body("failure");
}
if ("SUCCESS".equals(tradeStatus)) {
// 需要实现幂等:重复通知不能重复扣库存、发货或记账
paymentService.markOrderPaid(orderNo, transactionId);
}
// 返回值必须符合对应支付平台的协议
return ResponseEntity.ok("success");
}
这里的 SUCCESS、trade_status、out_trade_no 和响应内容仅用于示意。支付宝、微信支付及其他支付服务使用的字段和响应协议各不相同,应以实际接入平台的文档为准。
提供订单状态查询接口
Vue 结果页不应直接显示“支付成功”,而要查询后端保存的订单状态。
@GetMapping("/orders/{orderNo}/status")
public ResponseEntity<OrderStatusResponse> getOrderStatus(
@PathVariable String orderNo,
Principal principal) {
Order order = orderService.findByOrderNo(orderNo);
if (order == null) {
return ResponseEntity.notFound().build();
}
// 防止用户通过修改订单号查看他人的订单
if (!order.getUserId().equals(principal.getName())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
return ResponseEntity.ok(
new OrderStatusResponse(order.getOrderNo(), order.getStatus())
);
}
返回数据可以采用以下格式:
{
"orderNo": "ORDER202609120001",
"status": "PAID"
}
常见状态包括:
PENDING
PAID
CLOSED
FAILED
REFUNDED
Vue 支付结果页
下面的示例使用 Vue 3 和 Vue Router。页面加载后会查询订单状态。如果订单仍为 PENDING,页面会在短时间内继续轮询。
<template>
<main class="payment-result">
<p v-if="loading">正在确认支付结果,请稍候……</p>
<template v-else>
<h2 v-if="status === 'PAID'">支付成功</h2>
<h2 v-else-if="status === 'PENDING'">支付结果确认中</h2>
<h2 v-else-if="status === 'FAILED'">支付失败</h2>
<h2 v-else>暂时无法确认支付结果</h2>
<button @click="queryStatus">重新查询</button>
<button @click="router.push('/orders')">查看订单</button>
</template>
</main>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axios from 'axios'
const route = useRoute()
const router = useRouter()
const status = ref('PENDING')
const loading = ref(true)
let timer = null
let attempts = 0
const maxAttempts = 10
async function queryStatus() {
const orderNo = route.query.orderNo
if (!orderNo) {
status.value = 'UNKNOWN'
loading.value = false
return
}
try {
const response = await axios.get(
`/api/payment/orders/${encodeURIComponent(orderNo)}/status`
)
status.value = response.data.status
if (status.value === 'PENDING' && attempts < maxAttempts) {
attempts += 1
timer = window.setTimeout(queryStatus, 2000)
}
} catch (error) {
status.value = 'UNKNOWN'
} finally {
loading.value = false
}
}
onMounted(queryStatus)
onBeforeUnmount(() => {
if (timer) {
window.clearTimeout(timer)
}
})
</script>
对应路由可以这样配置:
import { createRouter, createWebHistory } from 'vue-router'
import PaymentResult from '@/views/PaymentResult.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/payment/result',
name: 'PaymentResult',
component: PaymentResult
}
]
})
export default router
Vue 跳转到支付页面的方式
如果后端返回支付页面 URL,可以直接跳转:
const response = await axios.post('/api/payment/create', {
orderNo: 'ORDER202609120001'
})
window.location.href = response.data.payUrl
如果后端返回支付平台要求提交的 HTML 表单,通常需要把表单写入新页面,或者由后端直接输出并提交。具体实现取决于支付平台,不能把整段 HTML 当作普通 URL 使用。
注意事项
不要在异步通知接口中执行 Vue 跳转
异步通知的调用方是支付平台服务器,并非用户浏览器。下面的代码通常不会改变用户页面:
return "redirect:https://www.example.com/payment/result";
它只会让支付平台服务器收到重定向响应,还可能导致平台无法识别通知的处理结果。
不要相信前端传来的支付状态
下面的地址只能说明浏览器携带了一个参数,无法证明订单已经付款:
/payment/result?status=success
用户可以自行修改查询参数。前端应把订单号交给后端,再由后端根据数据库记录或支付平台的查询结果返回状态。
异步通知必须幂等
支付平台可能多次发送同一笔订单的通知。更新订单时,应使用条件更新、唯一约束或事务控制。例如,只允许订单从 PENDING 变为 PAID:
UPDATE payment_order
SET status = 'PAID',
transaction_id = ?
WHERE order_no = ?
AND status = 'PENDING';
如果受影响行数为 0,还要继续判断订单是否已经处理,不能再次执行发货、积分发放或库存扣减。
防止订单号泄露和越权查询
订单号可以放在 URL 中,但后端查询时必须确认当前登录用户拥有该订单。对于不要求登录的支付场景,可以使用短期有效且不可猜测的查询令牌,不能只凭连续订单号返回结果。
处理异步通知延迟
同步返回可能比异步通知更早到达。结果页查询到 PENDING 时,可以每隔两秒再次查询,但要设置最大次数。超过等待时间后,应提示用户稍后前往订单列表查看,避免无限轮询。
如果业务要求立即确认,后端也可以主动调用支付平台的订单查询接口,但需要考虑接口限流、签名验证和查询成本。
回调地址必须可公网访问
支付平台服务器需要访问异步通知地址,因此该地址通常需要满足以下条件:
- 使用公网可以访问的域名。
- 使用平台要求的 HTTPS 和端口。
- 不会被登录拦截器或错误的 CSRF 校验阻挡。
- 能够正确接收平台指定的
GET、POST、表单或 JSON 请求。 - 在规定时间内返回平台要求的确认响应。
简单来说,同步返回负责把浏览器带回 Vue,异步通知负责确认支付结果并更新订单,Vue 再通过后端查询接口显示状态。
备注:内容仅供参考。
