PAYATHON 2026

前后端分离 Web 项目如何通过支付宝支付跳转至 Vue.js 前端?

支付小周

结论

前后端分离项目接入支付宝时,Vue.js 不能直接生成签名,也不能调用需要商户私钥的支付接口。完整流程如下:

  1. Vue 向后端提交订单号。
  2. 后端校验订单、金额和用户身份,调用支付宝 SDK 生成支付表单。
  3. Vue 提交支付表单,进入支付宝收银台。
  4. 支付宝通过 notify_url 将支付结果异步通知给后端。
  5. 用户完成支付后,经 return_url 回到后端,再由后端重定向至 Vue 页面。
  6. 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 发送表单格式的异步通知。后端收到通知后,需要:

  1. 读取全部通知参数。
  2. 使用支付宝 SDK 验证签名。
  3. 校验 app_id、商户身份、订单号和支付金额。
  4. 检查交易状态。
  5. 通过数据库事务幂等更新订单。
  6. 处理成功后返回纯文本 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 也应能独立完成订单处理。
  • 开发环境可以使用支付宝沙箱,但网关、应用标识、密钥和账号必须全部使用沙箱配置,不能与正式环境混用。

备注:内容仅供参考。