支付宝:电脑网站支付接入指南
本文面向自行运营网站的普通商户,介绍从申请产品到使用 tio-boot 后端接入支付宝收款的过程。用户在网站选择商品,后端创建订单并生成支付宝收银台链接,用户跳转后付款,后端通过异步通知和主动查单确认到账。
本文采用官方 Java SDK 的 v2 接口和 RSA2 公钥模式,调用 alipay.trade.page.pay。电脑网站支付、手机网站支付和 App 支付是不同产品,不能用其中一个产品的申请结果代表其他产品也已开通。
所有商户资料、AppID、账号、域名和订单号均应替换为自己的信息。本文仅使用占位符和 example.com 示例域名,不包含真实企业资料、手机号、验证码或密钥。
1. 先准备哪些材料
| 准备项 | 说明 |
|---|---|
| 企业支付宝账号 | 完成对应主体的实名认证,使用本次实际收款的企业账号登录 |
| 营业执照信息 | 公司名称、经营范围、法定代表人等,用于核对主体和经营类目;已认证账号可能无需重复上传 |
| 网站地址及备案信息 | 网站应能正常访问,并展示实际商品或服务、价格和购买流程;备案主体与收款主体应核对一致 |
| 联系人 | 提供真实姓名和能接收短信的手机号,由本人完成验证 |
| 应用名称、图标、简介 | 使用实际产品名称及图标,简介如实描述服务内容与交付方式 |
| 特殊材料 | 如果使用其他主体的网站,或经营类目要求资质,按页面要求提供授权函、许可证等材料 |
未上线网站是否可以用首页、商品页和支付页截图申请,以当前申请页面为准。选择类目要结合营业执照和实际业务,不能只挑容易通过审核的类目。
开通费、交易手续费和可能适用的保证金是不同事项。不要把其他平台的认证费直接套用到支付宝。申请时逐项查看当前产品页面和签约合同中的费用说明;示例中若按 0.6% 计算,收款 100 元对应手续费 0.60 元,这只是计算示例,不是对所有商户的费率承诺。
2. 在商家平台申请支付产品
- 登录 支付宝商家平台,确认当前企业名称和收款账号。
- 在产品中心找到「电脑网站支付」,进入申请流程。
- 如实选择经营类目,填写网站地址和联系人;按页面要求补充材料。
- 核对收款主体、网站、费率及协议,由联系人完成短信验证后提交。
- 查看申请结果。「已提交,正在审核中」只代表受理,须等到产品签约生效。
页面提示的预计审核时间不是批准承诺。若被退回,按原因修改材料,先查看已有申请,避免重复申请。短信验证码有时效且通常只能使用一次,优先由本人在官方页面填写,不应写入教程、截图说明或项目日志。
3. 在开放平台创建网页应用
登录 支付宝开放平台,创建「网页应用」,填写名称、图标、简介和官网,并绑定本次收款的企业账号。首次使用平台可能要求验证联系人信息。
应用类型和绑定主体可能在创建后不能修改,应在提交前核对。上传图标后可能还会出现裁剪确认弹窗,需要确认后才算完成上传。
创建完成后保存 AppID。在应用的「开发设置」配置接口加签方式,再从应用详情提交上线审核。产品签约审核与应用上线审核是两个流程;应用创建成功或密钥保存成功,都不等于可以正式收款。还应在「可调用产品」确认对应支付能力可用。
4. 配置 RSA2 密钥
三个名称要分清:
| 名称 | 用途 | 保存位置 |
|---|---|---|
| 应用私钥 | 后端给支付请求签名 | 仅保存在自己的服务端或密钥管理系统 |
| 应用公钥 | 支付宝验证商户请求 | 上传至该应用的接口加签配置 |
| 支付宝公钥 | 商户验证支付宝通知和接口响应 | 从该应用的配置页面获取,供后端使用 |
本文选择「密钥」加签方式,算法使用 RSA2。若使用要求证书模式的其他产品,需另行按证书模式集成,不能直接照搬本文配置。
可使用支付宝官方密钥工具生成密钥,也可在安装了 OpenSSL 的电脑上执行:
mkdir -p certs/alipay
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out certs/alipay/app-private-key.pem
openssl pkey -in certs/alipay/app-private-key.pem -pubout -out certs/alipay/app-public-key.pem
Windows 可先手动创建目录再执行两个 OpenSSL 命令。执行前确认目标文件不存在,不要覆盖已经绑定应用的私钥。上述私钥为 PKCS#8 格式;在平台粘贴应用公钥时,按页面要求使用去掉 PEM 头尾及换行的 Base64 内容。
保存公钥时可能再次要求主账号短信验证。看到「密钥保存成功」后,获取支付宝公钥,保存为 certs/alipay/alipay-public-key.pem。不要把应用公钥错当成支付宝公钥。
将以下路径加入 .gitignore,并限制运行账号之外的读取权限:
certs/alipay/
secrets.txt
.env
.gitignore 不能清除已经提交过的文件。如果私钥已经泄漏,应更换密钥并更新平台配置,不能仅删除文件后继续使用。
5. tio-boot 配置与 SDK
使用官方 com.alipay.sdk:alipay-sdk-java 依赖,在项目的依赖管理中选择经过验证的版本。SDK 用法参见 官方 Java SDK 文档。不要自行拼接签名算法或从非官方网页复制密钥。
配置示例:
ALIPAY_ENABLED=false
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/pay/result
ALIPAY_SELLER_ID 是实际收款支付宝账号的用户标识(PID),不是 AppID,也不是登录邮箱。应从官方后台核对。配置文件需要被项目启动流程实际加载;仅把文件放进目录不会自动生效。相对密钥路径以服务进程工作目录为基准。
以下是配置和客户端的核心示例,异常由应用启动或接口层处理:
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import com.alipay.api.AlipayClient;
import com.alipay.api.DefaultAlipayClient;
import nexus.io.tio.utils.environment.EnvUtils;
public final class AlipayClients {
public static String required(String key) {
String value = EnvUtils.get(key);
if (value == null || value.isBlank()) {
throw new IllegalStateException("Missing configuration: " + key);
}
return value.trim();
}
public static String readKey(String pathKey) throws Exception {
return Files.readString(Path.of(required(pathKey)), StandardCharsets.UTF_8)
.replaceAll("-----BEGIN [^-]+-----", "")
.replaceAll("-----END [^-]+-----", "")
.replaceAll("\\s", "");
}
public static AlipayClient create() throws Exception {
return new DefaultAlipayClient(
"https://openapi.alipay.com/gateway.do",
required("ALIPAY_APP_ID"), readKey("ALIPAY_PRIVATE_KEY_PATH"),
"json", "UTF-8", readKey("ALIPAY_PUBLIC_KEY_PATH"), "RSA2");
}
}
客户端和密钥可在服务启动时初始化并复用。关闭 ALIPAY_ENABLED 时停止创建新支付订单,但仍需处理已经发起的订单通知。沙箱必须使用沙箱应用、账号和密钥,网关以官方沙箱控制台为准,不能仅改网关就复用正式环境资料。
6. 创建订单并返回收银台链接
下单接口必须要求登录,由服务端根据商品或充值规则确定价格。不要相信前端传来的收款账号、积分数量、商品描述或回调地址。
先保存本地待支付订单,再生成支付链接。订单至少记录用户、支付渠道、应用、收款账号、金额(整数分)、状态、过期时间和支付宝交易号。商户订单号需要唯一,可使用雪花 ID:
String orderNo = "ALI" + nexus.io.tio.utils.snowflake.SnowflakeIdUtils.id();
下面只展示生成链接部分;其中的金额和订单号必须来自已经持久化的服务端订单:
import java.math.BigDecimal;
import com.alipay.api.AlipayClient;
import com.alipay.api.domain.AlipayTradePagePayModel;
import com.alipay.api.request.AlipayTradePagePayRequest;
import com.alipay.api.response.AlipayTradePagePayResponse;
public String checkoutUrl(AlipayClient client, String orderNo,
long amountFen) throws Exception {
if (amountFen <= 0) {
throw new IllegalArgumentException("Invalid amount");
}
AlipayTradePagePayModel model = new AlipayTradePagePayModel();
model.setOutTradeNo(orderNo);
model.setTotalAmount(BigDecimal.valueOf(amountFen, 2).toPlainString());
model.setSubject("网站服务订单");
model.setProductCode("FAST_INSTANT_TRADE_PAY");
model.setTimeoutExpress("15m");
AlipayTradePagePayRequest request = new AlipayTradePagePayRequest();
request.setBizModel(model);
request.setNotifyUrl(AlipayClients.required("ALIPAY_NOTIFY_URL"));
request.setReturnUrl(AlipayClients.required("ALIPAY_RETURN_URL"));
AlipayTradePagePayResponse response = client.pageExecute(request, "GET");
return response.getBody();
}
返回给前端订单号和 pay_url,前端通过链接进入收银台。pageExecute 生成签名链接,不表示订单已经付款,也不能用它证明产品签约已生效。复用订单时,应保持原订单的过期时间,不要每次生成链接都延长支付期限。
页面回跳只用于导航。用户访问成功页、关闭窗口或点击“我已支付”,都不能直接发放商品或积分。
6.1 另一种收款形态:二维码支付(当面付)
本文主流程用的是「电脑网站支付」,用户从浏览器跳到支付宝收银台完成付款。如果希望像扫码支付那样在当前页面弹出二维码、用户用支付宝 App 扫码付款,需要的是另一个产品 「当面付」的预下单接口 alipay.trade.precreate:
| 电脑网站支付 | 当面付(预下单) | |
|---|---|---|
| 接口 | alipay.trade.page.pay | alipay.trade.precreate |
| 返回 | 签名后的收银台链接 | qr_code(一段可编码成二维码的字符串) |
| 使用场景 | 浏览器内跳转收银台 | 弹出二维码,用支付宝 App 扫码 |
| 产品签约 | 电脑网站支付 | 当面付 |
两者是独立签约的产品,只开通其中一个不能使用另一个。扫码形态的接入要点:
import com.alipay.api.domain.AlipayTradePrecreateModel;
import com.alipay.api.request.AlipayTradePrecreateRequest;
import com.alipay.api.response.AlipayTradePrecreateResponse;
public String precreate(AlipayClient client, String orderNo, long amountFen) throws Exception {
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
model.setOutTradeNo(orderNo);
model.setTotalAmount(java.math.BigDecimal.valueOf(amountFen, 2).toPlainString());
model.setSubject("网站服务订单");
AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest();
request.setBizModel(model);
request.setNotifyUrl(AlipayClients.required("ALIPAY_NOTIFY_URL"));
AlipayTradePrecreateResponse response = client.execute(request);
if (!response.isSuccess()) {
throw new IllegalStateException("预下单失败:" + response.getSubCode());
}
return response.getQrCode();
}
拿到 qr_code 后,把它交给二维码库编码成图片(PNG 或 SVG,也可直接生成 data URL 交给前端 <img>)。页面同时展示金额、有效期和倒计时,并轮询服务端订单状态;服务端在订单仍是待支付时用 alipay.trade.query 主动查单。
二维码形态有几个容易忽略的点:
- 倒计时结束要主动关单。二维码有效期内用户仍可能完成支付,但过期后应调用
alipay.trade.close关闭该笔交易;只在前端隐藏二维码、不关单,会让一笔已失效的订单仍可被支付。 ACQ.TRADE_NOT_EXIST不是错误。用户尚未付款时查单会返回这个子码,此时必须保持订单为待支付,不能判成失败或关单;这与第 8 节的要求一致。- 二维码内容原样使用。不要把
qr_code再包一层自己的链接,否则支付宝 App 无法识别。 - 不要依赖前端回调。扫码后的结果仍然只以异步通知或服务端主动查单为准。
两条产品可以并存:PC 端走收银台跳转,需要扫码形态时走预下单,二者共用同一套订单表和幂等结算逻辑。
7. 接收支付宝异步通知
支付宝支付通知是表单 POST,通常为 application/x-www-form-urlencoded。tio-boot 可从 HttpRequest.getBodyString() 读取原始正文。必须只进行一次表单解码;签名中的 + 经 %2B 解码后不能再解码为空格。
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
public static Map<String, String> parseForm(String body) {
if (body == null || body.isBlank()) {
throw new IllegalArgumentException("Empty form");
}
Map<String, String> values = new LinkedHashMap<>();
for (String field : body.split("&")) {
String[] pair = field.split("=", 2);
if (pair.length != 2) {
throw new IllegalArgumentException("Invalid form");
}
String key = URLDecoder.decode(pair[0], StandardCharsets.UTF_8);
String value = URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
if (values.putIfAbsent(key, value) != null) {
throw new IllegalArgumentException("Duplicate field");
}
}
return values;
}
保留 sign、sign_type 等字段供 SDK 验签,不要自己对整个请求正文排序签名:
boolean verified = "RSA2".equals(params.get("sign_type"))
&& com.alipay.api.internal.util.AlipaySignature.rsaCheckV1(
params, alipayPublicKey, "UTF-8", "RSA2");
验签通过后仍须核对以下业务信息,全部通过才能结算:
out_trade_no对应本地订单,渠道为支付宝。app_id与当前应用及订单中保存的应用一致。seller_id与实际收款账号及订单一致。total_amount与订单金额一致,用BigDecimal精确比较,不能使用double。trade_no非空;若订单已经支付,必须与已保存的交易号一致。- 仅将
TRADE_SUCCESS、TRADE_FINISHED作为支付成功处理;不能把其他状态当成到账。
处理器实现 HttpRequestHandler,handle(HttpRequest request) 返回 HttpResponse。注册到 HttpRequestRouter 的方式与其他 tio-boot 接口相同:
router.add("/api/pay/alipay/notify", new AlipayNotifyHandler());
AlipayNotifyHandler 是业务处理器,需要自行实现上述解析、验签和订单结算。通知路由不要求网站用户登录,应从登录拦截器放行,但始终进行签名与订单校验。只接受 POST,并在服务器和代理层设置合理的正文大小限制。
事务提交成功后,返回 HTTP 200 和纯文本 success:
return nexus.io.tio.boot.http.TioRequestContext.getResponse()
.setStatus(200)
.setString("success", "UTF-8", "text/plain;charset=UTF-8");
不能包装成 JSON,也不要加 HTML 或调试文字。验签失败、金额不匹配或事务失败时返回失败响应,不要提前回复 success。
7.1 从请求里取参数的两种方式
tio-boot 的 HttpRequest 提供了两套取值方式,用途不同,不要混用:
| 方法 | 返回 | 适用场景 |
|---|---|---|
getBodyString() | 原始正文字符串 | 需要逐字节原始正文的场景,如支付通知验签、解密、第三方签名校验 |
getRequestMap() | Map<String, Object> | 普通业务接口取值,合并查询参数、已解码的表单参数与 JSON 对象正文 |
getRequestMap() 会自动识别 application/x-www-form-urlencoded 与 multipart/form-data,直接返回框架已经解码好的参数;其他非空正文按 JSON 对象解析,JSON 同名字段覆盖查询参数,重复查询参数取首值。空正文时只返回查询与表单参数。数组、标量、null 或非法 JSON 会抛出 ParameterValidationException,由应用统一转换为 400。它返回的是一份新 Map,不会改动原有参数表,getParam()、getObject()、getLong() 的取值规则保持不变。需要一次性把查询参数、表单字段和 JSON 正文合并处理时,用它比手工判断正文类型更省事。完整语义见 HttpRequest。
支付宝与微信的支付通知都必须用 getBodyString() 读取原始正文,因为验签依据的是未经改动的原始数据;一旦先做 JSON 解析再重新序列化,或提前做了一次表单解码,验签就会失败。两种写法分别长这样:
// 支付通知:保留原始正文,自行做一次表单解码,再交给 SDK 验签
String raw = request.getBodyString();
Map<String, String> params = parseForm(raw);
// 普通业务接口:查询参数、表单字段与 JSON 正文一次性合并取值
Map<String, Object> parameters = request.getRequestMap();
String outTradeNo = ParameterValidator.text(parameters.get("out_trade_no"), "out_trade_no", 64);
8. 防止重复到账与主动查单
通知可能重复,查单与通知也可能同时返回成功。不能采用“先查状态、再无条件加积分”的方式。
在同一个数据库事务中完成:
- 条件更新订单:仅允许
PENDING变为PAID,保存支付宝交易号。 - 只有更新成功的一次事务,才发放商品权益或充值积分并写流水。
- 权益发放失败则回滚订单更新;不能让订单已支付而权益没有到账。
- 重复通知核对相同交易号后返回成功,不再发放权益;不同交易号应记录异常供对账。
建议对渠道与支付宝交易号建立适当的唯一约束,对业务流水设置唯一订单标识。数据库并发冲突应作为失败处理,允许支付宝重试或后续查单恢复。
用户查询本地订单状态时先验证订单归属。仍未支付的订单可用 AlipayTradeQueryRequest 调用 alipay.trade.query,由官方 SDK 验证响应签名;核对订单号、金额、收款账号等返回信息后,进入与通知相同的结算函数。查询应限频,避免每次前端轮询都无节制调用渠道。
网络异常、查询超时或交易暂不存在,都不表示已关闭。本地倒计时结束也不能证明用户未付款。确认渠道关闭后才关闭本地订单;已关闭订单收到支付成功时应保留证据并进入对账处理,不能静默丢失资金。
9. 移动端收款与「不跳网页」的支付
「电脑网站支付」是为桌面浏览器设计的产品,在手机浏览器里打开收银台链接体验很差。下面单独说明移动端应该怎么做。
9.1 为什么可以不出现网页中转
移动端不跳网页,靠的是系统级的 URL Scheme(Deep Link):支付 App 在安装时会向系统注册自己的协议,比如支付宝是 alipays://、微信是 weixin://。任何应用或浏览器只要打开这样一个链接,系统就会直接唤起对应的 App,把付款参数交给它,整个过程不经过中间网页。用户看到的是“点一下就直接进了支付 App 的付款页”。
具体到各端:
| 场景 | 用什么 | 唤起方式 |
|---|---|---|
| 手机浏览器(H5 网页) | 支付宝手机网站支付 alipay.trade.wap.pay | 服务端返回签名链接,浏览器打开后立即重定向到 alipays://,唤起支付宝 App |
| 手机浏览器(H5 网页) | 微信 H5 支付 mweb_url | 打开 mweb_url 后唤起微信客户端,必须在请求里带真实 referer,并在商户平台配置 H5 支付域名 |
| 微信内打开的网页 | 微信 JSAPI 支付 | 网页已在微信内置浏览器里,通过 WeixinJSBridge 直接拉起付款面板,完全不出微信 |
| 自家 App(iOS/Android) | 各家支付 SDK | App 内直接调用 SDK,由 SDK 负责唤起支付宝/微信 |
| 小程序 | 小程序支付 | 小程序内 wx.requestPayment |
关键点:“不跳网页”的前提是浏览器或应用本身能触发 URL Scheme。微信内置浏览器会拦截第三方 App 的唤起,所以微信内的网页只能用 JSAPI;支付宝在微信内也无法唤起,需要引导用户“在浏览器中打开”。
9.2 产品仍然是独立签约的
上面每一种形态都对应一个独立的支付产品,需要分别签约:
| 形态 | 产品 | 接口 |
|---|---|---|
| 手机浏览器唤起支付宝 | 手机网站支付 | alipay.trade.wap.pay |
| 手机浏览器唤起微信 | H5 支付 | mweb_url |
| 微信内网页 | JSAPI 支付 | prepay_id + WeixinJSBridge |
| 扫码 | 当面付 / Native | alipay.trade.precreate / code_url |
| PC 网页 | 电脑网站支付 | alipay.trade.page.pay |
只开通了电脑网站支付时,不能用它的链接去实现手机端的唤起;同理,只开通扫码支付也不能替代 H5 支付。接入前先在商户平台确认目标形态的产品已经签约。
9.3 工程上的注意点
- 回调地址复用同一套。不论哪个形态,异步通知都会打到同一个
notify_url,因此第 8 节的幂等结算逻辑可以完全共用,不需要按形态分叉。 - 订单要记录支付形态。同一张订单表里建议保留渠道 + 形态(PC / H5 / JSAPI / 扫码),便于对账和排查。
- H5 支付需要校验
referer。服务端取请求头referer传给微信下单接口;缺失或与商户平台配置的域名不一致会下单失败。 - 微信内网页要单独处理。
WeixinJSBridge未就绪时需要监听WeixinJSBridgeReady事件再拉起支付;发起支付的参数里的package字段对应服务端返回的packageVal。 - JSAPI 需要用户 OpenID。在公众号网页里通过网页授权拿
openid(授权域名要在公众平台配置),再下单。 - 不要把回跳当成功。移动端尤其容易把“从 App 返回网页”当成支付成功,到账依据仍然只有异步通知和服务端主动查单。
10. 上线前逐项验证
| 验证项 | 通过标准 |
|---|---|
| 产品与应用 | 支付产品签约生效,应用上线,对应能力可调用 |
| 密钥 | 应用公钥与本地私钥匹配,支付宝公钥来源正确 |
| 公网通知 | HTTPS 地址可访问,无登录跳转,代理保留表单正文 |
| 小额真实付款 | 订单金额正确,通知或查单确认后到账一次 |
| 重复处理 | 重复通知、并发查单不重复发放权益 |
| 异常处理 | 错误签名、金额、主体和订单渠道均被拒绝 |
| 事务回滚 | 权益发放失败时订单不会错误地保持已结算 |
| 用户退出 | 关闭收银台、通知延迟和页面回跳都能通过查单恢复 |
| 待支付查单 | 用户未付款时查单返回“交易不存在”,订单仍保持待支付,不会被误判为关单 |
| 二维码形态 | 倒计时结束后主动关单,二维码内容原样使用且能唤起支付宝 App |
先完成测试和部署,再开启 ALIPAY_ENABLED。未部署、未通过审核或未完成真实支付验证时,只能称为“代码接入完成”,不能宣布生产收款已开通。
11. 常见问题与资料脱敏
| 现象 | 排查方向 |
|---|---|
| 应用存在却不能支付 | 应用是否上线、产品是否签约、是否绑定正确主体 |
| 验签失败 | 是否误用应用公钥、私钥格式是否正确、正文是否重复解码 |
| 前端跳回但没有到账 | 回跳不是到账依据,检查通知接收和主动查单 |
| 通知不断重试 | 是否返回纯文本 success、数据库事务是否真正提交 |
| 保存密钥又要求验证码 | 平台安全验证与首次联系人验证是独立流程,使用本次新验证码 |
| 手机上打开收银台体验很差 | 电脑网站支付面向桌面浏览器,移动端应改用手机网站支付或 H5 支付 |
| 手机浏览器下单报错或不唤起 App | 对应产品(手机网站支付 / H5 支付)未签约,或 H5 支付未传 referer、域名未在商户平台配置 |
| 微信内网页无法唤起支付 | 微信会拦截第三方 App 唤起,微信内只能用 JSAPI 支付;支付宝需引导用户改用系统浏览器打开 |
对外分享文档时,将企业名替换为“示例科技有限公司”,账号使用 YOUR_APP_ID、YOUR_SELLER_ID,域名使用 example.com,手机号使用 1**********。不要发布实际验证码、私钥、签名支付链接、买家账号、交易流水、营业执照图片或带登录状态的浏览器留档。公钥虽然不是私钥,教程也无需复制真实商户公钥。
日志只记录排障必要的状态、错误类型和脱敏订单标识,不记录通知全文或完整配置。生产环境应关闭 tio.devMode,并检查代理和请求日志中间件是否记录支付请求正文;仅在业务处理器里不打印正文还不够。截图需检查页头账号、地址栏参数、弹窗、二维码和文件路径;终端输出已脱敏不代表磁盘中的原始日志也已脱敏。
