PAYATHON 2026

Vue 前后端分离项目支付成功后如何回调并跳转页面?

支付老李

结论

支付成功后的页面跳转,应交给支付平台的同步返回地址或前端结果页处理。订单是否真的支付成功,则要以后端收到的异步通知为准。

建议按以下流程处理:

  1. 创建支付订单时,把同步返回地址配置为后端接口。
  2. 用户完成支付后,支付平台让浏览器访问该同步接口。
  3. 后端验签并获取订单号,再返回 302 或 303,把浏览器重定向到 Vue 支付结果页。
  4. Vue 结果页调用后端订单查询接口,显示订单的实际支付状态。
  5. 后端异步通知接口负责验签、核对金额和更新订单,不能依赖前端跳转完成这些业务操作。

同步通知接口不能直接把订单标记为支付成功,也不能通过未经验证的 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 等参数。

后端异步通知接口

异步通知接口负责更新订单的真实支付状态,处理时需要:

  1. 验证支付平台签名。
  2. 校验商户号、订单号、支付金额和币种。
  3. 判断支付状态是否成功。
  4. 幂等更新订单。
  5. 按照支付平台的要求返回确认内容。
@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 再通过后端查询接口显示状态。

备注:内容仅供参考。