支付宝:使用 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 码串。
| 参数 | 本文取值 | 作用 |
|---|---|---|
method | alipay.trade.page.pay | 电脑网站支付接口 |
product_code | FAST_INSTANT_TRADE_PAY | 对应电脑网站支付产品码 |
qr_pay_mode | 4 | 可设置宽度的嵌入式二维码 |
qrcode_width | 200 | 二维码宽度,模式 4 下生效 |
out_trade_no | 本站生成的唯一订单号 | 关联支付与业务订单 |
time_expire | 后端确定的绝对时间 | 约束订单付款期限 |
notify_url | 后端通知接口 | 接收服务端异步支付通知 |
本站负责弹窗、金额、倒计时和成功提示;iframe 中的二维码内容由支付宝负责。不能跨域读取 iframe 的 DOM,也不能根据 iframe 加载完成或出现某个页面就认定付款成功。
流程如下:
- 用户在网站选择金额,后端验证登录身份和金额。
- 后端先保存本站待支付订单,再生成支付宝签名链接。
- 前端把内嵌链接放入 iframe,同时向本站后端查询订单状态。
- 用户用支付宝扫码并确认付款。
- 后端通过通知或主动查单确认支付,在事务中完成订单更新和权益发放。
- 前端收到本站订单状态
PAID,卸载 iframe,显示支付成功。
3. 为同一个订单保留两个付款入口
推荐同时生成两个链接:
| 返回字段 | 模式 | 用途 |
|---|---|---|
qr_pay_url | qr_pay_mode=4 | 加载到支付弹窗的 iframe |
pay_url | qr_pay_mode=2 | 在新标签页打开完整支付宝收银台,作为备用入口 |
这两个字段是本文项目自定义的接口字段,不是支付宝 API 的原生响应字段。
两个链接使用相同的商户订单号、金额、标题和有效期,分别由后端签名。不要为了备用入口新建一笔订单,也不要让前端在已签名链接上修改 qr_pay_mode 或其他参数。
本文实现中,内嵌链接不设置 return_url,由本站查单结果控制成功弹窗;独立收银台链接保留 return_url,付款返回后展示完整结果页。这样原充值标签页仍停留在充值页,备用收银台返回的标签页才进入结果页。
4. Java SDK 配置
项目使用的 SDK 版本如下,其他版本应结合自身环境验证:
<dependency>
<groupId>com.alipay.sdk</groupId>
<artifactId>alipay-sdk-java</artifactId>
<version>4.40.934.ALL</version>
</dependency>
示例配置:
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 文件,使用正式网关;沙箱需要单独配置网关、应用、密钥和账号。
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,请求只包含购买参数:
{"amount": 10}
处理器从认证上下文确定用户;服务层校验金额,保存订单,再调用上面的 createLinks。客户端不能决定收款账号、回调地址、商品标题或实际发放积分。
响应示意如下,其中链接为占位值,不可直接付款:
{
"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(...) 设置,例如:
MyProfessor 积分充值([email protected])
从后端账户资料优先读取邮箱,没有邮箱时使用手机号;两者都没有时保留基础商品名。按接口要求限制标题长度并处理不支持的字符。该字段会发送给支付宝并可能展示在交易记录中,若希望脱敏,应在服务端生成标题时统一处理。
6. React 支付弹窗
下面展示嵌入和状态切换的核心组件。外层负责弹窗容器与倒计时,传入的 expired 表示是否到期;queryOrder 应调用本站带登录认证的查单接口,不直接调用支付宝。组件按 order.order_id 设置 React key,切换订单时重新挂载。
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 时回退到原来的收银台按钮。
本项目验证覆盖:
- SDK 生成的内嵌链接为模式 4、宽度 200,备用链接为模式 2。
- 双链接使用同一订单号;金额和账号标题来自服务端。
- 内嵌链接不传同步返回地址,备用链接保留同步返回地址。
- 弹窗自动查询,成功后移除 iframe,原充值页保持不变。
- 手机窄屏布局、备用入口、过期及错误状态。
- 通知验签、金额校验、重复到账防护和事务回滚。
- 项目方完成实际扫码付款并反馈测试成功。
本地模拟页面适合验证 UI 和状态流转,SDK 本地签名测试适合验证参数;真实二维码加载、账户产品权限、异步通知可达性和实际到账,需要真实环境验证。
