做过支付对接的都知道,真正吃时间的往往不是代码本身,而是“没有真实商户号之前没法联调”这个尴尬局面。申请正式商户号要营业执照、要签约产品、要等审核,这套流程走下来,新手光是前置手续就能卡一到两周。所以我在接到第一个 SpringBoot 支付需求时,就直接从支付宝沙箱环境切入——零门槛、有真实测试账号、能完整走通支付/回调/退款全链路。这篇内容就是把这套完整流程拆开讲透,包含下单接口、异步通知、验签、主动查单、退款,以及我实际联调中踩到过的所有高频坑。对 SpringBoot 支付开发还不太熟的读者,照着这份思路走一遍,基本能把支付链路理解个七八成。
1. 沙箱环境准备:先别急着写代码,把这几个东西配齐
1.1 沙箱应用与密钥的获取方式
沙箱环境本质上是支付宝开放平台单独划出来的一套模拟系统,它和正式环境的接口地址、参数结构几乎完全一致,只是用了一套独立的密钥、应用ID和测试账号。好处是你不需要任何营业执照,只需要登录支付宝开放平台控制台,找到“沙箱环境”入口,就能一键创建沙箱应用。
创建完成后,系统会分配一个沙箱的 APPID,类似2021000111111111。这个 APPID 和正式环境里每个应用唯一的 APPID 作用是一样的,只是它只匹配沙箱网关。实际开发中我会建议直接用沙箱应用来做一切接口验证,等支付逻辑全部跑通,再把这套代码切到正式应用参数上。
密钥部分是新手最容易懵的地方。支付宝加签用的是 RSA2,也就是 SHA256withRSA,密钥长度至少 2048 位。你需要自己生成一对 RSA 密钥,把公钥上传到支付宝后台,同时从后台拿到支付宝的公钥。私钥自己保留,绝不外传。
具体流程是这样的:
- 在沙箱控制台找到“接口加签方式”,选择“公钥模式”。
- 使用支付宝官方提供的密钥生成工具,生成应用私钥和应用公钥。
- 把应用公钥复制粘贴到后台的公钥输入框。
- 保存后,后台会生成对应的“支付宝公钥”,这个字符串需要拷贝出来,后面代码里要用。
这里有非常关键的一个点:支付宝公钥 ≠ 应用公钥。我在早期对接时,曾把应用公钥当成支付宝公钥拿去验签,结果验签永远失败,报错信息还特别隐晦。你要记得,上传到后台的是应用公钥,从后台取出来的是支付宝公钥,一个管“我往外发数据时签名用”,一个管“我收到支付宝回传数据时验签用”。
1.2 全局参数一览:这些配置后面都会用到
与其边写代码边查参数,不如先把整条链路涉及到的配置项拉一张总表出来。以下是我在项目里用到的所有支付相关配置,按用途做了分类:
| 参数 | 沙箱环境值 | 正式环境值 | 用途 |
|---|---|---|---|
| 网关地址 | https://openapi.alipaydev.com/gateway.do | https://openapi.alipay.com/gateway.do | 所有 API 统一请求入口 |
| APPID | 沙箱控制台生成 | 正式应用申请后生成 | 标识应用 |
| 应用私钥 | 本地生成的 RSA 私钥 | 重新生成的正式私钥 | 生成请求签名 |
| 支付宝公钥 | 沙箱后台展示 | 正式后台展示 | 验签支付宝回调通知 |
| 签名类型 | RSA2 | RSA2 | 加签算法 |
| 回调地址 | 公网可访问的 http/https 地址 | 正式服务器地址 | 接收支付宝异步通知 |
| 返回地址 | 支付成功后页面跳转地址 | 正式前端页面地址 | 同步跳转展示结果 |
| 格式/字符集 | JSON / UTF-8 | JSON / UTF-8 | 请求与响应编码 |
我强烈建议把这份配置单独抽成一个配置文件管理,后续切环境时只需要改配置文件,不需要动业务代码。如果你用的是 SpringBoot,把这些参数放进application.yml就是最常规的做法。
1.3 沙箱账号和测试工具
沙箱环境和正式环境还有一个重要区别:支付环节使用的账号体系不同。你在沙箱控制台能看到系统自动分配的“沙箱买家”和“沙箱商家”账号。买家账号是一串手机号或邮箱格式的测试账号,支付时可以直接选择余额支付,默认会有一定的余额可供消费。
如果你要在手机端验证支付效果,支付宝专门提供了沙箱版支付宝 App,这个 App 只能用于沙箱环境,不能登录真实的支付宝账号。早期我联调时就犯过一个错:在真实支付宝 App 里去扫沙箱支付的付款码,结果自然什么都扫不出来。后来才反应过来,沙箱支付必须用沙箱版 App 配合沙箱买家账号才能完整走通。
另外,支付宝后台还有一个“沙箱工具”,比如支付宝开放平台开发助手,它不仅能帮你生成密钥,还能提供一个本地的接口调试工具。不过我更习惯直接看接口返回的报文和日志来定位问题,工具可以辅助,但不能依赖。
2. SpringBoot 工程骨架与支付参数配置
2.1 依赖引入与项目结构
沙箱环境的事情搞定后,就可以开始搭 SpringBoot 工程了。我这里以一个标准的 Maven 工程为例,Java 版本 8 就够用,SpringBoot 用 2.x 或 3.x 都可以,但需要注意 SDK 的兼容性。
支付宝官方 Java SDK 的坐标是com.alipay.sdk:alipay-sdk-java。我这里用的版本是4.38.0.ALL,这个版本对 Java 8 的兼容性很好,接口封装也比较完整。如果你们项目用的是更高版本,只要接口方法名一致,直接替换版本号就行。
<dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.38.0.ALL</version> </dependency>工程结构上,我习惯按照功能分包,而不是按技术层分包。支付相关代码集中放在alipay包里,里面分别放config、controller、service、param、model几个子包。这样后续做正式环境切换、加日志、加异常处理时,改动范围都能控制在一个包内。
2.2 配置文件与属性绑定
接下来把沙箱参数写进application.yml。这里有一个经验之谈:私钥字符串通常比较长,而且包含换行符,YAML 里直接粘贴会出问题。我建议你在配置里使用连续的字符串,也就是去掉换行,让私钥变成一行。如果你非要在配置里保留可读性,可以放在 properties 文件里用\换行符处理。
alipay: app-id: 2021000123456789 app-private-key: MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC... alipay-public-key: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA... gateway: https://openapi.alipaydev.com/gateway.do notify-url: https://你的公网域名/notify return-url: http://localhost:8080/return sign-type: RSA2 charset: UTF-8 format: JSON为了让 SpringBoot 能把这堆配置自动装配成对象,我写了一个AlipayProperties配置类:
@Component @ConfigurationProperties(prefix = "alipay") @Data public class AlipayProperties { private String appId; private String appPrivateKey; private String alipayPublicKey; private String gateway; private String notifyUrl; private String returnUrl; private String signType; private String charset; private String format; }注意,用@ConfigurationProperties需要引入spring-boot-configuration-processor的依赖,或者在启动类上开启@EnableConfigurationProperties。不过即使不做这些,只要 SpringBoot 能够扫描到@Component,配置绑定也能生效,只是 IDE 里不会有自动提示。
2.3 全局配置里最容易被忽略的两个细节
第一个细节是构建AlipayClient的方式。官方推荐的写法是在每次请求时创建一个客户端,但那样会重复加载私钥,影响性能。我的做法是在配置类里直接构建一个单例的AlipayClient,用@Bean交给 Spring 容器管理。
@Configuration public class AlipayConfig { @Bean public AlipayClient alipayClient(AlipayProperties props) { return new DefaultAlipayClient( props.getGateway(), props.getAppId(), props.getAppPrivateKey(), props.getFormat(), props.getCharset(), props.getAlipayPublicKey(), props.getSignType() ); } }第二个细节是参数校验。支付金额、订单号、商品标题这些参数如果为空,直接去调支付宝接口,不仅浪费一次请求,还会导致后续回调没数据可匹配。我建议在 Service 层先做一次简单的参数非空校验,宁可校验严格一些,也好过在日志里排查半天空参数问题。
3. 打通核心链路:下单、拼接参数、发起支付
3.1 为什么用 alipay.trade.page.pay 而不是别的接口
支付宝开放平台针对不同场景提供了不同的下单接口。常见的有:
alipay.trade.page.pay:电脑网站支付,用于 PC 端打开支付宝收银台。alipay.trade.wap.pay:手机网站支付,用于 H5 端拉起支付宝。alipay.trade.app.pay:App 支付,用于原生应用内跳转支付宝。alipay.trade.create:统一收单交易创建接口,只创建订单不直接跳支付。
我在这个教程里用的是alipay.trade.page.pay,因为它最适合新手理解“下单——跳转——支付——回调”这个完整闭环。它会把请求参数拼成一个自动提交的 HTML 表单,返回给浏览器,用户点击按钮或直接访问接口地址,就会自动跳转到支付宝收银台页面。
很多初学者会纠结alipay.trade.create和alipay.trade.page.pay的区别。简单理解:alipay.trade.create只是把订单信息登记到支付宝系统,不会直接引导用户支付,通常用于 App 等自定义收银台场景;而page.pay是交易和支付一气呵成,支付页面都由支付宝托管。
3.2 代码实现:构建请求并返回支付表单
先写一个下单的 Service 方法。方法入参只需要三个核心信息:业务订单号、支付金额、商品标题。这些信息在真实项目里一般来自你的业务订单表,这里只演示核心逻辑。
@Service public class AlipayService { private final AlipayClient alipayClient; private final AlipayProperties alipayProperties; public AlipayService(AlipayClient alipayClient, AlipayProperties alipayProperties) { this.alipayClient = alipayClient; this.alipayProperties = alipayProperties; } public String createOrder(String outTradeNo, String totalAmount, String subject) { AlipayTradePagePayRequest request = new AlipayTradePagePayRequest(); request.setNotifyUrl(alipayProperties.getNotifyUrl()); request.setReturnUrl(alipayProperties.getReturnUrl()); JSONObject bizContent = new JSONObject(); bizContent.put("out_trade_no", outTradeNo); bizContent.put("total_amount", totalAmount); bizContent.put("subject", subject); bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY"); request.setBizContent(bizContent.toJSONString()); try { AlipayTradePagePayResponse response = alipayClient.pageExecute(request); if (response.isSuccess()) { return response.getBody(); } throw new RuntimeException("下单失败: " + response.getMsg()); } catch (AlipayApiException e) { throw new RuntimeException("调用支付宝接口异常", e); } } }这里有几个必须理解的地方:
bizContent是业务参数的核心,支付宝所有业务参数都以 JSON 字符串形式放在这里。字段名必须严格遵循官方文档,out_trade_no是下划线命名,不能写错。total_amount是字符串类型的金额,单位为元,支持两位小数。很多新手从数据库取金额是分,如果直接用整形拼接会变成1000,实际应该拼接成10.00。product_code必须设置为FAST_INSTANT_TRADE_PAY,它对应电脑网站支付场景。如果你不小心漏了这个字段,接口会报“产品码缺失或无效”。
然后写一个 Controller 方法,把 HTML 表单直接返回给前端。这里有个坑需要提前说:接收这个字符串的页面,要么通过window.location.href跳转,要么直接把接口响应内容以 HTML 方式渲染,不能把这个字符串塞进 JSON 里,否则浏览器不会自动提交表单。
@RestController @RequestMapping("/alipay") public class AlipayController { private final AlipayService alipayService; public AlipayController(AlipayService alipayService) { this.alipayService = alipayService; } @GetMapping("/pay") public void pay(@RequestParam String orderNo, @RequestParam String amount, @RequestParam String subject, HttpServletResponse response) throws IOException { String form = alipayService.createOrder(orderNo, amount, subject); response.setContentType("text/html;charset=UTF-8"); response.getWriter().write(form); response.getWriter().flush(); } }3.3 同步跳转 returnUrl:只能做展示,不能做订单状态依据
支付完成后,支付宝会通过浏览器跳转到returnUrl。很多新手会在这个接口里直接更新订单状态,这是一个严重的错误认知。
returnUrl是同步跳转,用户支付完点了“返回商家”才会触发,浏览器地址栏也能看到参数。问题是用户可能支付成功后没有点返回,也可能页面被强制刷新,甚至网银页面异常时压根没有执行跳转。所以这个接口只能用来展示支付结果,真正的订单状态更新必须依赖异步通知。
我通常会让returnUrl接口做两件事:
- 调用
alipay.trade.query(查询接口)获取实时交易状态。 - 在支付结果页展示“支付成功”或“支付失败”的提示。
这样用户体验是实时的,但业务数据的最终落库以异步通知为准。
4. 异步通知与验签:钱到账了才算完
4.1 异步通知机制说明
支付宝的异步通知是整条支付链路里最核心、也最容易出问题的环节。支付成功后,支付宝服务器会主动向你的notifyUrl发起一个 POST 请求,请求中的参数包括订单号、交易号、支付金额、买家信息、交易状态等一堆字段。
这里有几个机制层面的特征需要注意:
- 通知最多会发送 8 次,按一定时间间隔递增,直到你返回“success”字符串,或达到上限。
- 你收到通知后必须返回纯文本的
success(不带引号、不带 JSON、不带 XML),如果返回其他内容,支付宝会认为通知失败并继续重试。 - 通知请求是支付宝服务器发起的,不经过用户浏览器,所以它的参数可信度必须通过验签来确认。
异步通知参数中的trade_status字段决定了订单的最终走向:
| trade_status | 含义 | 订单状态处理 |
|---|---|---|
| WAIT_BUYER_PAY | 交易创建,等待买家付款 | 不更新,保持待支付 |
| TRADE_SUCCESS | 支付成功 | 更新为已支付,发货 |
| TRADE_FINISHED | 交易完成且不可退款 | 更新为交易完成,关闭退款功能 |
| TRADE_CLOSED | 未付款交易超时关闭/退款后关闭 | 更新为已关闭 |
大多数业务场景只需要关注TRADE_SUCCESS和TRADE_FINISHED。前者代表买家已付款,后者代表整个交易生命周期结束。如果你做的是虚拟商品,在TRADE_SUCCESS时发货即可;如果做实物商品,退款功能在TRADE_FINISHED后就不能再发起了。
4.2 验签代码实现
验签的目的是确认“这条通知真的是支付宝发来的,而不是别的服务伪造的”。支付宝的通知请求是一串参数,加上签名参数sign,我们使用支付宝公钥进行验签。
我给出一个通用的通知处理代码:
@PostMapping("/notify") public String notify(HttpServletRequest request) throws AlipayApiException { Map<String, String> params = new HashMap<>(); Map<String, String[]> requestParams = request.getParameterMap(); for (String name : requestParams.keySet()) { String[] values = requestParams.get(name); String valueStr = ""; for (int i = 0; i < values.length; i++) { valueStr = (i == values.length - 1) ? valueStr + values[i] : valueStr + values[i] + ","; } params.put(name, valueStr); } boolean signVerified = AlipaySignature.rsaCheckV1( params, alipayProperties.getAlipayPublicKey(), alipayProperties.getCharset(), alipayProperties.getSignType() ); if (!signVerified) { return "failure"; } String outTradeNo = 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"); // 追加校验:同一订单金额必须与本地订单一致,appId 必须匹配 // 这里省略业务查询逻辑,读者需要自行对比本地订单金额 if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) { // 更新本地订单为已支付 // 注意幂等处理,防止重复通知导致重复发货 } return "success"; }代码里那个参数拼接逻辑看起来有点绕,但它是官方推荐的写法。原因是有个别参数值本身可能包含逗号,直接用request.getParameter()只拿到第一个值会丢数据,所以手动用逗号拼接所有值。
4.3 幂等处理与订单状态更新
异步通知会重试,也就是说同一个订单的“支付成功”通知,你可能会收到两次、三次甚至更多次。如果每次收到通知都执行“更新库存”、“发送邮件”、“赠送积分”之类的操作,用户会被重复发货、重复加积分。
我处理幂等的思路很简单:
- 根据
out_trade_no查本地订单。 - 如果订单状态已经是“已支付”,直接返回
success,不重复执行业务逻辑。 - 如果订单状态是“待支付”,才执行状态更新和后续业务动作。
这里有个细节值得注意:从第一次收到成功通知到更新订单状态之间,可能存在并发问题。两个重试通知几乎同时到达,线程 A 和线程 B 同时查到“待支付”,同时执行更新,造成重复发货。我建议在更新订单状态时加一个数据库条件更新:
int rows = orderMapper.updateStatus( orderNo, OrderStatus.PAID.getCode(), OrderStatus.WAIT_PAY.getCode() );也就是update ... set status = 'PAID' where order_no = ? and status = 'WAIT_PAY'。如果rows == 0,说明已经被其他线程更新过了,直接返回success。这个做法比在代码里用 synchronized 或分布式锁更轻量,也更保险。
5. 查询订单与退款逻辑:备用的两道保险
5.1 主动查询交易状态
异步通知虽然可靠,但不能完全依赖。用户支付成功后,可能由于网络原因导致通知延迟或者你服务端的回调地址暂时不可用,这时主动查询就是一个兜底方案。
查询接口是alipay.trade.query,代码实现比较简洁:
public AlipayTradeQueryResponse queryTrade(String outTradeNo) throws AlipayApiException { AlipayTradeQueryRequest request = new AlipayTradeQueryRequest(); JSONObject bizContent = new JSONObject(); bizContent.put("out_trade_no", outTradeNo); request.setBizContent(bizContent.toJSONString()); return alipayClient.execute(request); }响应对象里有tradeStatus、sendPayDate、totalAmount等字段。我在实际项目中编写了一个定时任务,每 10 分钟扫描一次订单表中“已下单但长时间未支付”的订单,调用查询接口确认最终状态,补偿异步通知丢失导致的漏单。
这里要提醒一下:定时任务的频率不要太高,否则对支付宝接口的调用量会暴涨,也可能触发业务风控。查询兜底的作用是防止漏单,不是替代异步通知。
5.2 退款接口的适用场景
退款是支付类项目早晚会碰到的需求。支付宝的退款接口是alipay.trade.refund,需要注意的是它支持部分退款,同一个订单可以多次退款,只要总的退款金额不超过订单金额就行。
public AlipayTradeRefundResponse refund(String outTradeNo, String refundAmount, String outRequestNo) throws AlipayApiException { AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); JSONObject bizContent = new JSONObject(); bizContent.put("out_trade_no", outTradeNo); bizContent.put("refund_amount", refundAmount); bizContent.put("out_request_no", outRequestNo); request.setBizContent(bizContent.toJSONString()); return alipayClient.execute(request); }out_request_no是退款请求号,每次退款必须保证唯一,它用于防止重复退款。你在生成这个号时,可以用订单号 + 退款批次号拼接,保证同一订单多次退款时各批次号不同。
退款成功返回后,支付宝同样会发送异步通知,通知的trade_status或refund_status字段会标识退款结果。如果是自动退款到账,一般以接口返回结果为准;如果是资金延迟清算,就要以异步通知为准。最简单的做法是退款时直接把本地订单状态改成“退款中”,收到退款成功通知后再改成“已退款”。
6. 沙箱实测中的高频坑与上线切换清单
6.1 我踩过的那些坑,列举出来帮你提前避雷
对接支付宝的时间久了,每个坑都对应过一段调试日志。下面挑几个出现频率最高的,按影响程度排序:
金额单位不一致。数据库里存的是分,接口传参的是元。如果直接把 1000 分当 10 元传给支付宝,那支付的金额就直接翻了一百倍。我建议在业务代码里统一用BigDecimal,并且用字符串构造器来避免精度问题:new BigDecimal("10.00")。不要直接用double乘除法,否则会出现 0.1 + 0.2 不等于 0.3 这种经典精度问题。
私钥文件里的换行符。从密钥工具导出私钥时,文件里是带换行的-----BEGIN PRIVATE KEY-----和-----END PRIVATE KEY-----。如果你把整个文件内容直接粘贴到application.yml,YAML 解析时会把它拆成多行,导致签名报错。解决办法是在代码里读取文件内容时保留换行,或者在配置中手动去掉换行。更稳妥的做法是不要把私钥放配置文件,而是利用环境变量或密钥管理服务注入。
验签时用错公钥。验签必须用支付宝公钥,不是应用私钥、不是应用公钥。如果发现验签一直失败,先检查密钥配置是不是反了。我见过不少同事改了半天代码,最后发现是配置里把两个公钥位置放反了。
回调地址不稳定。notifyUrl必须是一个公网可访问的地址,且不能带参数或 IP 直连。如果你本地联调没有公网地址,可以用内网穿透工具,把本地 8080 端口映射到公网域名上。但要注意:有些穿透工具分配的域名会变,导致支付宝通知第一次成功、第二次就超时。
回调里拿不到某些参数。异步通知的参数是以 POST 表单形式传输的,如果你在 SpringBoot 中用了防盗链的过滤器或者统一参数包装,可能导致参数被提前读取,导致getParameterMap()为空。排查这类问题时,建议先在 Controller 入口处打印所有参数,再一层层查过滤链。
日志打印导致死循环。有人会把异步通知请求体和响应直接打到业务库里,结果收到通知后更新状态,又产生一条新的日志记录,通知重试时再打一条……看起来像异步通知风暴。其实只是日志逻辑写得太重,建议把通知日志写到独立的日志文件即可,不要与其他业务操作绑定。
6.2 沙箱切换到正式环境的完整清单
当沙箱环境验证通过后,切到正式环境并没有想象中那么难,但要核对的事项很多。我总结过一份切换清单,每次上线支付功能前都会逐项过一遍:
| 检查项 | 沙箱中的值 | 正式环境的操作 |
|---|---|---|
| 网关地址 | openapi.alipaydev.com/gateway.do | 改为openapi.alipay.com/gateway.do |
| APPID | 沙箱应用 ID | 替换为正式应用 ID |
| 应用私钥 | 沙箱私钥 | 生成新的正式私钥并妥善保存 |
| 支付宝公钥 | 沙箱公钥 | 从正式应用后台取出并更新 |
| 产品签约 | 沙箱默认支持 | 正式应用必须签约“电脑网站支付”产品 |
| 回调地址 | 公网测试域名 | 改为正式服务器域名,并配置 HTTPS |
| 测试买家 | 沙箱买家账号 | 无需处理,使用真实买家支付 |
上线前还建议用一笔极小的金额(比如 0.01 元)跑一遍真实支付,验证支付和回调链路都通。之后再做退款测试,确认买家能收到退款通知。全链路真实跑通一次,比看一百遍文档都管用。
最后再分享一个我在多轮对接后形成的小习惯:支付接口的入参、出参、回调参数统一打好结构化日志。沙箱调试时把这些日志打到独立文件,上线后通过日志关键字TRADE_SUCCESS、notify、signVerified等快速定位问题。支付链路涉及钱,出问题时要做到“一眼定位”,这比任何代码技巧都重要。