☰
微信小程序支付Java实现:从下单到回调的完整闭环
2026/10/8 2:31:47 网站建设 项目流程

简介:这份 PDF 文档是微信小程序支付后台的 Java 实现实例,面向需要在小程序项目中快速接入微信支付的后端开发者,也适合刚接触支付对接、希望了解完整调用链路的初中级 Java 工程师。内容以 LeanCloud 云引擎为运行环境,围绕支付全流程展开:从小程序前端登录授权获取 OpenId 开始,讲解唯一订单号生成、TreeMap 参数排序与统一下单接口签名、POST 请求发送,到微信返回 XML 数据的解析、预支付会话标识 prepay_id 提取与二次签名,再到前端 wx.requestPayment 调起支付,完整覆盖了前后端衔接的关键环节。文档同时说明了 appid、mch_id、notify_url、trade_type 等敏感参数通过 System.getenv() 环境变量注入的实践,以及 AVException、UnsupportedEncodingException、DocumentException 等异常处理和 notify_url 回调、支付结果查询验证等注意事项。资源包为单个 PDF 文件,大小仅 71KB,内容紧凑适合快速查阅。该资源已有 2000 余人学习,对排查支付签名失败、XML 解析异常等典型问题具有直接参考价值。

1. 微信小程序支付后台的Java实现,卡点从来不在“下单”

做过微信小程序支付后台Java实现的人应该都有同感:下单接口十分钟能调通,支付回调能磨你一个下午。我接过一个小程序商城的后端,客户反馈“支付成功但订单不更新”,日志里连回调记录都没有,最后发现是回调地址的 HTTPS 证书链不完整,微信服务器请求直接被握手阶段拦掉。这类问题看不到堆栈,排查全靠对协议的理解。

这篇文章要讲清楚的是微信小程序里的 JSAPI 支付,后台用 Java 怎么做完整闭环:统一下单、小程序端拉起支付、回调验签与解密、订单状态更新、查单退款与对账。适合自己接支付功能的独立开发者,也适合团队里第一次写支付模块的 Java 工程师。我不讲概念级的东西,直接按能落地的顺序来。

2. 支付后台的技术链路:JSAPI支付与APIv3接口的边界

2.1 谁发起、谁签名、谁回调:先把链路摆正

微信小程序支付由三个角色协作:小程序前端、你自己的 Java 后台、微信支付服务器。前端负责 wx.login 拿 code,后台拿 code 换 openid,再用 openid 调统一下单接口拿到 prepay_id,把支付参数返回给前端,前端调用 wx.requestPayment 拉起收银台。用户输密码完成支付后,微信服务器异步 POST 一笔通知到你的回调接口,后台验签、解密、更新订单状态。

很多入门的同学会把“后台下单”和“前端拉起支付”搞混。下单是后台的事,拉起支付是前端的事,前端拿到的不是金额和商品,而是一组签名参数。真正改订单状态的地方不是前端 wx.requestPayment 的 success 回调,而是微信服务器打到你后台的 notify 接口。前端成功只代表用户完成了支付动作,业务是否成立要以回调为准。

所以后台 Java 的职责可以拆成五块:登录换 openid、统一下单、接收支付回调、查单兜底、退款与对账。这五块里前三块是必须的,查单和退款的实现在我后面章节里会给出代码。搞清楚这个边界,再看微信支付接口就不会晕。

2.2 为什么选APIv3而不是老版v2接口

微信支付老接口 v2 用的是 XML 报文加 MD5/HMAC-SHA256 签名,配置项多、证书逻辑绕,而且很多 v2 接口已经不支持新商户入驻。新接口 APIv3 是 JSON 格式,用 RSA 非对称签名,配合平台证书做验签与回调解密,官方提供了 Java SDK,开发体验比 v2 好很多。新项目直接走 v3,别给自己挖坑。

APIv3 的签名机制要理解三个材料:商户 API 证书(含商户私钥和证书序列号)、APIv3 密钥(32 字节,用于回调内容 AES-256-GCM 解密)、微信支付平台证书(用于验证微信返回的签名)。请求微信接口时,你要用商户私钥对“请求方法 + 路径 + 时间戳 + 随机串 + 请求体”做 SHA256 然后 RSA 签名,放进 Authorization 头。响应和回调则反过来,用平台证书验证微信的签名。

推荐用官方 Java SDK(wechatpay-java)而不是自己拼签名。自己实现签名串拼接看起来不难,但平台证书轮换会导致验签突然失败,SDK 把这个逻辑封装好了。我在小项目里见过有人把签名逻辑手写到 400 行,最后时间戳校验和证书序列号问题不断,换成 SDK 后代码量少了一半。

2.3 后台要准备的材料清单与配置项

接入前,在微信商户平台要准备好几样东西:商户号 mchid、小程序 appid、商户 API 证书(下载下来是 apiclient_cert.pem 和 apiclient_key.pem)、APIv3 密钥(自己设置的一串 32 位字符串)、回调域名(必须是已备案域名,HTTPS 证书有效)。这些材料缺一不可,商户私钥文件要放在服务器安全目录,环境变量或配置中心管理,不要提交到 Git。

配置项我在代码里习惯用一组前缀wechat.pay统一管理,包括 appid、mchid、apiV3Key、商户私钥路径、商户证书序列号、回调地址。回调地址是后面最容易出问题的点,我一般单独拿出来放配置,因为测试环境和生产环境的回调地址不一样。

3. Spring Boot 接统一下单:从 openid 到 prepay_id 的完整代码

3.1 配置类与 SDK 初始化

第一步是引入依赖和配置。微信支付官方 Java SDK 依赖坐标系是com.wechat.pay:wechatpay-java,具体版本去 Maven 中央仓库看最新的,这里不写死版本。在application.yml里配置商户信息,示例配置如下。

wechat: pay: app-id: wx1234567890abcdef merchant-id: 1620000000 api-v3-key: 32位长度的字符串密钥 merchant-private-key-path: /data/certs/apiclient_key.pem merchant-serial-number: 商户API证书序列号 notify-url: https://api.example.com/api/pay/notify

配置项里最关键的是api-v3-key,它不参与请求签名,只用于解密回调内容。如果这里填错,下单能成功,但回调会一直解密失败。merchant-private-key-path指向 apiclient_key.pem 文件,注意证书文件权限要收紧,Java 进程需要能读但不能让其他用户读。

把配置读入一个 Properties 类,然后初始化微信支付 SDK 的配置对象。官方 SDK 推荐用RSAAutoCertificateConfig,它会自动下载并更新微信支付平台证书,避免平台证书轮换时验签失败。

@Configuration public class WechatPayConfig { @Bean public RSAAutoCertificateConfig wechatPayConfig( WechatPayProperties props) throws Exception { PrivateKey privateKey = PemUtil.loadPrivateKey( new File(props.getMerchantPrivateKeyPath())); return new RSAAutoCertificateConfig.Builder() .merchantId(props.getMerchantId()) .privateKey(privateKey) .merchantSerialNumber(props.getMerchantSerialNumber()) .apiV3Key(props.getApiV3Key()) .build(); } }

PemUtil.loadPrivateKey是官方 SDK 提供的文件加载工具,也可以自己实现 PKCS8 格式私钥读取。RSAAutoCertificateConfig内部会做平台证书的自动更新,这是新老手之间差距最大的地方,老版本方式是手动上传平台证书,轮换时只能半夜起来换证书。自动更新配置建议只在后台进程里初始化一次,不要每次请求时 new。

3.2 统一下单:参数怎么组,返回值怎么用

统一下单的逻辑集中在 Service 层。创建订单前,先确认三件事:out_trade_no 必须是商户内部唯一的订单号,金额单位是分且是整数,openid 必须是当前登录用户在微信侧的标识。创建订单的完整方法如下。

@Service public class WechatPayService { private final RSAAutoCertificateConfig wechatPayConfig; private final WechatPayProperties props; public PrepayWithRequestPaymentResponse createJsapiOrder( String openid, String outTradeNo, long amountFen) { PrepayRequest request = new PrepayRequest(); request.setAppid(props.getAppId()); request.setMchid(props.getMerchantId()); request.setDescription("小程序商品支付"); request.setOutTradeNo(outTradeNo); request.setNotifyUrl(props.getNotifyUrl()); Amount amount = new Amount(); amount.setTotal(amountFen); // 单位是分 amount.setCurrency("CNY"); request.setAmount(amount); Payer payer = new Payer(); payer.setOpenid(openid); request.setPayer(payer); JsapiService service = new JsapiService.Builder() .config(wechatPayConfig) .build(); return service.createOrderWithRequestPayment(request); } }

createOrderWithRequestPayment是 JSAPI 下单的快捷方法,返回的响应里已经包含小程序端拉起收银台要用的全部参数:prepay_id、timeStamp、nonceStr、package、signType、paySign。其中package这个字段在 Java 里是关键字,在 JSON 序列化时会映射为字符串键,前端直接用就行。

下单接口对外提供时,最好把返回参数封装成一个 Map 给前端,字段名保持微信支付要求的原样。我一般会额外返回一个 outTradeNo 给前端做幂等关联,这样前端重复点击时可以对比订单号。注意下单时金额校验要在业务层做,比如订单已超时、商品已下架就不允许发起支付,避免用户对着无效订单付款。

3.3 前端 wx.requestPayment 怎么配合收尾

前端拿到后台返回的支付参数后,调用wx.requestPayment。这步出错率不高,但要注意参数类型,支付宝的经验在微信小程序里不通用,金额和订单号不是前端传给微信的,前端只是把后台的参数原样透传。

wx.requestPayment({ timeStamp: res.timeStamp, nonceStr: res.nonceStr, package: res.package, signType: res.signType, paySign: res.paySign, success: () => { // 这里不要直接把订单标记为已支付 }, fail: (err) => { console.error('支付失败', err); } });

很多新人在success回调里直接调后端把订单改成已支付,这是典型的翻车姿势。此时微信只是确认前端发起了支付,并不代表支付结果一定成功,而且 iOS 和安卓的收银台行为有差异。正确做法是:前端回到订单页后,轮询后台查单接口,由后台根据微信侧结果返回真实支付状态。

获取 openid 这一步要在下单前完成。小程序的 wx.login 拿到 code,后台用 code 调微信的jscode2session接口,返回 openid 和 session_key。获取手机号和获取 openid 是两个不同接口,热词里混在一起问的同学不少,登录获取 openid 走的是 code 换 session,获取手机号必须用户在页面点击授权,两者不要相互依赖。openid 要绑定到当前登录态,后续下单时直接取,不要让前端把它当参数传上来。

4. 支付回调:验签、解密与订单状态机实现

4.1 回调报文结构与签名校验

支付成功后,微信服务器会向 notify_url 发起 POST 请求,Content-Type 是 application/json。回调报文结构里,外层是通知 ID、创建时间、事件类型、摘要,真正重要的是resource字段,它里面的 ciphertext 是加密后的订单数据。微信先用平台私钥签名外层报文,再用 APIv3 密钥加密 resource 内的数据,后台要做两步:验签和解密。

官方 SDK 的NotificationParser把两步封装好了。拿到原始请求体,构造 Notification,parser.parse 内部完成验签和解密,直接返回 Transaction 对象。如果验签通过但解密失败,会抛异常,需要在 catch 里分清原因。

@RestController @RequestMapping("/api/pay") public class PayNotifyController { @PostMapping("/notify") public Map<String, Object> payNotify(@RequestBody String body) { try { NotificationParser parser = new NotificationParser( (RSAAutoCertificateConfig) wechatPayConfig); Notification notification = parser.parse(body); Transaction transaction = (Transaction) notification.getData(); String outTradeNo = transaction.getOutTradeNo(); String tradeState = transaction.getTradeState().name(); long payerTotal = transaction.getAmount().getPayerTotal(); orderService.handlePaidNotification(outTradeNo, tradeState, payerTotal); return successResponse(); } catch (Exception e) { log.error("支付回调处理失败", e); return failResponse(); } } }

回调接口返回给微信服务器的内容有固定格式:成功时返回 HTTP 200,JSON 是{"code":"SUCCESS","message":"成功"}。不要返回业务自己的 JSON 结构。如果后台处理过程中抛异常或返回非 200,微信会按既定策略重试,重试间隔是 15 秒、15 秒、30 秒、3 分钟、10 分钟、20 分钟、30 分钟、30 分钟、30 分钟、60 分钟,共 10 次。

很多人会在这里犯一个错:回调里直接调微信查单接口去确认订单状态。没必要,回调报文的 transaction 里已经有 trade_state,而且经过了验签解密。再查一次接口除了增加耗时,还引入了新的网络错误点。回调处理要快,把耗时操作放到 MQ 或者线程池里异步处理。

4.2 用事务保证订单状态更新不丢不重

回调处理的核心是更新订单,这一步必须幂等。微信的重试机制决定了同一个回调可能被送多次,如果不加判断,订单状态会被反复重写。实现上我用一条带条件更新的 SQL 保持原子性,更新时要求订单当前状态必须是待支付。

UPDATE t_order SET pay_status = 1, transaction_id = #{transactionId}, paid_at = #{paidAt}, updated_at = now() WHERE out_trade_no = #{outTradeNo} AND pay_status = 0

如果影响行数为 1,说明本次更新有效,订单从未支付变成了已支付。如果影响行数为 0,说明订单已经处理过,直接返回成功,不再重复处理。这个设计比先 select 再 update 更安全,并发场景下也不会出现两个线程同时把订单状态改乱的问题。

回调接口要放在事务边界内,但事务里尽量不要查外部接口,微信支付查单、发短信、扣库存建议放到事务提交后的领域事件里。回调里业务处理完立即返回 SUCCESS,微信收到成功后就不会再重试。如果库存扣减失败导致事务回滚,回调返回失败,微信会重试,这比返回成功但业务没落库要安全得多。

4.3 查单兜底与订单状态机

回调不是唯一获取支付结果的途径。用户付完钱后可能手机断网,回调虽然发出去了但客户端没收到,微信重试还在进行,用户着急催发货。这时候后台要提供主动查单接口,前端在支付完成后轮询它。查单接口对应微信支付 APIv3 的查询订单接口,用 out_trade_no 查。

public Transaction queryOrder(String outTradeNo) { QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest(); request.setMchid(props.getMerchantId()); request.setOutTradeNo(outTradeNo); JsapiService service = new JsapiService.Builder() .config(wechatPayConfig) .build(); return service.queryOrderByOutTradeNo(request); }

查单返回的 trade_state 有几种:SUCCESS、REFUND、NOTPAY、CLOSED、REVOKED、USERPAYING、PAYERROR。订单状态机要覆盖这些状态,我习惯定义 6 个状态:待支付、支付中、已支付、已退款、已关闭、支付异常。待支付可以走到已支付、已关闭;已支付可以走到已退款;支付中状态主要应对用户正在输密码、还没来得及回调的窗口期。

前端轮询查单时,要设置合理的间隔,一般 3 秒一次,最多 10 次。支付成功的订单要立刻停止轮询并跳转结果页。用户点击取消支付后,要调用关闭订单接口把未支付的订单关闭,防止超时后还能被拉起支付。

5. 微信小程序支付 Java 实现:5 个高频坑与排查方法

5.1 回调一直收不到,问题多半不在代码

现象:下单成功,前端也能拉起支付,用户付完钱后台日志里却没有任何回调请求。

原因:回调是微信服务器主动发起 HTTP 请求,你的服务器需要能被公网访问。最常见的是回调域名没有备案,或者 HTTPS 证书链不完整。有些开发机在本地用 ngrok 之类的隧道工具做穿透,微信支付会校验域名,临时域名经常被拒。我遇到过一次,nginx 的 SSL 证书只配置了叶子证书,没有带中间证书,浏览器访问正常,但微信服务器的 TLS 客户端库校验更严格,握手直接失败。

解决:确认回调地址满足三个条件,域名已备案、HTTPS 证书有效且证书链完整、回调路径不被防火墙拦截。然后看微信商户平台的“开发配置”里回调地址是否和下单参数一致。检查证书链用openssl s_client -connect api.example.com:443 -servername api.example.com,看返回的证书链是否包含中间证书。

5.2 验签失败:平台证书与公钥模式混用

现象:回调能收到,但每次都在parse阶段抛验签异常,日志里提示签名不匹配或证书序列号找不到。

原因:微信支付 APIv3 支持两种验签方式,平台证书模式和非证书的公钥模式。SDK 初始化用RSAAutoCertificateConfig会自动拉取平台证书,但如果你之前手动下载过平台证书,并在代码里写死了某个序列号,平台证书更新后就会验签失败。另一种是回调方配置的商户证书序列号填成了商户 API 证书的序列号,微信签名时用的是平台证书,两者对不上。

解决:优先用RSAAutoCertificateConfig并删除手写的证书序列号配置。如果坚持公钥模式,需要从微信支付平台证书下载接口拿到最新公钥,并定时刷新。排查时先在日志里打出notification原始报文和验签异常堆栈,对比微信商户平台里显示的证书序列号与代码里用到的序列号是否一致。

5.3 金额“分”转“元”的精度陷阱

现象:测试支付 0.10 元,数据库里记录的是 9 分钱,或者用户支付 19.9 元,后台校验不通过。

原因:微信支付 API 里金额单位是分,整数类型,没有小数。很多同学在前端把元转分时直接price * 100,浮点乘法会丢精度,19.9 乘 100 在二进制浮点里是 1989.9999,强转成 int 就变成了 1989 分。数据库如果设计成 decimal,存进去也会出现精度不一致。

解决:金额从元转分用BigDecimal.valueOf(price).movePointRight(2).intValue(),从分转元用BigDecimal.valueOf(fen).movePointLeft(2).setScale(2)。不要在业务链路里用 double 传金额,前后端、MQ、数据库统一用分或统一用 string 的 decimal。我在订单表里直接存分为 int 类型,展示层再转,简单不容易错。

5.4 重复回调与回调覆盖正在处理的订单

现象:订单状态被覆盖,比如先收到支付成功回调,又收到一笔退款回调,把已支付状态改回了未支付。

原因:回调处理没做幂等,且状态机没有约束条件。微信重试机制下同一个支付结果会多次投递,退款事件也会推送到同一个回调地址,如果回调里只按 out_trade_no 更新字段,不校验当前状态,状态就会被来回改写。

解决:更新 SQL 里必须带当前状态条件,已支付状态不允许被支付成功事件回退到待支付。同时回调处理要记录回调消息 ID,用消息 ID 做去重表,重复投递直接跳过。我习惯在回调处理逻辑最前面查一次订单当前状态,已经终态的订单直接返回 SUCCESS,不进入后续逻辑。

5.5 openid 被换掉:支付人不是下单人

现象:下单时用的 openid 是 A 用户,实际拉起支付时却是 B 用户的微信,后台回调里拿到的 openid 对不上。

原因:后台生成下单参数后,没有把它们和当前登录态绑定,前端拿到支付参数后可能被其他页面或其他用户使用。有些同学图方便把 openid 作为参数传给前端,再由前端传回下单接口,中间被替换后微信侧校验不过,或者校验规则写得太松散直接漏过去。

解决:下单时用out_trade_no关联当前登录用户,微信回调回来时再拿transaction.getPayer().getOpenid()比对订单用户。如果对不上,说明支付账号和下单账号不一致,要记日志并标记订单异常。更严格的做法是下单前重新从 session 取 openid,而不是信任前端传上来的任何用户标识。

6. 用日志与抓包验证整条支付链路:查单、退款与对账

6.1 结构化打印回调参数,5分钟定位问题

支付类问题最怕的是信息黑洞。我要求所有回调入口先打印一行结构化日志,包含 out_trade_no、transaction_id、trade_state、payer_total、回调收到时间。这样排查问题时,不需要翻微信商户平台,直接在日志里就能还原当时的现场。

日志格式我用 JSON 风格,方便日志平台检索。字段固定成pay_notify_received加订单号和状态,出问题时按订单号 grep 一次就能看到完整链路:下单日志、回调日志、查单日志。有些坑是玄学,但大部分支付问题只要现场完整,十分钟能定位。

验证回调参数是否正确的另一个手段是用 Charles 抓包小程序请求。在开发者工具里配上 HTTPS 抓包证书,能看到小程序端调 wx.requestPayment 时透传的 timeStamp、nonceStr、package、paySign,把这些参数和后台下单日志对比,能快速判断是前端透传错参数还是后台签名生成错误。抓包只能看到端上请求,看不到微信服务器到后台的回调,但这已经能覆盖大部分联调问题。

6.2 小额退款自测与账单对账

支付联调通过后,退款是另一个必须测的功能。微信支付退款接口同样走 APIv3,后台业务里需要记录原订单金额、退款金额、退款原因,并且退款也要有自己唯一的退款单号。退款接口通常配置在管理后台,人工操作,不对外暴露。

public Refund createRefund(String outTradeNo, long refundAmountFen, String refundReason) { RefundRequest request = new RefundRequest(); request.setOutTradeNo(outTradeNo); request.setOutRefundNo("R" + outTradeNo + System.currentTimeMillis()); request.setReason(refundReason); AmountReq amount = new AmountReq(); amount.setRefund(refundAmountFen); amount.setTotal(orderService.getOrderAmountFen(outTradeNo)); amount.setCurrency("CNY"); request.setAmount(amount); RefundService service = new RefundService.Builder() .config(wechatPayConfig) .build(); return service.create(request); }

退款成功后微信同样会推送退款结果回调,处理方式和支付回调一致,但要更新订单状态到已退款,并记录退款单号。这里要特别注意:退款不一定立刻成功,微信返回的处理中状态要落到订单表,等回调确认后再改终态。

对账是支付系统上线前不要省的一步。微信支付有专门的账单下载 API,可以下载日账单和商户转账账单,格式是 CSV。我一般每天凌晨跑一个定时任务,下载前一天的账单,和本地订单表做对账,核对金额、订单号、交易状态。对账不平的订单单独列表,人工介入,这是支付后台最后的防线。

我在这个系统上翻过最狠的一次车,是上线第一周对账发现少了一笔订单。查了半天原因,是回调更新订单时只判断了影响行数,没校验支付金额,一笔支付 0.01 元的测试单混进了正式数据,把订单状态改成了已支付。后来所有回调处理都加了金额一致性校验,订单金额和回调金额不一致一律标记异常。支付系统没有银弹,每一层都做校验,把异常晾在阳光下,比依赖微信的重试更可靠。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询