news 2026/6/10 3:04:32

SpringBoot微信小程序JSAPI支付完整后端实现,含下单、回调、查单、关单四步闭环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot微信小程序JSAPI支付完整后端实现,含下单、回调、查单、关单四步闭环

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

简介:直接可用的微信小程序JSAPI支付后端方案,基于SpringBoot开发,覆盖从用户下单到支付完成的全部关键环节。提供统一下单接口(createOrder),自动接收并验签微信服务器推送的异步支付结果通知(notify),支持主动查询订单状态(queryOrder)和手动关闭未支付订单(closeOrder)。内置OkHttp封装、FastJSON解析、XML签名与验签逻辑、客户端IP获取、随机密钥生成等基础工具,附带MySQL建表SQL(base_pay_order.sql)及配套Mapper、Service、Controller代码。核心支付逻辑集中于WxPay工具类,业务方只需传入openId、商品描述、金额等必要参数即可快速接入,无需处理证书加载、签名算法、HTTPS通信等底层细节。所有配置统一管理在WxPayConfig.java中,严格遵循微信支付V2接口规范,兼容JDK 8+及SpringBoot 2.x/3.x主流版本。

1. 这不是“又一个支付Demo”,而是一套能直接上线的小程序JSAPI支付后端骨架

我做支付系统集成快八年了,从最早手动拼XML、手算MD5签名,到后来用官方SDK踩坑无数,再到如今把整套流程拆解成可复用、可审计、可监控的模块——这套SpringBoot微信小程序JSAPI支付实现,就是我在三个真实电商小程序项目里反复打磨出来的“最小可用生产级后端”。它不讲大道理,不堆概念,只解决四个最硬核的问题:用户点“立即支付”之后,后端怎么在300毫秒内返回预支付参数;支付成功后微信服务器推过来的那条XML通知,怎么在不被伪造的前提下稳稳接住;用户突然切后台再回来,订单状态怎么实时刷新;还有最关键的——钱还没到账,但用户取消了,订单必须立刻关闭,不能留任何资金悬空风险。

关键词里“小程序支付”“JSAPI支付”“SpringBoot集成”“微信支付回调”“统一下单”,每一个都不是虚词。比如“JSAPI支付”,它和公众号支付、H5支付根本不是一回事:它强制要求调起支付时必须传入用户的有效openId(绑定在当前小程序AppID下),且签名密钥必须是商户平台配置的APIv2密钥(32位纯字母数字),而不是V3的证书体系;再比如“微信支付回调”,它不是HTTP GET请求,而是微信服务器用POST方式、以application/xml格式、不带任何URL参数地推送到你的notify地址——这意味着你连SpringMVC的@RequestParam都用不上,必须原生读取InputStream;而“统一下单”接口返回的prepay_id,也不是直接给前端的,你还得按微信文档要求二次组装成{ "appId": "...", "timeStamp": "...", "nonceStr": "...", "package": "prepay_id=xxx", "signType": "MD5", "paySign": "..." }这个特定JSON结构,否则小程序wx.requestPayment会静默失败。

这套方案之所以能“开箱即用”,是因为我把所有容易出错的环节都做了防御性封装:OkHttp连接池自动管理超时与重试,FastJSON解析时强制忽略未知字段防止反序列化崩溃,XML签名前自动trim空格、转义特殊字符、按字典序排序参数,验签时不仅校验签名,还校验通知里的return_coderesult_code双层状态码,甚至IP白名单校验也内置了——微信回调只可能从固定几个IP段发起,你不需要自己去查文档,工具类已经帮你写死了。MySQL建表SQL里base_pay_order这张表,字段设计不是拍脑袋来的:out_trade_no加了唯一索引防重复下单,transaction_id允许为空(因为只有支付成功才回填),status用tinyint(1)存0-3四个状态(0待支付/1支付中/2已支付/3已关闭),避免字符串比较带来的隐患。WxPay工具类里每个方法都是原子操作,createOrder失败绝不留脏数据,closeOrder执行前必查当前状态是否为“待支付”,queryOrder返回结果必带微信原始响应头供审计。你拿到代码,改好WxPayConfig里的5个配置项(AppID、MchID、APIv2密钥、证书路径、回调地址),就能跑通全流程。这不是教学Demo,这是我在凌晨三点处理完一笔异常订单后,把血泪教训写进代码里的产物。

2. 整体架构设计:为什么放弃官方SDK,选择手撸核心逻辑

2.1 放弃微信官方SDK的三大现实理由

很多团队第一反应是“直接引入weixin-java-pay”,但我在三个项目里都主动放弃了它,原因很实在:

第一,版本碎片化严重。微信支付V2接口虽稳定,但官方SDK更新节奏慢,SpringBoot 3.x全面转向Jakarta EE 9+命名空间后,旧版SDK依赖的javax.servlet包直接冲突,强行升级又牵扯到Jackson、OkHttp等底层依赖的兼容性问题。我试过fork源码改包名,结果发现其XML解析器对中文商品描述里的全角标点处理有bug,导致验签永远失败——这种细节问题,官方Issue里躺了两年没人修。

第二,过度抽象掩盖关键路径。SDK把createOrder、notify、queryOrder都封装成一行调用,看似简洁,但一旦线上出现“预支付返回success但小程序调不起支付”的问题,你得层层点进去看它怎么拼URL、怎么设Header、怎么处理HTTP状态码。而我们业务方最需要的,其实是清晰的调用链路和可控的日志埋点。比如notify接口,SDK默认把整个XML日志打成一行DEBUG,而我们需要的是:原始XML体(用于人工比对)、解析后的Map(验证字段完整性)、验签结果(true/false)、业务状态更新结果(影响数据库行数)。手写逻辑可以精准控制每一行日志的粒度。

第三,安全边界模糊。SDK内部会自动加载证书、缓存密钥、管理连接池,但它的销毁时机不透明。我们有个项目因容器热部署频繁,SDK的静态证书缓存没清理干净,导致新实例用旧证书签名,微信返回“签名错误”。而手写方案里,证书加载、密钥生成、连接池创建全部放在WxPayConfig的@Bean方法里,生命周期由Spring容器严格管理,启动时加载,关闭时销毁,毫无歧义。

2.2 四步闭环的职责划分与数据流向

这套方案的骨架非常清晰:四个核心动作对应四个独立接口,彼此解耦,通过订单号(out_trade_no)串联:

  • createOrder(统一下单):接收前端传来的openId、body、totalFee,生成唯一out_trade_no,调用微信https://api.mch.weixin.qq.com/pay/unifiedorder,返回prepay_id并持久化订单到MySQL。关键约束:同一out_trade_no在15分钟内重复请求,必须直接返回缓存结果,避免微信侧生成多笔交易。

  • notify(支付回调):暴露/api/wxpay/notify端点,微信服务器POST原始XML至此。流程为:① 校验来源IP(白名单)→ ② 读取InputStream转String → ③ XML转Map并验签 → ④ 双重状态校验(return_code=SUCCESS && result_code=SUCCESS)→ ⑤ 更新订单状态为“已支付”→ ⑥ 返回<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>注意:必须返回这段固定XML,且不能有任何额外空格或换行,否则微信会持续重发

  • queryOrder(查单):提供/api/wxpay/query?outTradeNo={}接口,调用微信https://api.mch.weixin.qq.com/pay/orderquery,将微信返回的trade_state(NOTPAY/USERPAYING/SUCCESS/REFUND/CLOSED/REVOKED)映射为本地status,并同步更新数据库。此接口供前端轮询或用户主动刷新订单页使用。

  • closeOrder(关单):提供/api/wxpay/close?outTradeNo={}接口,调用微信https://api.mch.weixin.qq.com/pay/closeorder,仅当本地订单status为0(待支付)时才发起关单请求。微信关单成功后,本地status更新为3(已关闭)。重要:关单接口不能替代超时关单逻辑,它只是人工干预入口;真正的超时关单必须由定时任务驱动

数据流向图(文字描述):前端下单 → createOrder生成out_trade_no并落库 → 小程序调wx.requestPayment → 用户支付 → 微信服务器异步notify → 后端更新订单状态 → 前端轮询queryOrder确认最终状态;若用户取消,前端调closeOrder触发关单;若用户未操作,定时任务扫描status=0且create_time超过30分钟的订单,自动调closeOrder。

2.3 工具链选型:为什么是OkHttp + FastJSON + 原生XML

  • OkHttp:替代RestTemplate的绝对首选。它支持连接池复用(避免频繁创建Socket)、自动GZIP解压(微信响应默认压缩)、拦截器链(方便添加统一User-Agent、日志打印、重试逻辑)。我在WxPayClient里配置了:connectTimeout=10s、readTimeout=15s、writeTimeout=15s、最大连接数20、自动重试1次。实测在高并发下单场景下,比RestTemplate吞吐量高40%,错误率低60%。

  • FastJSON:虽然Alibaba已宣布停止维护,但V1.2.83仍是目前处理微信XML→JavaBean最稳定的方案。关键在于它的XMLDeserializer能正确处理CDATA块(如<return_msg><![CDATA[OK]]></return_msg>),而Jackson的xml-module对CDATA支持极差。我们不用它反序列化整个XML,而是先用正则提取所有<tag>value</tag>,再用FastJSON的parseObject(xmlString, Map.class)转成键值对,最后手动映射到DTO——这样既规避了复杂嵌套XML的解析风险,又保留了字段级控制权。

  • 原生XML处理:拒绝任何XML框架(如XStream、JAXB)。签名时,我们按微信规则:① 过滤掉sign字段;② 所有字段值trim()去首尾空格;③ 对key=value按字典序拼接(如appid=wx123&body=商品&...);④ 拼接后末尾追加&key=APIv2密钥;⑤ MD5哈希并转大写。验签时完全逆向操作。所有步骤用StringBuilder手写,无反射、无动态代理,性能极致,逻辑透明。我甚至把签名字符串生成过程打成DEBUG日志,线上出问题时,复制日志里的字符串到微信签名工具里一比对,5秒定位是字段没trim还是顺序错了。

3. 核心细节解析:从配置到数据库,每个环节都藏着坑

3.1 WxPayConfig:5个配置项背后的生死线

所有配置收口在WxPayConfig.java,它不是一个简单的Properties类,而是Spring Boot的@ConfigurationProperties,具备类型安全和校验能力:

@ConfigurationProperties(prefix = "wxpay") @Data public class WxPayConfig { private String appId; // 小程序AppID,必须和前端wx.login()获取的unionId所属AppID一致 private String mchId; // 商户号,10位纯数字,不是子商户号 private String apiKey; // APIv2密钥,32位字母数字,必须和商户平台"API安全"里设置的完全一致(区分大小写!) private String certPath; // PKCS12证书路径,如classpath:apiclient_cert.p12,密码固定为mchId private String notifyUrl; // 回调地址,必须是外网可访问的HTTPS域名,且在商户平台"开发配置"里备案 }

这5个配置里,apiKeynotifyUrl是两大雷区:

  • apiKey:微信商户平台设置密钥时,页面会提示“请妥善保管,无法查看”。很多人复制时不小心带了前后空格,或者用了全角字符。我们在@PostConstruct方法里加了强校验:
    java @PostConstruct public void validate() { if (StringUtils.isBlank(apiKey) || apiKey.length() != 32 || !apiKey.matches("[a-zA-Z0-9]{32}")) { throw new IllegalArgumentException("APIv2密钥必须为32位字母数字,当前值:" + apiKey); } if (!notifyUrl.startsWith("https://")) { throw new IllegalArgumentException("notifyUrl必须为HTTPS协议"); } }
    实测帮两个团队避开了因密钥错误导致的“签名失败”问题。

  • notifyUrl:必须是完整URL,包括路径。比如你配置https://api.xxx.com,微信会POST到https://api.xxx.com/(根路径),但你的Controller映射的是/api/wxpay/notify,必然404。正确配置应为https://api.xxx.com/api/wxpay/notify。更致命的是,微信回调不携带任何Cookie或Session,所以notify接口必须是@RestController且方法上加@RequestMapping(value = "/api/wxpay/notify", method = RequestMethod.POST, produces = MediaType.APPLICATION_XML_VALUE)produces指定返回XML,否则Spring MVC可能按Accept头返回JSON,导致微信解析失败。

3.2 MySQL建表SQL:字段设计如何支撑高并发与审计

base_pay_order.sql不是简单CREATE TABLE,每个字段都有明确业务语义:

CREATE TABLE `base_pay_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `out_trade_no` varchar(64) NOT NULL COMMENT '商户订单号,业务方生成,全局唯一', `transaction_id` varchar(64) DEFAULT NULL COMMENT '微信订单号,支付成功后回填', `open_id` varchar(128) NOT NULL COMMENT '用户openId,用于JSAPI支付', `body` varchar(128) NOT NULL COMMENT '商品描述', `total_fee` int NOT NULL COMMENT '总金额,单位为分', `status` tinyint NOT NULL DEFAULT '0' COMMENT '订单状态:0待支付 1支付中 2已支付 3已关闭', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', `notify_time` datetime DEFAULT NULL COMMENT '微信回调时间,用于审计', PRIMARY KEY (`id`), UNIQUE KEY `uk_out_trade_no` (`out_trade_no`) COMMENT '商户订单号唯一索引,防重复下单', KEY `idx_openid_status` (`open_id`,`status`) COMMENT '按用户查订单列表', KEY `idx_status_ctime` (`status`,`create_time`) COMMENT '按状态+时间扫描超时订单' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='微信支付订单表';

关键设计点:

  • out_trade_no唯一索引:这是防重的核心。createOrder方法第一步就是SELECT COUNT(*) FROM base_pay_order WHERE out_trade_no = ? AND status IN (0,1),如果存在未完成订单,直接返回缓存的prepay_id,绝不调微信接口。我们曾在线上遇到因Nginx负载均衡导致同一请求被分发到两台机器,唯一索引让第二台机器插入失败,捕获DuplicateKeyException后优雅返回,避免微信侧生成两笔交易。

  • statustinyint而非varchar:状态值只有0-3四个,用数字比较比字符串快10倍以上,且占用空间小。更重要的是,它强制业务逻辑只能在这四个值里切换,避免出现status='paid'这种非法值。

  • notify_time字段:微信回调成功后,必须更新此字段。它不仅是审计依据(查某笔订单何时收到回调),更是排查问题的关键——如果notify_time为空但status=2,说明是人工误操作更新了状态;如果notify_time有值但status!=2,说明回调处理逻辑有bug。

3.3 WxPay工具类:四个方法如何保证原子性与幂等性

核心逻辑集中在WxPay.java,所有方法均声明为static,便于单元测试,且每个方法都遵循“三原则”:输入校验、事务控制、异常兜底

createOrder方法详解
public static WxPayResult createOrder(String openId, String body, Integer totalFee, String outTradeNo) { // 1. 输入校验 if (StringUtils.isBlank(openId) || StringUtils.isBlank(body) || totalFee == null || totalFee <= 0) { throw new IllegalArgumentException("参数错误:openId/body/totalFee不能为空且totalFee>0"); } if (StringUtils.isBlank(outTradeNo)) { outTradeNo = "ORD" + System.currentTimeMillis() + RandomUtil.randomNumbers(6); // 业务方不传则自动生成 } // 2. 数据库防重检查(关键!) PayOrder existing = payOrderMapper.selectByOutTradeNo(outTradeNo); if (existing != null && (existing.getStatus() == 0 || existing.getStatus() == 1)) { log.info("订单已存在,直接返回预支付参数,outTradeNo={}", outTradeNo); return buildPrepayResponse(existing); // 从DB读取已有prepay_id组装响应 } // 3. 构造微信请求参数 Map<String, String> params = new HashMap<>(); params.put("appid", config.getAppId()); params.put("mch_id", config.getMchId()); params.put("nonce_str", RandomUtil.randomString(32)); params.put("body", body); params.put("out_trade_no", outTradeNo); params.put("total_fee", totalFee.toString()); params.put("spbill_create_ip", IpUtil.getRealIp()); // 获取真实客户端IP params.put("notify_url", config.getNotifyUrl()); params.put("trade_type", "JSAPI"); params.put("openid", openId); String sign = SignUtil.generateSign(params, config.getApiKey()); // 手写签名 params.put("sign", sign); // 4. 调用微信接口 String xmlRequest = XmlUtil.mapToXml(params); String xmlResponse = OkHttpUtil.postXml("https://api.mch.weixin.qq.com/pay/unifiedorder", xmlRequest); // 5. 解析响应并落库 Map<String, String> respMap = XmlUtil.xmlToMap(xmlResponse); if ("SUCCESS".equals(respMap.get("return_code")) && "SUCCESS".equals(respMap.get("result_code"))) { PayOrder order = new PayOrder(); order.setOutTradeNo(outTradeNo); order.setOpenId(openId); order.setBody(body); order.setTotalFee(totalFee); order.setStatus(1); // 支付中 order.setPrepayId(respMap.get("prepay_id")); payOrderMapper.insert(order); // MyBatis-Plus insert,自动生成id return buildPrepayResponse(order); // 组装小程序所需JSON } else { throw new RuntimeException("微信下单失败:" + respMap.get("err_code_des")); } }

幂等性保障:靠selectByOutTradeNo查询+唯一索引双重保险。即使并发请求同时到达,第一个插入成功,第二个因唯一索引失败抛出异常,被上层@Transactional捕获后回滚,然后重试查询,拿到已存在的记录。

原子性保障:整个方法不在事务内,但insert操作本身是原子的。我们不把微信调用和DB插入包在一个事务里,因为微信接口超时不可控,长事务会拖垮数据库。策略是:先查DB,存在则返回;不存在则调微信,微信成功后再插入DB。如果微信成功但DB插入失败(概率极低),下次查询仍会失败,需人工介入,但绝不会产生“微信已下单,DB无记录”的脏数据。

notify方法的生死细节
@PostMapping(value = "/api/wxpay/notify", consumes = MediaType.APPLICATION_XML_VALUE) public ResponseEntity<String> handleNotify(@RequestBody String xml) { try { // 1. IP白名单校验(微信固定IP段) String clientIp = request.getRemoteAddr(); if (!IpUtil.isWechatIp(clientIp)) { log.warn("非法IP访问notify:{}", clientIp); return ResponseEntity.status(HttpStatus.FORBIDDEN).body(buildFailXml("非法IP")); } // 2. XML转Map并验签 Map<String, String> notifyMap = XmlUtil.xmlToMap(xml); if (!SignUtil.verifySign(notifyMap, config.getApiKey())) { log.warn("notify验签失败,原始XML:{}", xml); return ResponseEntity.ok(buildFailXml("签名失败")); } // 3. 双重状态校验(微信文档强调!) if (!"SUCCESS".equals(notifyMap.get("return_code")) || !"SUCCESS".equals(notifyMap.get("result_code"))) { log.warn("notify业务状态失败,return_code={},result_code={}", notifyMap.get("return_code"), notifyMap.get("result_code")); return ResponseEntity.ok(buildFailXml("业务失败")); } // 4. 更新订单状态(关键:必须先查再更新,防并发) PayOrder order = payOrderMapper.selectByOutTradeNo(notifyMap.get("out_trade_no")); if (order == null || order.getStatus() != 0) { // 只有待支付才能更新为已支付 log.warn("订单不存在或状态非法,无法更新,outTradeNo={}", notifyMap.get("out_trade_no")); return ResponseEntity.ok(buildSuccessXml()); } order.setStatus(2); order.setTransactionId(notifyMap.get("transaction_id")); order.setNotifyTime(new Date()); int updated = payOrderMapper.updateById(order); if (updated != 1) { log.error("notify更新订单失败,可能并发冲突,outTradeNo={}", notifyMap.get("out_trade_no")); return ResponseEntity.ok(buildFailXml("更新失败")); } log.info("notify处理成功,订单已支付,outTradeNo={}", notifyMap.get("out_trade_no")); return ResponseEntity.ok(buildSuccessXml()); } catch (Exception e) { log.error("notify处理异常", e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(buildFailXml("系统异常")); } }

生死细节
-consumes = MediaType.APPLICATION_XML_VALUE:强制Spring MVC用String接收原始XML,避免Jackson试图解析成对象失败。
-buildSuccessXml()必须返回严格符合微信要求的XML,连换行符都不能多一个,否则微信认为失败并重发。
- 更新订单前selectByOutTradeNo再查一次:这是防“两次回调并发更新”的最后一道锁。假设A回调正在执行update,B回调同时到达,B先查到status=0,但A更新后commit,B的update会因where条件不匹配而影响0行,此时我们返回fail,微信会重发B,直到成功。

4. 实操过程:从零部署到线上验证的完整流水线

4.1 环境准备与证书加载

部署前必须完成三件事:

  1. 下载并放置证书:登录微信商户平台 → “账户中心” → “API安全” → 下载“API证书”。解压后得到apiclient_cert.p12apiclient_key.pem注意:微信只提供p12格式,且密码固定为你的商户号(mchId)。将apiclient_cert.p12放到src/main/resources/目录下,在WxPayConfig.certPath中配置为classpath:apiclient_cert.p12

  2. 配置HTTPS回调域名:在商户平台“开发配置” → “支付授权目录”里,添加你的回调域名(如https://api.xxx.com/)。重点:这里填的是域名,不是完整URL;且必须是HTTPS,HTTP会被拒绝。同时,确保你的Nginx或云服务商已配置SSL证书,且443端口开放。

  3. 开通JSAPI支付权限:在商户平台“产品中心” → “开发配置” → 确认“JSAPI支付”已开通,并绑定你的小程序AppID。常见错误:绑错了AppID(比如把公众号AppID绑进来了),导致createOrder返回“appid not registered”

证书加载逻辑在WxPayConfig@Bean方法里:

@Bean public SSLContext sslContext() throws Exception { KeyStore keyStore = KeyStore.getInstance("PKCS12"); try (InputStream is = resourceLoader.getResource(config.getCertPath()).getInputStream()) { keyStore.load(is, config.getMchId().toCharArray()); // 密码是mchId } KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); keyManagerFactory.init(keyStore, config.getMchId().toCharArray()); SSLContext sslContext = SSLContext.getInstance("TLS"); sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); return sslContext; }

这段代码确保OkHttp客户端发起HTTPS请求时,能正确携带商户证书。如果证书路径错误或密码不对,keyStore.load()会抛出IOException,Spring启动失败,第一时间暴露问题。

4.2 四步闭环的逐级验证方案

不要一上来就测全流程,按顺序分四步验证,每步成功再进下一步:

第一步:createOrder接口验证(本地即可)

用Postman发送POST请求:

POST http://localhost:8080/api/wxpay/create Content-Type: application/json { "openId": "oZjQY5Kz7yqLdFgHjIuVwXyZaBcD", "body": "测试商品", "totalFee": 1 }

预期响应:

{ "appId": "wx1234567890abcdef", "timeStamp": "1712345678", "nonceStr": "abcd1234efgh5678ijkl9012mnop3456", "package": "prepay_id=wx1234567890abcdef1234567890abcdef", "signType": "MD5", "paySign": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6" }

验证点
- HTTP状态码200
-paySign能用微信签名工具验证通过(复制appId+timeStamp+nonceStr+package+signType+key字符串)
- MySQL里base_pay_order表新增一条status=1的记录

第二步:notify接口验证(需外网环境)

微信回调无法本地调试,必须部署到有HTTPS域名的环境。验证方法:

  1. 用createOrder生成一笔订单,拿到out_trade_no
  2. 访问微信支付沙箱环境(需申请),用沙箱密钥调用unifiedorder,获得沙箱prepay_id
  3. 在小程序前端用此prepay_idwx.requestPayment,支付成功后,微信沙箱会向你的notifyUrl推送测试回调

或者更简单:用curl模拟微信回调(仅限测试环境,生产禁用):

curl -X POST https://api.xxx.com/api/wxpay/notify \ -H "Content-Type: application/xml" \ -d '<xml><appid><![CDATA[wx1234567890abcdef]]></appid><bank_type><![CDATA[CMB_DEBIT]]></bank_type><cash_fee><![CDATA[1]]></cash_fee><fee_type><![CDATA[CNY]]></fee_type><is_subscribe><![CDATA[N]]></is_subscribe><mch_id><![CDATA[1234567890]]></mch_id><nonce_str><![CDATA[abcd1234efgh5678ijkl9012mnop3456]]></nonce_str><openid><![CDATA[oZjQY5Kz7yqLdFgHjIuVwXyZaBcD]]></openid><out_trade_no><![CDATA[ORD1712345678123456]]></out_trade_no><result_code><![CDATA[SUCCESS]]></result_code><return_code><![CDATA[SUCCESS]]></return_code><sign><![CDATA[A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6]]></sign><time_end><![CDATA[20240405123456]]></time_end><total_fee>1</total_fee><trade_type><![CDATA[JSAPI]]></trade_type><transaction_id><![CDATA[42000012345678901234567890]]></transaction_id></xml>'

验证点
- 返回<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>
- MySQL里对应订单status变为2,notify_time有值,transaction_id被填充

第三步:queryOrder与closeOrder验证
  • queryOrder:用浏览器访问https://api.xxx.com/api/wxpay/query?outTradeNo=ORD1712345678123456,返回JSON包含status=2和微信原始字段。
  • closeOrder:先确保订单status=0,再访问https://api.xxx.com/api/wxpay/close?outTradeNo=ORD1712345678123456,返回{"code":200,"msg":"关单成功"},且数据库status=3

4.3 生产环境必备监控与告警

上线后必须加三道监控:

  1. 回调失败率监控:统计/api/wxpay/notify接口的HTTP 200响应占比。正常应>99.9%。如果连续5分钟低于99%,触发企业微信告警。失败原因主要是验签失败或IP非法,需立即检查apiKey或防火墙配置。

  2. 订单状态不一致告警:定时任务每5分钟扫描status=0create_time超过30分钟的订单,如果数量>10,告警“存在大量超时未支付订单,检查closeOrder是否失效”。

  3. 微信接口调用延迟监控:用Micrometer埋点,监控WxPay.createOrder方法的P95耗时。正常应在300ms内,如果突增至2s以上,可能是网络抖动或微信服务降级,需切换备用线路或降级为“暂不支持支付”。

5. 常见问题与排查技巧实录:那些让你半夜爬起来的坑

5.1 典型问题速查表

问题现象可能原因排查命令/步骤解决方案
createOrder返回“签名错误”apiKey含空格或大小写错误echo "API密钥长度:${#apiKey}";用od -c查看是否含不可见字符重新复制密钥,用trim()处理
小程序调wx.requestPayment报“支付验证失败”返回的JSON里paySign计算错误复制appId+timeStamp+nonceStr+package+signType+key到微信签名工具验证检查package字段是否漏了prepay_id=前缀;确认timeStamp是字符串非数字
notify接口收不到微信回调Nginx未透传原始XMLtail -f /var/log/nginx/access.log \| grep notify,看是否有404或415配置Nginx:client_max_body_size 10M;proxy_set_header Content-Type application/xml;
notify返回SUCCESS但订单状态未更新并发导致updateById影响0行查MySQL binlog:mysqlbinlog --base64-output=DECODE-ROWS -v mysql-bin.000001 \| grep -A 10 "out_trade_no"updateById后加if (updated != 1) throw new RuntimeException("并发更新失败"),让微信重发
closeOrder调用后微信返回“订单不存在”订单已支付或已关闭SELECT * FROM base_pay_order WHERE out_trade_no = 'xxx';关单前必须校验status=0,否则直接返回失败

5.2 我踩过的三个血泪坑

坑一:微信回调的“静默重发”机制

微信文档写的是“通知失败后,会间隔一段时间重试,直到成功或超时”。但实际是:只要你的notify接口返回的不是严格符合要求的SUCCESS XML,它就会无限重试,且重试间隔越来越短(1m→2m→5m→15m→30m)。我们曾因buildSuccessXml()里多了一个空格,导致微信连续重发72小时,日志刷爆磁盘。解决方案:在notify方法开头加if (xml.length() > 10240) { log.warn("XML过大,疑似攻击"); return fail; },限制最大长度;并在返回前用xml.trim().equals("<xml><return_code><![CDATA[SUCCESS]]></return_code><return_msg><![CDATA[OK]]></return_msg></xml>")做终极校验。

坑二:时区导致的timeStamp失效

timeStamp必须是当前北京时间的秒级时间戳(10位数字)。我们有个项目部署在UTC时区的海外服务器,System.currentTimeMillis()/1000返回的是UTC时间戳,比北京时间小8小时,导致微信认为timeStamp过期(微信要求timeStamp与当前时间误差不超过15分钟)。解决方案:强制用Instant.now().atZone(ZoneId.of("Asia/Shanghai")).toEpochSecond()获取东八区时间戳。

坑三:MySQL的utf8mb4与微信商品描述

微信回调里的body字段可能含emoji(如🍎),MySQL默认utf8只支持3字节UTF-8,存储emoji会变成??,导致验签失败(因为验签时用的是原始XML里的body,而DB里存的是乱码)。解决方案:建表时指定DEFAULT CHARSET=utf8mb4,且JDBC URL加?useUnicode=true&characterEncoding=utf8mb4

5.3 性能优化与扩展建议

  • 连接池调优:OkHttp默认最大连接数5,对于QPS>100的系统不够。建议设为maxConnections=50,并开启connectionPool共享。
  • 签名缓存apiKey不变,nonce_str每次不同,但appId+mch_id+trade_type等固定参数的签名前缀可缓存。我们用Caffeine缓存Map<String, String>的签名字符串,命中率>85%,降低CPU消耗。
  • 异步化notify:notify接口必须快速返回SUCCESS,真正的订单更新可丢进消息队列(如RabbitMQ)。但要注意:微信重试机制要求你必须在返回SUCCESS前确保“至少一次”处理成功,所以队列必须配置ack模式,且消费者失败时要重试。
  • V3接口迁移准备:微信已宣布V2接口逐步停用。本方案预留了扩展点:WxPayConfig里可加v3Secret字段,WxPay类里预留createOrderV3()方法,签名逻辑改为HMAC-SHA256,证书加载改为PEM格式。迁移时只需替换工具类,业务层代码几乎不动。

这套方案没有炫技的架构,只有一个个被线上流量锤炼过的细节。它不承诺“零故障”,但保证每个故障点都有迹可循、有据可查、有法可解。当你在凌晨三点盯着日志排查一笔异常订单时,你会感谢当初把notify_time字段加上,把buildSuccessXml()的空格校验写死,把apiKey的长度校验放在@PostConstruct里——这些不是代码,是经验。

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

简介:直接可用的微信小程序JSAPI支付后端方案,基于SpringBoot开发,覆盖从用户下单到支付完成的全部关键环节。提供统一下单接口(createOrder),自动接收并验签微信服务器推送的异步支付结果通知(notify),支持主动查询订单状态(queryOrder)和手动关闭未支付订单(closeOrder)。内置OkHttp封装、FastJSON解析、XML签名与验签逻辑、客户端IP获取、随机密钥生成等基础工具,附带MySQL建表SQL(base_pay_order.sql)及配套Mapper、Service、Controller代码。核心支付逻辑集中于WxPay工具类,业务方只需传入openId、商品描述、金额等必要参数即可快速接入,无需处理证书加载、签名算法、HTTPS通信等底层细节。所有配置统一管理在WxPayConfig.java中,严格遵循微信支付V2接口规范,兼容JDK 8+及SpringBoot 2.x/3.x主流版本。


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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/10 3:00:50

LangChain 里的 chatmodel.bind_tools 和 ReAct Agent

1、对比LangChain 里的 chatmodel.bind_tools 和 ReAct Agent 来解释&#xff0c;并会把“底层机制”和“怎么组合使用”的代码放在一起对比。bind_tools 是底层工具调用能力&#xff1b;ReAct 是Agent 循环策略。区别项目chatmodel.bind_tools(tools)ReAct / create_react_age…

作者头像 李华
网站建设 2026/6/10 2:59:43

全球高精度河流矢量及河流属性数据集

本数据集提供了一个基于多源数据融合改进的全球高精度河流矢量数据集。该数据集通过整合 HydroRIVERS、开源地图&#xff08;OSM&#xff09;以及全球河流地形数据集&#xff08;GRIT&#xff09;的优势&#xff0c;旨在生成空间位置更准确、河流网络更完整的全球河流分布数据。…

作者头像 李华
网站建设 2026/6/10 2:41:27

Git 设置网络代理:解决 GitHub 无法访问

# 设置 HTTP 代理 改成自己代理的本地端口 git config --global http.proxy http://127.0.0.1:10808 # 设置 HTTPS 代理 git config --global https.proxy http://127.0.0.1:10808# 列出config判断是否设置成功 git config --global --list# 取消全局代理 git config --global …

作者头像 李华
网站建设 2026/6/10 2:39:57

零基础如何挑选白名单赛事?新手避坑全攻略

绝大多数刚开始接触竞赛的家长和学生&#xff0c;都会面临同一个难题&#xff1a;白名单赛事数量繁多&#xff0c;三大类目下细分赛事多达二十余项&#xff0c;新手不知道如何根据学段、自身特长挑选适配的赛事&#xff0c;最终盲目跟风报名难度过高的项目&#xff0c;耗费大量…

作者头像 李华
网站建设 2026/6/10 2:30:08

开源 AI 工具链:SDK 设计模式与开发者体验工程

开源 AI 工具链&#xff1a;SDK 设计模式与开发者体验工程一、AI 工具链的集成困境&#xff1a;为什么开发者总在写胶水代码 在 AI 应用开发中&#xff0c;一个普遍的痛点是&#xff1a;模型能力越来越强&#xff0c;但把模型接入业务系统的工程成本却居高不下。开发者往往需要…

作者头像 李华