Skip to content

支付宝:使用 page.pay 二维码前置模式实现网页内扫码支付 ​

使用 alipay.trade.page.pay 的 qr_pay_mode=4,可以在网站自己的支付弹窗内嵌入支付宝二维码。用户用手机支付宝扫码付款,网站确认到账后显示成功,原页面无需跳转到支付宝收银台。

本文基于 tio-boot、支付宝官方 Java SDK 和 React 的实际项目实现。2026 年 10 月 2 日,项目方已反馈包括二维码前置模式在内的支付流程实测成功。以下说明对应这一实现,不代表所有商户环境都无需联调。

1. 适用场景与前提 ​

适合已经接入支付宝电脑网站支付,希望将付款二维码直接放到本站页面的项目。仍然使用电脑网站支付能力、原有订单、异步通知和查单流程,不必为了网页内展示二维码而改接 alipay.trade.precreate 或聚合支付。

接入前需要完成:

  • 商户电脑网站支付产品签约、网页应用上线及 RSA2 加签配置。
  • 后端能够创建本站订单,保存用户、金额、商户订单号和有效期。
  • 后端已经实现通知验签、主动查单和幂等到账处理。
  • 公网异步通知地址可被支付宝访问。

产品申请及密钥配置可参考 支付宝:电脑网站支付接入指南。本文使用 v2 Java SDK、公钥模式;示例域名、账号和密钥均为占位信息。

2. 二维码前置模式的工作方式 ​

这里的“前置”是把支付宝生成的二维码页面嵌入商户页面。接口生成的是支付宝页面的访问链接,不是供商户自行绘制二维码的 qr_code 码串。

参数本文取值作用
methodalipay.trade.page.pay电脑网站支付接口
product_codeFAST_INSTANT_TRADE_PAY对应电脑网站支付产品码
qr_pay_mode4可设置宽度的嵌入式二维码
qrcode_width200二维码宽度,模式 4 下生效
out_trade_no本站生成的唯一订单号关联支付与业务订单
time_expire后端确定的绝对时间约束订单付款期限
notify_url后端通知接口接收服务端异步支付通知

本站负责弹窗、金额、倒计时和成功提示;iframe 中的二维码内容由支付宝负责。不能跨域读取 iframe 的 DOM,也不能根据 iframe 加载完成或出现某个页面就认定付款成功。

流程如下:

  1. 用户在网站选择金额,后端验证登录身份和金额。
  2. 后端先保存本站待支付订单,再生成支付宝签名链接。
  3. 前端把内嵌链接放入 iframe,同时向本站后端查询订单状态。
  4. 用户用支付宝扫码并确认付款。
  5. 后端通过通知或主动查单确认支付,在事务中完成订单更新和权益发放。
  6. 前端收到本站订单状态 PAID,卸载 iframe,显示支付成功。

3. 为同一个订单保留两个付款入口 ​

推荐同时生成两个链接:

返回字段模式用途
qr_pay_urlqr_pay_mode=4加载到支付弹窗的 iframe
pay_urlqr_pay_mode=2在新标签页打开完整支付宝收银台,作为备用入口

这两个字段是本文项目自定义的接口字段,不是支付宝 API 的原生响应字段。

两个链接使用相同的商户订单号、金额、标题和有效期,分别由后端签名。不要为了备用入口新建一笔订单,也不要让前端在已签名链接上修改 qr_pay_mode 或其他参数。

本文实现中,内嵌链接不设置 return_url,由本站查单结果控制成功弹窗;独立收银台链接保留 return_url,付款返回后展示完整结果页。这样原充值标签页仍停留在充值页,备用收银台返回的标签页才进入结果页。

4. Java SDK 配置 ​

项目使用的 SDK 版本如下,其他版本应结合自身环境验证:

xml
<dependency>
  <groupId>com.alipay.sdk</groupId>
  <artifactId>alipay-sdk-java</artifactId>
  <version>4.40.934.ALL</version>
</dependency>

示例配置:

properties
ALIPAY_APP_ID=YOUR_APP_ID
ALIPAY_SELLER_ID=YOUR_SELLER_ID
ALIPAY_PRIVATE_KEY_PATH=certs/alipay/app-private-key.pem
ALIPAY_PUBLIC_KEY_PATH=certs/alipay/alipay-public-key.pem
ALIPAY_NOTIFY_URL=https://api.example.com/api/pay/alipay/notify
ALIPAY_RETURN_URL=https://www.example.com/recharge/result

ALIPAY_PRIVATE_KEY_PATH 指向应用私钥,ALIPAY_PUBLIC_KEY_PATH 指向支付宝公钥。密钥仅在后端读取;配置文件必须由应用启动流程实际加载。

下面的类可放到 tio-boot 项目的支付模块中。示例读取 PKCS#8 私钥及公钥 PEM 文件,使用正式网关;沙箱需要单独配置网关、应用、密钥和账号。

java
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;

import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradePagePayModel;
import com.alipay.api.request.AlipayTradePagePayRequest;
import nexus.io.tio.utils.environment.EnvUtils;

public final class AlipayPagePayment {
  private final AlipayClient client;
  private final String notifyUrl;
  private final String returnUrl;

  public AlipayPagePayment() throws Exception {
    client = new DefaultAlipayClient(
        "https://openapi.alipay.com/gateway.do",
        required("ALIPAY_APP_ID"), key("ALIPAY_PRIVATE_KEY_PATH"),
        "json", "UTF-8", key("ALIPAY_PUBLIC_KEY_PATH"), "RSA2");
    notifyUrl = required("ALIPAY_NOTIFY_URL");
    returnUrl = required("ALIPAY_RETURN_URL");
  }

  public record Links(String qrPayUrl, String payUrl) {}

  // 参数全部来自服务端已经保存的订单和账户资料。
  public Links createLinks(String orderNo, int amountFen,
      OffsetDateTime expires, String subject) throws Exception {
    if (amountFen <= 0 || expires == null || subject == null || subject.isBlank()) {
      throw new IllegalArgumentException("Invalid payment order");
    }
    return new Links(
        createUrl(orderNo, amountFen, expires, subject, true),
        createUrl(orderNo, amountFen, expires, subject, false));
  }

  private String createUrl(String orderNo, int amountFen,
      OffsetDateTime expires, String subject, boolean embedded) throws Exception {
    AlipayTradePagePayModel model = new AlipayTradePagePayModel();
    model.setOutTradeNo(orderNo);
    model.setTotalAmount(BigDecimal.valueOf(amountFen, 2).toPlainString());
    model.setSubject(subject);
    model.setProductCode("FAST_INSTANT_TRADE_PAY");
    model.setQrPayMode(embedded ? "4" : "2");
    if (embedded) {
      model.setQrcodeWidth(200L);
    }
    model.setTimeExpire(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
        .format(expires.atZoneSameInstant(ZoneId.of("Asia/Shanghai"))));

    AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
    request.setBizModel(model);
    request.setNotifyUrl(notifyUrl);
    if (!embedded) {
      request.setReturnUrl(returnUrl);
    }
    // GET 模式生成签名后的页面链接,不把可执行 HTML 返回给前端。
    return client.pageExecute(request, "GET").getBody();
  }

  private static String required(String name) {
    String value = EnvUtils.get(name);
    if (value == null || value.isBlank()) {
      throw new IllegalStateException("Missing configuration: " + name);
    }
    return value.trim();
  }

  private static String key(String pathKey) throws Exception {
    return Files.readString(Path.of(required(pathKey)), StandardCharsets.UTF_8)
        .replace("-----BEGIN PRIVATE KEY-----", "")
        .replace("-----END PRIVATE KEY-----", "")
        .replace("-----BEGIN PUBLIC KEY-----", "")
        .replace("-----END PUBLIC KEY-----", "")
        .replaceAll("\\s", "");
  }
}

pageExecute(..., "GET") 生成链接不等于交易已经在支付宝侧建立。两个模式的链接均应在用户主动下单时生成,不要在每次状态轮询中重新签名。

5. tio-boot 下单接口与订单数据 ​

例如提供 POST /api/pay/alipay/page,请求只包含购买参数:

json
{"amount": 10}

处理器从认证上下文确定用户;服务层校验金额,保存订单,再调用上面的 createLinks。客户端不能决定收款账号、回调地址、商品标题或实际发放积分。

响应示意如下,其中链接为占位值,不可直接付款:

json
{
  "code": 1,
  "data": {
    "order_id": "123456789012345678",
    "out_trade_no": "MPA123456789012345678",
    "channel": "alipay",
    "status": "PENDING",
    "amount_fen": 1000,
    "amount_yuan": 10,
    "credits": 10,
    "expire_at": "2026-10-02T10:30:00Z",
    "qr_pay_url": "<后端生成的模式4签名链接>",
    "pay_url": "<后端生成的模式2签名链接>"
  }
}

订单 ID 使用字符串,避免 JavaScript 大整数精度丢失。expire_at 返回带时区的时间,前端据此倒计时;支付平台的 time_expire 才负责限制实际付款期限。

可以复用同一用户、金额、渠道、应用、收款账号下尚未过期的待支付订单。金额变化或订单已关闭时创建新订单;不要仅靠前端隐藏二维码来替代服务端订单状态管理。

在商品说明中标识充值账户 ​

商品说明通过 model.setSubject(...) 设置,例如:

text
MyProfessor 积分充值(user@example.com)

从后端账户资料优先读取邮箱,没有邮箱时使用手机号;两者都没有时保留基础商品名。按接口要求限制标题长度并处理不支持的字符。该字段会发送给支付宝并可能展示在交易记录中,若希望脱敏,应在服务端生成标题时统一处理。

6. React 支付弹窗 ​

下面展示嵌入和状态切换的核心组件。外层负责弹窗容器与倒计时,传入的 expired 表示是否到期;queryOrder 应调用本站带登录认证的查单接口,不直接调用支付宝。组件按 order.order_id 设置 React key,切换订单时重新挂载。

tsx
import {useEffect, useRef, useState} from "react";

type Order = {
  order_id: string;
  status: "PENDING" | "PAID" | "CLOSED";
  amount_fen: number;
  qr_pay_url: string;
  pay_url: string;
};

export function AlipayQrPayment({order, expired, queryOrder, onPaid}: {
  order: Order;
  expired: boolean;
  queryOrder: (id: string) => Promise<Order>;
  onPaid: (paid: Order) => void;
}) {
  const [status, setStatus] = useState(order.status);
  const [queryError, setQueryError] = useState(false);
  const queryRef = useRef(queryOrder);
  const paidRef = useRef(onPaid);
  queryRef.current = queryOrder;
  paidRef.current = onPaid;

  useEffect(() => {
    if (status !== "PENDING") return;
    let stopped = false;
    let timer: ReturnType<typeof setTimeout>;
    const check = async () => {
      try {
        const result = await queryRef.current(order.order_id);
        if (stopped) return;
        if (result.order_id !== order.order_id) throw new Error("Order mismatch");
        setQueryError(false);
        if (result.status === "PAID") {
          setStatus("PAID");
          paidRef.current(result);
          return;
        }
        if (result.status === "CLOSED") {
          setStatus("CLOSED");
          return;
        }
      } catch {
        if (!stopped) setQueryError(true);
      }
      if (!stopped) timer = setTimeout(check, 2500);
    };
    timer = setTimeout(check, 2500);
    return () => { stopped = true; clearTimeout(timer); };
  }, [order.order_id, status]);

  if (status === "PAID") return <p role="status">支付成功,充值已到账。</p>;
  if (status === "CLOSED") return <p>订单已关闭,请重新发起充值。</p>;
  if (expired) return <p>付款入口已过期,仍在核对可能延迟到达的支付结果。</p>;

  return <div style={{textAlign: "center"}}>
    <p>使用支付宝扫一扫付款</p>
    <h2>¥{(order.amount_fen / 100).toFixed(2)}</h2>
    <iframe
      src={order.qr_pay_url}
      title="支付宝付款二维码"
      width={240}
      height={240}
      style={{display: "block", margin: "0 auto", maxWidth: "100%",
        border: "1px solid #e2e8f0", borderRadius: 12, background: "white"}}
      sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"
      referrerPolicy="no-referrer"
    />
    <p>二维码未显示或不方便扫码?</p>
    <a href={order.pay_url} target="_blank" rel="noopener noreferrer">
      打开支付宝收银台
    </a>
    <p role="status">{queryError
      ? "暂时无法查询,正在重试;已付款请勿重复支付。"
      : "付款后将自动确认到账,请勿重复支付。"}</p>
  </div>;
}

实际应用还应提供关闭、重新发起支付和手动查询按钮,并按业务需要限制长时间自动查询。上面的示例采用串行轮询,前一次请求结束后再安排下一次,避免请求堆积;卸载时停止后续查询,忽略已经发出请求的迟到响应。

iframe 使用沙箱限制顶层导航,允许支付宝页面所需的脚本、表单和弹出窗口。不要追加 allow-top-navigation 来让支付页接管充值页面。该配置应在目标浏览器中验证;二维码尺寸为 200px,iframe 留出空间设为 240×240,并非所有页面尺寸都必须照搬。

7. 支付结果与权益发放 ​

支付结果始终以服务端核验为准:

  • 异步通知先做 RSA2 验签,再校验 AppID、收款方、商户订单号、渠道和金额。
  • 查单接口先校验订单属于当前用户;本地仍待支付时,可调用 alipay.trade.query 补查。
  • 只有确认支付宝交易成功后,才在事务中更新订单并发放积分或其他权益。
  • 用条件更新或唯一约束保证幂等,通知重试、前端查询和多个标签页不能重复发放。
  • 同步返回参数只用于定位订单和页面导航,不作为到账凭证。

原充值页的 onPaid 只更新余额,保留弹窗成功状态,不执行页面跳转。独立收银台的返回页则重新向后端查询订单,再显示金额、订单号、到账积分等信息。

前端倒计时到期时移除付款 iframe,但仍可查询是否已经支付。网络超时、通知延迟或暂时查不到交易,都不应直接将本地订单判为支付失败。

8. 常见问题 ​

8.1 二维码不显示 ​

先检查下单响应是否有 qr_pay_url,再确认解码后的业务参数包含 qr_pay_mode=4 和 qrcode_width。诊断时不要把完整带签名的链接打印到公开日志。

检查浏览器网络面板和控制台,区分支付页面报错、CSP 拦截、iframe 策略及网络问题。若站点设置了 CSP,其 frame-src 需要允许实际支付宝页面及必要跳转的来源;应依据观察到的来源配置,而不是直接放开全部站点。

iframe 的 load 事件也可能在错误页加载后触发,因此不能把它当成“二维码加载成功”。始终保留独立收银台入口,不要抓取或截图支付宝页面来生成另一份付款二维码。

8.2 查单提示 ACQ.TRADE_NOT_EXIST ​

本站保存订单、SDK 生成签名链接和支付宝侧建立交易不是同一个时刻。用户尚未进入收银台或交易尚未建立时,主动查询可能返回 ACQ.TRADE_NOT_EXIST,之后再查询便可能成功。

此时保持本地待支付状态,继续按合理间隔查单。若持续出现,应核对同一商户订单号、正式/沙箱环境、应用和收款账号,以及二维码页面是否真正加载;不要只关闭日志而忽略未建单原因。

接口返回 code=10000 仅表示查询成功,还要检查 trade_status 才能决定是否到账。

8.3 商品说明没有显示账号 ​

依次核对:运行中的后端版本、服务端账户资料、该订单签名链接中的 biz_content.subject、支付宝最终账单。必须比对同一订单,不能用其他订单的查单日志来判断标题。

可记录标题版本、商户订单号、contactSource=email/phone/none 和标题长度,避免记录邮箱、手机号明文及完整签名链接。确认部署使用的是本次构建的 JAR,Dockerfile 的复制路径与启动文件名必须和 Maven 产物一致。

8.4 手机浏览器如何付款 ​

PC 网页展示二维码、手机支付宝扫码是本文主要场景。用户在同一部手机浏览网站时不一定方便扫码,备用收银台只是额外入口,并不保证所有移动浏览器都能直接唤起支付宝。需要完整移动端体验时,应另行评估手机网站支付的产品权限和接入方式。

9. 验证与部署 ​

部署时同时更新前后端。对于兼容升级,前端可在没有 qr_pay_url 时回退到原来的收银台按钮。

本项目验证覆盖:

  1. SDK 生成的内嵌链接为模式 4、宽度 200,备用链接为模式 2。
  2. 双链接使用同一订单号;金额和账号标题来自服务端。
  3. 内嵌链接不传同步返回地址,备用链接保留同步返回地址。
  4. 弹窗自动查询,成功后移除 iframe,原充值页保持不变。
  5. 手机窄屏布局、备用入口、过期及错误状态。
  6. 通知验签、金额校验、重复到账防护和事务回滚。
  7. 项目方完成实际扫码付款并反馈测试成功。

本地模拟页面适合验证 UI 和状态流转,SDK 本地签名测试适合验证参数;真实二维码加载、账户产品权限、异步通知可达性和实际到账,需要真实环境验证。

10. 官方参考 ​

MIT Licensed