
简介微信JSAPI V3支付、退款与小程序登录的Spring Boot实现资源面向需要对接微信支付、处理退款及实现小程序授权登录的Java开发者解决支付流程集成、接口签名和YML配置繁琐等实际问题。压缩包共55个文件其中52个java源码完整覆盖统一下单、预支付订单prepay_id生成、JSAPI调起支付、支付回调验签、退款申请与状态查询以及小程序登录code换openid、access_token等核心逻辑1个yml配置文件集中管理微信AppID、商户号、API密钥和证书路径修改后即可启用相关功能。另含pom.xml与md说明方便Maven依赖导入和快速阅读代码结构清晰注释完整可帮助规避常见签名与证书配置问题。整包仅42KB轻量紧凑便于对照学习或嵌入现有项目。目前已有2260人学习下载适合具备Spring Boot基础、希望快速掌握微信支付V3与登录授权的后端工程师参考。1. 微信JSAPI V3支付、退款、小程序登录一次讲清微信闭环的顺序与坑微信生态里“网页 H5 里发起支付、产生售后退款、用户从小程序进来先登录”这三个动作经常落在同一个后端项目里。JSAPI V3 指微信支付 APIv3 版本下的 JSAPI 下单能力它面向公众号网页和小程序内支付场景核心特点是采用 RSA 签名、AES-GCM 回调解密和旧版 V2 的 MD5 签名、XML 报文完全不同。很多团队在这套链路里反复踩坑证书不会下载、Authorization 头拼错、回调解密失败、小程序登录的 openid 没和支付关联上。本文按“登录在前、支付居中、退款在后”的依赖顺序展开以 Java 为例讲解接口对接但签名思路与参数设计可平移到 Go、Python、PHP 等任意语言。读完你能独立完成从商户配置到三套接口联调通过的全部落地工作。2. 前置配置与 APIv3 签名规则支付、退款、登录统一走同一套凭证2.1 商户平台与小程序后台的账号配置清单开始写代码前先梳理微信侧需要准备的三类账号信息商户号mchid、小程序 AppID 与 AppSecret、APIv3 密钥和商户 API 证书。这里有一个容易混淆的地方小程序登录用 AppID AppSecret微信支付与退款用 mchid APIv3 密钥 商户私钥两者分别是微信开放平台和微信商户平台两套体系需要在小程序后台完成“关联商户号”操作后openid 才能用于支付下单。以下是配置项的完整清单建议在项目启动前逐项核对配置项获取位置用途安全级别mchid商户平台-账户中心请求路径与请求体参数低小程序 AppID小程序后台-开发管理登录与支付下单低AppSecret小程序后台-开发管理code2Session 换 openid高APIv3 密钥商户平台-API安全AES-GCM 回调解密高商户证书序列号商户平台-API安全Authorization 头低商户私钥下载证书时生成请求签名极高只存后端那在配置层面最常见的坑是什么是证书路径与私钥内容不一致。很多团队把证书上传到服务器后又重新生成了一次证书导致本地私钥和商户平台记录的公钥不匹配请求会一直返回 401。每次在商户平台重置 APIv3 密钥或重新申请证书都要同步更新服务器上的私钥文件和配置文件。2.2 Authorization 签名串的构造规则APIv3 的每个请求头里必须携带Authorization字段格式为WECHATPAY2-SHA256-RSA2048 mchid...,nonce_str...,signature...,timestamp...,serial_no...signature 是对一个待签名串做 SHA256withRSA 的结果待签名串由五部分拼接每部分以换行符分隔HTTP 方法、请求路径、请求时间戳、随机字符串、请求体。注意请求路径不包含域名比如POST /v3/pay/transactions/jsapi里的待签名串就只取/v3/pay/transactions/jsapi请求体为空时也要留一个空行。下面是一段自实现的签名工具代码不依赖微信官方 SDK能直观看到拼接过程public class WechatPayV3Signer { private final PrivateKey merchantPrivateKey; private final String mchId; private final String serialNo; public String buildAuthorization(String method, String urlPath, String body) throws Exception { long timestamp System.currentTimeMillis() / 1000; String nonceStr UUID.randomUUID().toString().replace(-, ).substring(0, 32); String message method \n urlPath \n timestamp \n nonceStr \n (body null ? : body) \n; Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(merchantPrivateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); String signature Base64.getEncoder().encodeToString(sign.sign()); return String.format( WECHATPAY2-SHA256-RSA2048 mchid\%s\,nonce_str\%s\,signature\%s\,timestamp\%d\,serial_no\%s\, mchId, nonceStr, signature, timestamp, serialNo); } }调用时body必须是实际发送的 JSON 字符串。GET 请求传空字符串POST 请求传序列化后的 JSON如果 body 里有中文或转义符必须保证是 UTF-8 编码后按原文拼接。最容易出错的地方是换行符最后一段 body 之后还要有一个\n漏掉这个会导致签名一致对不上。2.3 平台证书的获取与验签策略选择微信支付 APIv3 的双向安全机制里请求用商户私钥签名响应和回调则需要用微信支付平台公钥验签。微信推荐的做法是把平台证书放在本地通过证书中的公钥验证响应签名。而不是每次请求都动态下载证书以免拉取频率过高被限流。这里有一个初学时容易误解的点商户 API 证书和微信支付平台证书是两个完全不同的文件。前者在商户平台下载标识商户身份后者由微信支付 CA 签发用于验签。你在解密回调时用的 APIv3 密钥和这两个证书也没有关系。常见的不带 SDK 落地方案是首次启动时调用GET /v3/certificates拉取平台证书并缓存到本地后续用证书公钥验签证书过期前微信会主动轮换需要监听新证书并更新缓存。验签的关键是判断响应头里的Wechatpay-Signature是否与请求时约定的待签名串一致。如果用官方 SDK这步会被封装手动实现时记得处理平台证书序列号变化的情况否则证书轮换后验签会突然失败。最简单的方式是把证书序列号与证书内容一起存下来验签时先比对序列号不同则重新拉取。3. 小程序登录 Code2Session 与 openid 的安全管理3.1 前端 wx.login 与后端 code2Session 的联动小程序登录的链路并不复杂前端调用wx.login()拿到临时 code传给后端后端拿着 code AppID AppSecret 调用微信接口jscode2session换取 openid、unionid 和 session_key。其中 openid 是后续 JSAPI V3 支付下单时必传参数所以这一节是支付的前置条件。下面是后端处理登录请求的代码重点看参数名与错误处理RestController RequestMapping(/api/wx) public class WxLoginController { Value(${wechat.appid}) private String appid; Value(${wechat.secret}) private String secret; PostMapping(/login) public MapString, Object login(RequestBody MapString, String request) { String code request.get(code); String url https://api.weixin.qq.com/sns/jscode2session ?appid appid secret secret js_code code grant_typeauthorization_code; // 用 HttpClient 发起 GET 请求解析返回体 String response httpGet(url); MapString, Object result JSON.parseObject(response); if (result.get(errcode) ! null) { throw new RuntimeException(code2Session 失败: result.get(errmsg)); } // result 中包含 openid, session_key, unionid return result; } }调用该接口时js_code有效期只有 5 分钟且只能使用一次重复使用会报40029错误。grant_type固定传authorization_code。返回里的openid是用户在该小程序下的唯一标识unionid需要 AppID 绑定开放平台账号才有值普通项目用 openid 即可。3.2 session_key 的存储与安全边界session_key是微信会话密钥用于解密手机号等敏感数据它的安全要求非常高。实际项目中不要把 session_key 直接返回给前端也不要用它做业务登录态兜底。业界常见做法是服务端把session_key存在 Redis 里key 用 openid 拼接一个随机 token返回给前端的是这个业务 token后续接口再拿 token 换 openid。再一个容易被忽视的细节同一个用户每次调用wx.login()都会生成新的 code但 session_key 只有在用户删除小程序后重新进入时才会变化。如果 session_key 用于手机号解密要留意前端传的加密数据是对应哪一次登录态的错开会造成解密失败。在支付和退款场景中我们几乎用不到 session_key只需要记住它和 openid 的绑定关系即可。3.3 登录态与支付用户校验的边界JSAPI V3 支付下单时参数payer.openid必须是真实用户在小程序下的 openid。如果用户在 A 小程序登录却在 B 小程序的支付接口里传 A 的 openid微信会返回APPID_MCHID_NOT_MATCH或PARAM_ERROR。所以后端的登录态里必须保存 openid 与 AppID 的绑定关系下单时从登录态里取而不是从请求参数里读。我见过不少团队直接用前端传上来的 openid 下单这等于把支付人的决定权交给了调用方存在被刷单或替他人付款的风险。正确做法是登录后把 openid 写入服务端会话下单接口从会话里取 openid前端只传订单信息。这条边界在小程序登录和支付的对接中非常关键安全事故往往就出在这种关联关系上。4. JSAPI V3 支付下单与前端调起完整链路4.1 统一下单接口的请求体与参数语义小程序内支付的核心接口是POST /v3/pay/transactions/jsapi请求体字段不算多但每个字段都有讲究。description是商品描述会展示在用户的支付账单里out_trade_no是商户订单号微信侧用它做幂等键同一个订单号重复下单会返回上一次的 prepay_idamount.total单位是分不是元这里经常有小数点精度问题。下面给出核心下单代码自实现了签名和请求逻辑public String createJsapiOrder(String openid, String outTradeNo, Integer totalFee, String description) { String urlPath /v3/pay/transactions/jsapi; MapString, Object body new HashMap(); body.put(appid, appid); body.put(mchid, mchid); body.put(description, description); body.put(out_trade_no, outTradeNo); body.put(notify_url, notifyUrl); MapString, Object amount new HashMap(); amount.put(total, totalFee); amount.put(currency, CNY); body.put(amount, amount); MapString, String payer new HashMap(); payer.put(openid, openid); body.put(payer, payer); String json JSON.toJSONString(body); String auth signer.buildAuthorization(POST, urlPath, json); // 发起 HTTP POSTheader 带 Authorizationbody 为 json String resp httpPost(https://api.mch.weixin.qq.com urlPath, auth, json); JSONObject obj JSON.parseObject(resp); return obj.getString(prepay_id); }下单成功后返回prepay_id它只是支付凭证不能直接给前端调起收银台。notify_url是异步回调地址要求公网可访问且是 HTTPS。回调地址早配晚配都会影响支付结果通知联调阶段可以用内网穿透工具临时映射但生产环境一定要用正式域名并保证回调接口能扛住一定并发。4.2 前端 wx.requestPayment 的二次签名拿到 prepay_id 后还需要再生成一组参数给前端。这组参数不是把 prepay_id 原样返回而是需要用商户私钥对一段固定格式的字符串签名生成paySign。前端拿到后调用wx.requestPayment弹起收银台。二次签名的字符串格式为appid\n timeStamp\n nonceStr\n packageprepay_idxxx\n注意这里换行和顺序不能错。下面是生成前端支付参数的代码public MapString, String buildPayParams(String prepayId) throws Exception { String timeStamp String.valueOf(System.currentTimeMillis() / 1000); String nonceStr UUID.randomUUID().toString().replace(-, ); String packageStr prepay_id prepayId; String message appid \n timeStamp \n nonceStr \n packageStr \n; Signature sign Signature.getInstance(SHA256withRSA); sign.initSign(merchantPrivateKey); sign.update(message.getBytes(StandardCharsets.UTF_8)); String paySign Base64.getEncoder().encodeToString(sign.sign()); MapString, String result new HashMap(); result.put(timeStamp, timeStamp); result.put(nonceStr, nonceStr); result.put(package, packageStr); result.put(signType, RSA); result.put(paySign, paySign); return result; }signType必须传RSA因为 V3 只支持 SHA256withRSA不像 V2 有 MD5 和 HMAC-SHA256 选项。前端把以上五个字段传入wx.requestPayment如果签名错误收银台会直接报错invalid signature。时间戳这里取秒不要用毫秒。nonceStr 是一次性的注意和下单请求体的 nonce_str 不要混用同一个值各自独立生成。4.3 支付结果查询与回调通知的兜底策略微信支付的成功通知是异步的notify_url可能收到也可能因为网络问题收不到。所以不能只依赖回调更新订单状态在下单成功后要主动发起查询确认订单最终状态。查询接口是GET /v3/pay/transactions/out-trade-no/{out_trade_no}?mchid{mchid}返回的trade_state为SUCCESS才表示支付成功。这里有一个工程细节值得多说一句不要在前端wx.requestPayment的 success 回调里直接发请求改订单状态。前端 success 只代表微信收银台成功弹出并完成了支付动作不代表服务端已经收到回调。正确做法是前端通知后端“用户已完成支付”后端先查一次订单状态再返回确认结果。如果查询返回NOTPAY可以让前端稍等再查或者依靠异步回调兜底。回调通知的处理里最核心的两个动作是验签和解密。微信回调的 body 里resource字段是 AES-256-GCM 加密的需要用 APIv3 密钥解密。解密后才得到订单号、金额、交易号等数据再根据 out_trade_no 更新本地订单状态最后返回{code:SUCCESS}。这里的流程在退款回调里同样适用。5. 退款接口与 AES-GCM 回调解密实现5.1 退款请求的参数设计与签名复用退款和支付走的是不同的 API 路径POST /v3/refund/domestic/refunds但签名方式完全一致。请求体里有几个字段容易出错out_refund_no是商户退款单号必须和支付订单号区分开不能用同一个值amount.refund是本次退款金额amount.total是原订单总金额单位都是分。看一段退款请求代码public String refund(String outTradeNo, String outRefundNo, Integer refundFee, Integer totalFee) { String urlPath /v3/refund/domestic/refunds; MapString, Object body new HashMap(); body.put(out_trade_no, outTradeNo); body.put(out_refund_no, outRefundNo); MapString, Object amount new HashMap(); amount.put(refund, refundFee); amount.put(total, totalFee); amount.put(currency, CNY); body.put(amount, amount); String json JSON.toJSONString(body); String auth signer.buildAuthorization(POST, urlPath, json); String resp httpPost(https://api.mch.weixin.qq.com urlPath, auth, json); JSONObject obj JSON.parseObject(resp); return obj.getString(refund_id); }退款是异步的接口返回成功后只是说明退款请求受理了不代表钱已经退回用户。refund_id是微信侧退款单号建议落库存下来。需要重点留意的是金额校验做退款前服务端要重新查一次原订单的实付金额避免前端传一个大于实付金额的数值。退款金额超过原订单金额时微信会返回NOT_ENOUGH或INVALID_REQUEST。再有一点退款也需要回调通知在请求体里传notify_url不传则默认使用支付回调地址。部分退款场景下同一个订单多次退款每次的out_refund_no必须不同微信用这个字段做幂等。5.2 回调通知的解密步骤与 AES-GCM 代码支付回调和退款回调的报文结构一致都是resource里放加密数据。解密需要三个参数ciphertext密文、nonce随机串、associated_data附加数据。用 APIv3 密钥作为 AES 密钥nonce 作为 GCM 模式的 IV解密后得到 JSON 原文。以下是完整解密方法public static String decryptResource(String apiV3Key, String ciphertext, String nonce, String associatedData) throws Exception { SecretKeySpec key new SecretKeySpec(apiV3Key.getBytes(StandardCharsets.UTF_8), AES); GCMParameterSpec spec new GCMParameterSpec(128, nonce.getBytes(StandardCharsets.UTF_8)); Cipher cipher Cipher.getInstance(AES/GCM/NoPadding); cipher.init(Cipher.DECRYPT_MODE, key, spec); if (associatedData ! null !associatedData.isEmpty()) { cipher.updateAAD(associatedData.getBytes(StandardCharsets.UTF_8)); } byte[] plaintext cipher.doFinal(Base64.getDecoder().decode(ciphertext)); return new String(plaintext, StandardCharsets.UTF_8); }解密失败的高频原因有三个。一是 APIv3 密钥配错了商户平台重置密钥后回调用旧密钥解密必然失败。二是 UTF-8 编码问题某些语言默认用 ISO-8859-1 读取请求体导致密文被转码。三是associatedData处理不当微信某些场景下该字段为 null代码要允许跳过updateAAD。解密后需要从明文里取出out_trade_no和amount.total与本地订单比对金额不一致就返回失败。5.3 退款状态查询与幂等设计退款申请提交后除了等回调也要主动查状态。查询接口是GET /v3/refund/domestic/refunds/{out_refund_no}它返回的status有四种SUCCESS、CLOSED、ABNORMAL、PROCESSING。只有SUCCESS才能确认退款完成。这个查接口在联调阶段尤其有用可以快速确认退款请求是否真的创建成功。幂等设计的要点是同一个退款请求重复提交时微信不会重复退款而是返回相同的refund_id。所以在业务侧out_refund_no必须全局唯一建议用业务流水号或订单号加序号生成。比如refund_ 原订单号 随机后缀。如果退款单号重复微信会直接复用原退款单不会触发二次退款但日志里要能区分是重复请求还是新请求。6. 接口联调常见报错与压测排查技巧6.1 登录返回 errcode 40029 与支付报错 PARAM_ERROR 的排错路径联调中最高频的错误集中在两个环节。第一个是登录时报40029 invalid code原因通常是 code 重复使用或过期。前端每调一次wx.login()都会废弃上一次 code如果后端接口超时后前端又调了一次第二次就用不了第一个 code 了。排查时看后端日志里 code 是否和前端传入的一致并确认调用链路上没有二次转发。第二个是支付下单报PARAM_ERROR这类错误微信不会告诉你是哪个字段错了只返回错误码。最快的定位方法是把请求体原文和文档逐字段比对重点检查appid是否和 mchid 有绑定关系、amount.total是否传了字符串而不是整数。在 JSON 里字段类型错误非常常见因为total是 int有些代码会拼成1字符串微信直接拒绝。6.2 回调验签失败时如何排查回调验签失败是一个高频且难排查的问题。先确认你是在验平台证书的签名而不是验你的请求签名。微信回调头里的Wechatpay-Signature是微信用平台私钥签的需要用平台公钥验。很多团队在这步错用了商户私钥公钥自然验不过。其次是时间戳偏移服务器时间和标准时间差超过几分钟微信会认为请求已过期返回401。再有一个容易被忽略的点回调通知的验签和请求 API 的验签不是同一套证书。请求用商户 API 证书中的私钥签名微信用商户公钥验微信回调用平台证书中的私钥签名开发者用平台公钥验。平台证书可以通过GET /v3/certificates下载也可以用微信支付官网提供的方式批量下载并部署到本地。验签时建议打印Wechatpay-Timestamp和本地服务器时间快速判断是否是时间窗口问题。6.3 用日志记录一次全链路请求的关键字段联调期间建议在核心代码里打结构化日志把每个环节的关键字段串起来。一般在登录接口记录 code、openid下单接口记录 out_trade_no、prepay_id回调接口记录解密前的 resource 和解密后的订单号、金额。出了问题按 out_trade_no 查全链路日志是最快的定位方式。我习惯用一个统一字段trace_id贯穿登录到支付到退款前端每次请求带上幂等标识后端在响应里返回同一个值。日志级别上登录和下单打 info回调解密打 debug报错打 warn。联调阶段把 debug 全量打开观察签名和报文上线后关掉 debug保留 info 和 warn。验证上线是否正常的最后一步是用真实用户账号下一笔 0.01 元订单完成支付、退款两套流程确认回调都收到且状态同步一致。本文还有配套的精品资源点击获取