支付宝当面付实战:Spring Boot集成扫码支付与回调处理
简介支付宝当面付完整代码面向需要集成扫码支付功能的移动端或服务端开发者是一套可直接参考落地的Java示例项目。压缩包共92个文件以75个xml配置、9个java源码、2个properties配置为主体辅以mvnw构建脚本、jar依赖和README说明整体仅88KB目录结构清晰便于按功能模块检索学习。内容围绕当面付业务流程展开覆盖前端SDK调用、订单创建、异步通知验签、沙箱环境测试等关键环节也涉及OAuth2.0用户授权、数据加密与异常处理设计能够帮助开发者理解二维码支付背后的技术框架和安全机制。同时示例代码展示了如何通过合理日志排查问题、优化支付流程中的用户体验并兼顾不同设备与系统版本的兼容性。目前已有992人学习下载适合刚接触支付宝开放平台、希望缩短集成周期并减少排错成本的初中级开发者。1. 解压“支付宝当面付完整代码.rar”先看清这个工程在解决什么解压“支付宝当面付完整代码.rar”第一眼看到的是 demo2 这个标准 Spring Boot 工程而不是一堆零散源码。pom.xml、mvnw、HELP.md 都在src/main 和 src/test 分层清晰说明它是一份能直接打开、能构建、能跑通整个支付链路的完整后端工程不是网上那种随手截取的代码片段。当面付这个产品线有一个容易踩的认知差它分为主扫和被扫两条路线用户扫商家的二维码走 alipay.trade.precreate商家用扫码枪扫用户的付款码走 alipay.trade.pay。这套代码的服务端部分正是围绕这两条链路组织起来的。适合谁读手里已有业务系统、需要用 Java 对接支付宝支付接口的服务端开发者尤其是做线下收银、扫码点餐、自助终端这类场景的人。下面按“配置 → 下单 → 回调 → 沙箱”的顺序把决定成败的细节拆开。2. 密钥、依赖与配置demo2 工程里最容易被忽略的三件事2.1 RSA2 密钥三元组先弄清谁签谁验当面付的通信安全建立在 RSA2 签名上密钥体系一共有三个角色应用私钥、应用公钥、支付宝公钥。应用私钥只保存在你自己的服务器上用来给请求参数签名应用公钥上传到支付宝开放平台支付宝公钥从开放平台获取用来验证支付宝回调的签名。很多第一次接入的人会在这里栽跟头拿着“应用公钥”去验支付宝的回调验一万次都是失败的。生成密钥对直接用 OpenSSL 即可openssl genrsa -out app_private_key.pem 2048 openssl rsa -in app_private_key.pem -pubout -out app_public_key.pem第一条命令生成 2048 位的 RSA 私钥文件第二条从私钥推导出对应的公钥文件。把 app_public_key.pem 的内容粘贴到开放平台“应用公钥”输入框保存后平台会返回一串新的支付宝公钥这串公钥才是验签时要用的。注意生成密钥时不要选 1024 位RSA2 签名算法对密钥长度的最低要求是 2048 位位数不够会直接导致下单接口报签名错误。私钥文件权限建议设为 600不要提交到 Git 仓库。2.2 Maven 依赖与工程结构辨识demo2 里能看到 mvnw 和 mvnw.cmd这是 Maven Wrapper作用是固定构建工具版本。不管 CI 机器上装的是 Maven 3.6 还是 3.9用 ./mvnw 构建都会下载指定版本避免本地环境和线上构建结果不一致。引入支付宝官方 SDK 只需要在 pom.xml 加一个依赖dependency groupIdcom.alipay.sdk/groupId artifactIdalipay-sdk-java/artifactId !-- 按 Maven 中央仓库当前最新 release 替换4.x 均支持当面付 -- version4.38.0.ALL/version /dependency这个依赖包把签名、验签、HTTP 请求、响应解析全部封装好了不需要自己再用 HttpClient 手动拼参数。工程里 src/main 下是业务代码src/test 下是测试代码和 Spring Initializr 生成的标准结构一致。需要说明的是支付宝 SDK 的版本更新比较频繁官方会不定期修复签名边界问题升级时重点看 release notes 里和 notify、rsaCheck 相关的变更不要无脑升级。2.3 application.yml 里到底该放哪些项当面付的配置项不多但每一项的来源都要能对上号alipay: app-id: 2021003122000000000 # 开放平台应用 AppID private-key: | -----BEGIN PRIVATE KEY----- MIIEvQIBADANBg... -----END PRIVATE KEY----- alipay-public-key: | -----BEGIN PUBLIC KEY----- MIIBIjANBg... -----END PUBLIC KEY----- gateway: https://openapi.alipay.com/gateway.do notify-url: https://api.example.com/pay/notify配置项来源说明app-id开放平台控制台创建应用后自动生成沙箱环境用沙箱应用的 AppIDprivate-key本地生成应用私钥只在服务端使用用于请求签名alipay-public-key开放平台获取上传应用公钥后平台返回用于验签gateway官方固定正式环境是 openapi.alipay.com沙箱是 openapi.alipaydev.comnotify-url自己配置支付宝异步通知的后端接口地址必须是外网可访问的 URLprivate-key 和 alipay-public-key 用 YAML 的|块标量语法保留换行这样处理 PEM 格式最稳妥。如果写成单行字符串密钥内容里没有换行倒也能用但复制粘贴时容易串入多余空格导致解析失败。3. 从预下单到收银台核心支付链路的代码落地3.1 初始化 AlipayClient所有调用的统一入口SDK 的 DefaultAlipayClient 是线程安全的整个应用只初始化一次不要在每个请求里 new。把它声明成 Spring Bean 是最常见的做法Configuration public class AlipayConfig { Value(${alipay.app-id}) private String appId; Value(${alipay.private-key}) private String privateKey; Value(${alipay.alipay-public-key}) private String alipayPublicKey; Value(${alipay.gateway}) private String gateway; Bean public AlipayClient alipayClient() { return new DefaultAlipayClient( gateway, appId, privateKey, json, UTF-8, alipayPublicKey, RSA2 ); } }构造函数里的七个参数依次是网关地址、AppID、应用私钥、响应格式、字符集、支付宝公钥、签名类型。响应格式固定传json字符集固定UTF-8签名类型用RSA2。这里要特别注意签名类型不是自己想传什么传什么必须和开放平台上应用配置的签名算法一致否则下单接口会返回“签名类型不匹配”。从工程实践看新应用全部使用 RSA2RSA1 已经处于淘汰边缘新做的项目没有必要再兼容。3.2 扫码支付预下单alipay.trade.precreate主扫模式的核心接口是 alipay.trade.precreate服务端调用后支付宝返回一个二维码字符串商家把字符串渲染成二维码用户扫码后完成支付。下面是完整的预下单方法Service public class PaymentService { private final AlipayClient alipayClient; private final String notifyUrl; public PaymentService(AlipayClient alipayClient, Value(${alipay.notify-url}) String notifyUrl) { this.alipayClient alipayClient; this.notifyUrl notifyUrl; } public String preCreate(String outTradeNo, BigDecimal amount, String subject) throws AlipayApiException { AlipayTradePrecreateRequest request new AlipayTradePrecreateRequest(); request.setNotifyUrl(notifyUrl); String bizContent { \out_trade_no\:\ outTradeNo \, \total_amount\:\ amount.setScale(2, RoundingMode.HALF_UP).toPlainString() \, \subject\:\ subject \, \timeout_express\:\2h\ }; request.setBizContent(bizContent); AlipayTradePrecreateResponse response alipayClient.execute(request); if (!response.isSuccess()) { throw new IllegalStateException(预下单失败: response.getSubMsg()); } return response.getQrCode(); } }这段代码里有几个细节值得注意。out_trade_no 是商户订单号必须全局唯一支付宝会用它做幂等键同一个订单号重复调用预下单会返回同一笔交易。total_amount 的单位是“元”不是“分”金额格式化成两位小数后直接转字符串不要用 BigDecimal 的 toString() 输出否则可能出现0.010这种三位小数的串。timeout_express 传2h表示二维码两小时内有效超过时间用户扫码会提示交易关闭。isSuccess() 判断的是业务码 code 是否为 10000网络层面的异常由 AlipayApiException 抛出来调用方需要区分“下单失败”和“网络异常”两种场景做不同的提示。3.3 收银台轮询二维码渲染、订单查询与关闭拿到 qrCode 字符串后后端通常把它返回给前端渲染成二维码图片。支付完成前收银台页面需要不断询问后端订单状态后端再调用 alipay.trade.query 向支付宝确认public String queryTradeStatus(String outTradeNo) throws AlipayApiException { AlipayTradeQueryRequest request new AlipayTradeQueryRequest(); request.setBizContent({\out_trade_no\:\ outTradeNo \}); AlipayTradeQueryResponse response alipayClient.execute(request); return response.getTradeStatus(); }接口返回的 tradeStatus 有三个值值得关注WAIT_BUYER_PAY 表示等待付款TRADE_SUCCESS 表示支付成功TRADE_CLOSED 表示超时关闭或已退款。轮询策略建议间隔 3 秒最长轮询 2 到 3 分钟超过时间后不再轮询改为提示用户稍后通过订单列表确认结果。轮询期间发现订单已支付立即停止并刷新收银台状态。这里有个容易被忽略的优化点轮询不是越频繁越好支付宝端有接口频率限制3 秒一次已经足够覆盖绝大多数收银场景。如果用户在别处已经完成支付查询接口会立刻返回 TRADE_SUCCESS不会产生副作用。3.4 条码支付被扫模式的关键差异条码支付对应 alipay.trade.pay用户打开支付宝付款码商家用扫码枪读取 auth_code后端带着这笔支付凭证直接提交public void pay(String outTradeNo, String authCode, BigDecimal amount, String subject) throws AlipayApiException { AlipayTradePayRequest request new AlipayTradePayRequest(); request.setBizContent({ \out_trade_no\:\ outTradeNo \, \auth_code\:\ authCode \, \total_amount\:\ amount.setScale(2, RoundingMode.HALF_UP).toPlainString() \, \subject\:\ subject \ }); AlipayTradePayResponse response alipayClient.execute(request); // 10000 表示支付成功其余状态需要结合 result 判断 if (!response.isSuccess()) { throw new IllegalStateException(支付失败: response.getSubMsg()); } }主扫和被扫两种模式在工程上的差异集中在结果获取方式上维度扫码支付主扫条码支付被扫请求接口alipay.trade.precreatealipay.trade.pay支付凭证服务端生成二维码用户付款码 auth_code结果拿取异步通知 轮询同步返回 异步兜底典型场景商家立牌/台牌超市收银、餐饮一体机条码支付虽然同步返回结果但网络抖动时响应可能丢失这笔交易实际已经成功。所以被扫模式也要配置 notify_url同步响应超时后靠异步通知兜底确认最终状态。demo2 工程如果被扫和主扫都做了落库时建议以 trade_no支付宝交易号为业务唯一键而不是只存商户订单号。4. 支付宝回调的验签与幂等别让通知重试打爆你的订单表4.1 回调接口不验签就写库等于裸奔支付宝的异步通知是一个 POST 请求带着支付结果参数打到 notify_url 上。如果不验签任何知道这个地址的人都能伪造一笔成功支付的通知把订单置为已支付然后白嫖商品。验签是回调处理的第一道关口PostMapping(/pay/notify) public String notify(HttpServletRequest request) throws AlipayApiException { MapString, String params new HashMap(); MapString, String[] requestParams request.getParameterMap(); for (String name : requestParams.keySet()) { params.put(name, request.getParameter(name)); } boolean signVerified AlipaySignature.rsaCheckV1( params, alipayPublicKey, UTF-8, RSA2 ); if (!signVerified) { return failure; } // 后续业务处理... }rsaCheckV1 会从 params 里自动取出 sign 和 sign_type然后用支付宝公钥对剩余参数做签名校验。这里需要注意传入的 params 必须包含所有请求参数不要在验签前手动移除 sign 字段以外的参数。验签要放在方法第一道逻辑任何前置校验都不应该放在它前面。4.2 四重校验签名只是一张入场券签名通过只能证明数据来源于支付宝接下来还要校验业务字段是否和下单时一致。完整校验链路有四步验签、校验 app_id、校验 out_trade_no 是否存在、校验 total_amount 是否等于订单金额。下面是完整的处理逻辑PostMapping(/pay/notify) public String notify(HttpServletRequest request) throws AlipayApiException { MapString, String params new HashMap(); MapString, String[] requestParams request.getParameterMap(); for (String name : requestParams.keySet()) { params.put(name, request.getParameter(name)); } if (!AlipaySignature.rsaCheckV1(params, alipayPublicKey, UTF-8, RSA2)) { return failure; } String appId params.get(app_id); String outTradeNo params.get(out_trade_no); String tradeStatus params.get(trade_status); String totalAmount params.get(total_amount); if (!configuredAppId.equals(appId)) { return failure; } if (!TRADE_SUCCESS.equals(tradeStatus) !TRADE_FINISHED.equals(tradeStatus)) { return success; } Order order orderService.getByOutTradeNo(outTradeNo); if (order null || order.isPaid()) { return success; } if (order.getAmount().compareTo(new BigDecimal(totalAmount)) ! 0) { // 金额不一致记录告警返回 failure 让支付宝重新通知 return failure; } orderService.markPaid(outTradeNo, params.get(trade_no)); return success; }trade_status 的取值里TRADE_SUCCESS 和 TRADE_FINISHED 都表示交易成功但语义上有差别TRADE_SUCCESS 之后交易还可以退款TRADE_FINISHED 表示交易已经完成且不可退。对普通商户来说两个状态都按“支付成功”处理业务即可。total_amount 用 BigDecimal 比较而不是字符串 equals避免0.01和0.010这种格式差异导致误判。4.3 幂等处理与支付宝的重试语义回调逻辑必须幂等原因在于支付宝的通知不是只发一次。如果返回的响应不是字符串success或者处理过程中抛了异常支付宝会按递增时间间隔重试通知最长持续若干小时。这意味着同一笔订单的支付成功通知可能收到多次如果不做幂等控制就可能出现重复发货、重复加积分之类的业务事故。幂等落库建议用数据库状态机实现而不是先查再更UPDATE orders SET status PAID, trade_no ?, paid_at NOW() WHERE out_trade_no ? AND status UNPAID这条 SQL 用WHERE status UNPAID做条件更新受影响行数为 1 表示本次是首次支付完成为 0 表示订单已经处理过直接跳过发奖逻辑。相比“先 SELECT 再 UPDATE”这种方式在并发场景下不会出现两个线程都查到 UNPAID 然后重复处理的问题。回调返回success的响应体必须是不带任何 HTML 标签的字符串返回 200 状态码但响应体不是 success支付宝同样会判定处理失败并重试。5. 沙箱联调与上线前的五个高频坑5.1 切沙箱只改一行配置支付宝开放平台提供沙箱环境最省事的是在配置层面做隔离把 gateway 换成https://openapi.alipaydev.com/gateway.doapp_id 换成沙箱应用的 AppID支付宝公钥也换成沙箱应用对应的那串。沙箱环境下有专用的买家账号登录支付宝沙箱版 App 或者配套的模拟器完成“付款”动作整个流程和真实环境完全一致。开发期验证回调时本机无法接收外部请求常见做法是用内网穿透工具把本地 port 映射出一个公网地址把这个地址配成 notify-url。沙箱收到的每笔通知都会展示在开放平台沙箱控制台的“通知记录”里排查收不到回调的问题比正式环境方便得多。5.2 用 JUnit 回放一笔带签名的通知每次在 App 里手动点支付再等回调效率太低了。推荐在 src/test 里写一个回放工具用沙箱的应用私钥手动签名拼出和支付宝完全一致的请求报文Test void replayNotify() throws Exception { String content app_id2021003122000000000 out_trade_noDEMO20250101001 trade_statusTRADE_SUCCESS total_amount0.01 trade_no2025010122001000000000000000; String sign AlipaySignature.rsaSign( content, appPrivateKey, UTF-8, RSA2); String notifyUrl content sign URLEncoder.encode(sign, UTF-8); // 用 RestTemplate 或 MockMvc 把 notifyUrl 作为 POST body 发到 /pay/notify // 断言返回体为 success订单状态变为 PAID }rsaSign 方法对 content 做签名返回的 sign 需要 URL 编码后拼到报文末尾。把拼好的完整报文用 Postman 或 curl 发给本机接口就能反复测试验签、幂等、金额校验这些逻辑不用每次都在沙箱 App 里重新下一单。这套回放机制建议固化在工程里回归测试时一键执行。5.3 上线前逐条检查的五个坑检查项错误表现正确做法公钥配置误用“应用公钥”验签验签用“支付宝公钥”在开放平台应用详情页获取金额精度0.01 元被存成 1 分或 0.010total_amount 单位是元入库前统一用 BigDecimal.setScale(2)回调异常处理业务代码抛异常网关兜底返回 500notify 方法内部捕获全部异常业务处理失败返回 failure私钥泄露密钥上传到了 Git 仓库或交给前端私钥只存在于服务端.gitignore 排除 pem 和 yml 密钥项通知超时处理超时支付宝重试导致重复操作回调里只做状态更新和发奖耗时操作丢进 MQ 异步处理沙箱环境与正式环境的差异要单独确认清楚沙箱不保证通知的到达时序有时支付成功后立即查询还是 WAIT_BUYER_PAY沙箱的扫码页面和真实 App 不完全一致用模拟器测出来通过的二维码渲染逻辑上线前务必用真机扫码走一遍。本文还有配套的精品资源点击获取