ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

微信小程序支付后台Java实现:统一下单与回调验签全流程解析

2026/9/25 23:12:40 拓冰建站 浏览量
微信小程序支付后台Java实现:统一下单与回调验签全流程解析 简介面向微信小程序开发者的Java后台支付实现实例围绕微信支付完整闭环展开涵盖OpenId获取、订单号生成与管理、统一下单接口签名调用、XML响应解析、二次签名、前端调起支付及notify_url回调处理等关键环节。资源以PDF文档形式提供共1个文件压缩包整体约71KB方便直接查看阅读。目前已有2340人学习下载适用于具备Java基础并希望快速上手小程序支付接入的开发者。文档结合LeanCloud云引擎服务端代码示例梳理了从获取用户openid到后台预下单、再到返回参数调起微信支付的业务流程并给出appid、mch_id、notify_url等环境变量的配置建议同时涉及订单状态验证、异常捕获等注意事项。读者可借助这份实例理解支付签名机制、理解XML数据解析与二次签名细节减少对接过程中的常见坑点提升支付模块的开发与排错效率。1. 微信小程序支付后台Java实现不是调一个接口就完事把一个微信小程序项目的支付功能做通后端要干的活比前端多得多。前端只是拿到后台返回的 payParams 然后调一次wx.requestPayment真正决定支付能不能走下去的是后台的统一下单、回调验签、订单落库这三段逻辑。这套流程是 Java 后端接微信小程序支付最常见也最稳妥的实现路径先换 openid再下单拿 prepay_id前端调起收银台微信异步回调通知后台后台验签解密后更新订单。反直觉的是真正容易翻车的并不是调起支付而是回调验签和金额精度这两块。这篇文章会把这套实例拆到代码级新手可以照着搭熟手可以重点看第 5 章的坑。2. 支付后台的四个基础件openid、商户证书、APIv3 密钥和前端分工2.1 小程序端只做两件事剩下都在后台小程序端在支付环节的代码其实非常薄先wx.login拿到临时 code发给后台后台拿 code 换 openid再用 openid 去下订单最后把后台返回的支付参数原样传给wx.requestPayment。// 小程序端wx.login 获取临时 code wx.login({ success: (res) { // 把 res.code 发给后台 /api/wx/openid/{code} wx.request({ url: https://your.domain.com/api/wx/openid/ res.code, success: (resp) { const openid resp.data.openid; // 用 openid 去后台下单 wx.request({ url: https://your.domain.com/api/wxpay/order, method: POST, data: { openid: openid, orderNo: ORDER20250716001, totalFee: 100 }, success: (orderResp) { const payParams orderResp.data; wx.requestPayment({ appId: payParams.appId, timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign, success: () { /* 用户支付完成等后台回调更新订单 */ }, fail: (err) { console.log(支付调起失败, err); } }); } }); } }); } });注意wx.login的 code 一次有效且有效期只有 5 分钟openid必须由后台通过 jscode2session 接口换取不能信任前端随便传一个字符串。如果你是用 uniapp 开发微信小程序uni.requestPayment接收的参数结构与wx.requestPayment完全一致后端不需要做任何区分。2.2 用 Java 后端实现微信小程序登录code 换 openid支付的前提是拿到当前用户的 openid。这一步用的是微信官方接口jscode2session一个 GET 请求就能拿到 openid 和 session_key。RestController RequestMapping(/api/wx) public class WxLoginController { Value(${wx.appid}) private String appid; Value(${wx.secret}) private String secret; private final RestTemplate restTemplate new RestTemplate(); GetMapping(/openid/{code}) public MapString, String getOpenid(PathVariable String code) { String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code code grant_typeauthorization_code; Map?, ? resp restTemplate.getForObject(url, Map.class); MapString, String result new HashMap(); if (resp ! null resp.get(openid) ! null) { result.put(openid, (String) resp.get(openid)); // session_key 只在后端使用不要返回给小程序端 } else { throw new RuntimeException(code 无效或已过期: resp); } return result; } }这里的appid和secret从小程序后台获取放在配置文件中。grant_type固定为authorization_code。一个常见误用是有人把secret写死在 JS 代码里这是绝对禁止的secret一旦泄露别人就可以伪造 session。另外session_key不要返回给前端它只在后端解密手机号等场景使用。code换openid这一套逻辑和你做 Java 后端实现微信小程序登录时复用同一段代码不用单独维护一套。2.3 商户证书与 APIv3 密钥四份文件要分清楚微信支付 v3 的签名体系里文件容易搞混。实际项目里你会接触这几样东西apiclient_cert.pem商户 API 证书包含公钥下单时不需要上传但退款时的双向证书要用它。apiclient_key.pem商户私钥所有请求签名都靠它必须放在后端服务里不能出服务器。证书序列号从apiclient_cert.pem中读取请求头serial_no填的是它不是商户号。APIv3 密钥在微信支付商户平台自己设置的 32 字节字符串用于回调内容解密。除此之外还有一份微信支付平台证书专门用来验回调签名。平台证书和商户证书是两回事前者是微信的证书验微信发来的通知后者是你的证书签名你发出去的请求。# 查看商户 API 证书序列号 openssl x509 -in apiclient_cert.pem -noout -serial # 输出示例: serial5A3B7C9D1E2F3A4B5C6D7E8F9A0B1C2D3E4FJava 后端需要把商户私钥加载成PrivateKey对象常见做法是写在配置类里服务启动时加载一次Component public class WechatPayConfig { Value(${wxpay.mch-id}) private String mchId; Value(${wxpay.app-id}) private String appId; Value(${wxpay.api-v3-key}) private String apiV3Key; Value(${wxpay.private-key-path}) private String privateKeyPath; Value(${wxpay.mch-serial-no}) private String mchSerialNo; private PrivateKey privateKey; PostConstruct public void init() throws Exception { try (InputStream in new FileInputStream(privateKeyPath)) { // PKCS8 格式的私钥openssl 生成后直接可用 byte[] keyBytes in.readAllBytes(); PKCS8EncodedKeySpec spec new PKCS8EncodedKeySpec(keyBytes); privateKey KeyFactory.getInstance(RSA).generatePrivate(spec); } } public PrivateKey getPrivateKey() { return privateKey; } public String getMchSerialNo() { return mchSerialNo; } public String getApiV3Key() { return apiV3Key; } public String getMchId() { return mchId; } public String getAppId() { return appId; } }mch-serial-no就是刚才用 openssl 查出来的序列号。把私钥和序列号放进配置类后后面所有签名和下单操作都从这个类取参数避免每个 Service 里重复读文件。3. 用 Java 实现微信小程序统一下单从订单到 prepay_id 再到 payParams3.1 JSAPI 下单的请求结构和签名规则微信小程序支付走的是 JSAPI 下单接口地址是POST https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi。请求体里最关键的是appid、mchid、description、out_trade_no、notify_url、amount.total和payer.openid。v3 的请求头要求带 Authorization格式是WECHATPAY2-SHA256-RSA2048后面跟五个键值对mchid、nonce_str、signature、timestamp、serial_no。其中 signature 是对一个固定格式的字符串做 SHA256withRSA 签名HTTP请求方法\n URL路径\n 请求时间戳\n 请求随机串\n 请求报文摘要\n注意 URL 只到路径部分不带域名如果请求带了 query 参数签名串里的 URL 必须包含完整的 query。请求报文摘要是对请求体 JSON 做 SHA256 后转十六进制字符串。下面这段代码把签名头封装成一个方法public class WechatPaySignUtil { public static String buildAuthHeader(String method, String url, String bodyJson, PrivateKey privateKey, String mchId, String serialNo, String timestamp, String nonceStr) throws Exception { // 1. 对请求体做 SHA256得到报文摘要 MessageDigest digest MessageDigest.getInstance(SHA-256); byte[] hash digest.digest(bodyJson.getBytes(StandardCharsets.UTF_8)); String bodyHash HexUtil.toHexString(hash); // 转十六进制字符串 // 2. 拼接签名串注意每段以 \n 结尾 String message method \n url \n timestamp \n nonceStr \n bodyHash \n; // 3. 用商户私钥做 SHA256withRSA 签名 Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(privateKey); signature.update(message.getBytes(StandardCharsets.UTF_8)); String sign Base64.getEncoder().encodeToString(signature.sign()); // 4. 拼 Authorization 头 return WECHATPAY2-SHA256-RSA2048 mchid\ mchId \, nonce_str\ nonceStr \, signature\ sign \, timestamp\ timestamp \, serial_no\ serialNo \; } }这里的HexUtil就是简单的bytes - hex工具网上能搜到一堆实现也可以自己写几行。签名串最后一个字段后面也要有\n漏掉这个换行符是新手最容易出错的点。3.2 统一下单与二次签名的 Java 实现拿到 prepay_id 只是第一步前端真正要的是二次签名后的 payParams。所谓二次签名是用商户私钥对appId、timeStamp、nonceStr、package四个值按顺序拼成的字符串做签名这个签名串和请求微信接口的签名串不是同一个东西。Service public class WechatPayService { Autowired private WechatPayConfig config; Autowired private RestTemplate restTemplate; Value(${wxpay.notify-url}) private String notifyUrl; public MapString, String createJsapiOrder(String openid, String outTradeNo, Integer totalFee, String description) throws Exception { // 1. 组装统一下单请求体 MapString, Object body new LinkedHashMap(); body.put(appid, config.getAppId()); body.put(mchid, config.getMchId()); body.put(description, description); body.put(out_trade_no, outTradeNo); body.put(notify_url, notifyUrl); MapString, Object amount new LinkedHashMap(); amount.put(total, totalFee); // 单位是分必须传整数 amount.put(currency, CNY); body.put(amount, amount); MapString, String payer new LinkedHashMap(); payer.put(openid, openid); body.put(payer, payer); String bodyJson new ObjectMapper().writeValueAsString(body); String url /v3/pay/transactions/jsapi; String timestamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); // 2. 构造请求头并调用微信接口 HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set(Authorization, WechatPaySignUtil.buildAuthHeader( POST, url, bodyJson, config.getPrivateKey(), config.getMchId(), config.getMchSerialNo(), timestamp, nonceStr)); HttpEntityString entity new HttpEntity(bodyJson, headers); ResponseEntityMap resp restTemplate.postForEntity( https://api.mch.weixin.qq.com url, entity, Map.class); if (resp.getStatusCode() ! HttpStatus.OK) { throw new RuntimeException(统一下单失败: resp.getBody()); } String prepayId (String) resp.getBody().get(prepay_id); // 3. 二次签名返回给小程序端 String payTimestamp String.valueOf(System.currentTimeMillis() / 1000); String payNonceStr UUID.randomUUID().toString().replace(-, ); String packageStr prepay_id prepayId; // 二次签名的签名串appId \n timeStamp \n nonceStr \n package \n String signMessage config.getAppId() \n payTimestamp \n payNonceStr \n packageStr \n; Signature signature Signature.getInstance(SHA256withRSA); signature.initSign(config.getPrivateKey()); signature.update(signMessage.getBytes(StandardCharsets.UTF_8)); String paySign Base64.getEncoder().encodeToString(signature.sign()); // 4. 按固定顺序返回参数 MapString, String result new LinkedHashMap(); result.put(appId, config.getAppId()); result.put(timeStamp, payTimestamp); result.put(nonceStr, payNonceStr); result.put(package, packageStr); result.put(signType, RSA); result.put(paySign, paySign); return result; } }几点参数说明totalFee必须是从数据库订单表里读出来的“分”后端在下单接口里不能信任前端传的金额下单前要按outTradeNo查自己的订单表重新取金额再扣减库存out_trade_no由业务系统生成最长 32 个字符必须保证唯一description是商品描述会展示在微信支付凭证上。3.3 返回给前端的 payParams用 uniapp 和原生小程序都一样后端这一步返回的 payParams字段名和文档保持一致即可前端不用做二次加工参数名类型说明appIdString小程序 AppIDtimeStampString秒级时间戳注意是字符串nonceStrString随机字符串不长于 32 位packageStringprepay_idxxx必须带前缀signTypeString固定RSAv3 不支持 MD5paySignString二次签名结果timeStamp要返回字符串而不是 long虽然 JS 也能处理但微信文档要求的是 string 类型。package必须包含prepay_id前缀二次签名时参与签名的也是带前缀的完整字符串。如果你是用 uniapp 开发微信小程序uni.requestPayment的入参直接吃这份 map字段名不需要转换。4. 支付结果回调验签、解密、更新订单4.1 回调接口的接收与响应格式用户付完钱微信服务器会往你配置的notify_url发一个 POST 请求。回调地址要在微信支付商户平台的“开发配置”里配好必须是公网能访问的 HTTPS 地址但域名不要求一定在小程序后台配置只要能被外网访问到即可。回调接口的写法很简单但响应格式非常关键RestController RequestMapping(/api/wxpay) public class WechatPayNotifyController { PostMapping(/notify) public MapString, String notify(RequestBody String body, RequestHeader(Wechatpay-Serial) String serialNo, RequestHeader(Wechatpay-Timestamp) String timestamp, RequestHeader(Wechatpay-Nonce) String nonce, RequestHeader(Wechatpay-Signature) String signature) { // 这里先验签验签通过后再解密更新订单 boolean verified verifySignature(serialNo, timestamp, nonce, signature, body); if (!verified) { return Map.of(code, FAIL, message, 验签失败); } // 验签通过后解析并解密 resource 中的内容更新订单 processNotify(body); // 必须返回 codeSUCCESS 且 HTTP 状态码为 200 return Map.of(code, SUCCESS, message, 成功); } }微信回调要求响应体的结构是{code:SUCCESS,message:成功}同时 HTTP 状态码必须是 200。如果返回了其他状态码或者 code 不是 SUCCESS微信会认为通知失败然后在接下来的 24 小时内按一定间隔重试重试次数最多 6 次。这就是为什么有些项目明明收到了回调、订单却没更新——因为响应体格式不对微信一直在重试你的业务逻辑其实压根没跑到后面。4.2 用平台证书验证微信支付签名回调请求头里带着四个字段Wechatpay-Serial是微信平台证书的序列号Wechatpay-Timestamp是时间戳Wechatpay-Nonce是随机串Wechatpay-Signature是签名。验签用的公钥来自微信支付平台证书不是你的商户证书这两者不能混。private boolean verifySignature(String serialNo, String timestamp, String nonce, String signature, String body) { try { // 从本地加载微信支付平台证书serialNo 对应证书序列号 PublicKey publicKey platformCertMap.get(serialNo); if (publicKey null) { return false; } // 签名串格式timestamp \n nonce \n body \n String message timestamp \n nonce \n body \n; Signature sign Signature.getInstance(SHA256withRSA); sign.initVerify(publicKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); return sign.verify(Base64.getDecoder().decode(signature)); } catch (Exception e) { log.error(回调验签失败, e); return false; } }platformCertMap是启动时预加载的“平台证书序列号 - 公钥”映射。微信的证书会定期轮换所以在验签时如果遇到serialNo找不到建议先返回 FAIL然后触发一次证书自动更新流程重新从微信拉取最新平台证书后再处理。不过简单项目也可以手动下载最新证书替换前提是你要定期关注失效时间。4.3 解密 resource 并落库验签通过后请求体里的resource字段是加密的需要先用 APIv3 密钥做 AES-GCM 解密。微信使用的加密算法是AEAD_AES_256_GCM其中密钥是 APIv3 密钥nonce 和 associated_data 直接取自resource字段。public String decryptResource(String associatedData, String nonce, String ciphertext) throws Exception { byte[] cipherBytes Base64.getDecoder().decode(ciphertext); // ciphertext 的最后 16 字节是认证标签 byte[] tag Arrays.copyOfRange(cipherBytes, cipherBytes.length - 16, cipherBytes.length); byte[] data Arrays.copyOfRange(cipherBytes, 0, cipherBytes.length - 16); SecretKeySpec keySpec new SecretKeySpec(config.getApiV3Key().getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec gcmSpec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec); cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); return new String(cipher.doFinal(data), StandardCharsets.UTF_8); }解密后拿到的 JSON 里包含out_trade_no、transaction_id、trade_state、amount.total、payer.openid等关键字段。落库时注意判断trade_state是否为SUCCESS只对支付成功的订单做状态更新同时在更新前按out_trade_no查一下订单当前状态如果已经是已支付或已发货要直接返回 SUCCESS保证回调处理的幂等性。注意回调处理耗时不要超过 30 秒。解密、更新订单、记流水这些操作建议串行执行超时会让微信判定通知失败并重试重试次数多了容易把业务逻辑重复执行。5. 支付后台避坑Java 实现这份方案常踩的 5 个坑5.1 报“签名错误”serial_no 到底填商户号还是证书序列号现象请求统一下单接口时微信返回签名错误错误码是SIGN_ERROR。原因Authorization 头里的serial_no填成了商户号。实际这个字段必须是商户 API 证书的序列号也就是用openssl x509 -in apiclient_cert.pem -noout -serial查出来的那串十六进制。商户号是纯数字的mchid序列号是一长串带字母的值两者长得很不一样。解决把配置里的mchSerialNo换成证书序列号。如果你用的是微信提供的 API 证书文件下载包里面有个证书序列号.txt直接复制里面的内容。还有一个常见连带问题有些人换成了apiclient_key.pem对应的序列号其实序列号属于证书不是私钥文件私钥文件里本身不包含序列号信息。5.2 wx.requestPayment 一直返回 fail页面弹不出输入密码的框现象前端调wx.requestPayment返回 fail错误信息是invalid signature或者直接报requestPayment:fail。原因二次签名串和你返回给前端的参数不一致。最多的情况是拼签名串时把package写成了prepay_idxxx之外的格式或者漏掉了最后一个\n。二次签名串的格式是appId \n timeStamp \n nonceStr \n package \npackage 必须是prepay_idxxx完整串不能只传xxx。timeStamp必须是秒级字符串不能是毫秒级的 long如果前端拿到的是数字也是对的但类型别传错。解决把后端拼签名串的代码拉出来和返回给前端的payParams逐项对照。最笨但最有效的方法是先在后端打印签名串原文再用一个能独立验签的工具把签名结果验一遍。如果你在代码里加了换行符但用了 Windows 的\r\n也会导致验签失败注意统一用\n。5.3 回调明明收到了订单状态却一直不更新现象后台日志能看到微信发来的回调请求但数据库订单状态始终是未支付或者微信那边显示一直在重试几分钟后又来一次。原因回调接口返回的响应体格式不对或者干脆没有返回固定格式 JSON。微信判断通知成功的唯一标准是 HTTP 200 且 body 里的code为SUCCESS。有人直接返回了success字符串有人返回 HTTP 500这些东西都会让微信按“通知失败”处理回到自己的队列里继续重试。解决回调 Controller 方法加ResponseBody返回Map.of(code, SUCCESS, message, 成功)并且保证方法不抛异常。如果你用了 Spring 的RestController默认返回 JSON 是没问题的但如果你在方法里做了订单更新操作一旦抛异常Spring 默认返回 500微信就会无限重试重试期间你的代码还会再次进入回调方法容易造成重复扣减库存或重复发券。建议把订单更新逻辑包在 try-catch 里业务成功再返回 SUCCESS业务失败也返回 FAIL 并记录日志。5.4 金额单位搞混10.00 元变成 100 还是 10000现象用户支付了 10 元后台收到回调后解析出的amount.total是 1000或者用户实际扣款 0.1 元。原因微信支付所有金额单位都是“分”且是整数。JS 端、Java 后端、数据库存金额的方式如果不统一很容易在某个环节乘以 100 或除以 100 出问题。最常见的错误代码是用Double相乘(Double) 10.00 * 100结果是1000.0再转 int 时可能精度丢失。解决Java 后端统一用BigDecimal或直接存整数的分。前端传金额给后端时规定只传字符串形式的元或直接传分后端以下单时查询数据库订单金额为准不信任前端传值。回调解密后拿到的amount.total直接和你订单表里的分做比对不一致就拒绝落库并告警。另外防止有人拿着回调数据伪造订单落库时要校验out_trade_no是当前用户且金额一致这一步能做掉绝大部分支付数据篡改风险。5.5 退款报“证书序列号不存在”提现和对账也奇怪现象统一下单都正常但调用退款接口时一直报证书错误或者提示请使用商户证书。原因v3 接口里统一下单、查单只需要用商户私钥做请求签名退款接口要求的是双向 TLS也就是必须用apiclient_cert.pem和apiclient_key.pem作为客户端证书发起请求。很多人用 HttpClient 时只设置了签名头没有配置 SSL 上下文导致退款请求过不去。解决退款时用 Apache HttpClient 加载商户证书设置SSLConnectionSocketFactory示例思路是这样SSLContext sslContext SSLContexts.custom() .loadKeyMaterial(keyStore, password.toCharArray()) // keystore 由 apiclient_cert.pem 和 apiclient_key.pem 生成 .build(); CloseableHttpClient client HttpClients.custom() .setSSLContext(sslContext) .build();这几个坑每一类都是真实项目里反复出现的高频问题。做支付对接时把上面五条过一遍基本能覆盖掉 80% 的线上事故。这套流程如果你能讲清楚在 Java 面试题里也是很强的实战加分项。6. 验证与进阶把微信小程序支付后台从“能付”做到“敢上线”6.1 用真实回调做本地验证本地开发环境最难验证的是回调因为微信服务器访问不到你的 localhost。常见做法是下载一个内网穿透工具把本机 8080 端口暴露到公网然后把穿透生成的 HTTPS 地址配置成商户平台的回调地址。启动服务后先不要急着开验签把回调接口改成只打印请求头和 body然后在微信小程序里真实支付一分钱观察打印出来的数据结构。确认结构符合文档后再打开验签逻辑用同一个回调数据重新触发一次。也可以模拟回调请求用 curl 打向本机接口curl -X POST http://localhost:8080/api/wxpay/notify \ -H Content-Type: application/json \ -H Wechatpay-Serial: xxx \ -H Wechatpay-Timestamp: 1721100000 \ -H Wechatpay-Nonce: abc123 \ -H Wechatpay-Signature: base64sig \ -d {resource:{ciphertext:...,nonce:...,associated_data:...}}第一次验证时签名验证过不了很正常把验签关掉只跑解密和落库流程能更快暴露业务逻辑本身的问题。我自己的习惯是先关验签跑通全链路再开验签两边分开验证省掉的排查时间远比多花的几分钟多。6.2 查单接口是回调丢失的后悔药回调有重试机制但即使重试也可能因为中间网络问题彻底丢失。支付后台要加一个查单兜底定时任务扫描订单表里状态为“已下单未支付”且超过 5 分钟的订单调用查单接口确认最终状态。// GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchidxxxx String url /v3/pay/transactions/out-trade-no/ outTradeNo ?mchid mchId;注意这个请求带 query 参数签名串里的 URL 必须包含?mchidxxx完整部分。查到trade_stateSUCCESS就直接更新订单并做库存发货不需要等回调。对账任务每天再拉一次账单文件做最终校准这样支付后台才算闭环。希望这套从换 openid、统一下单、回调处理到避坑的实例能帮你把微信小程序支付后台顺利跑起来。真到了线上遇到奇怪问题记得先看签名再看金额最后看证书九成问题都在这三处。本文还有配套的精品资源点击获取