简介:wechatpad.zip 是一份面向 Java 后端开发者的微信 JSAPI 支付实战代码包,适合在公众号网页或移动端 H5 场景中接入微信支付,又不想从零调试签名与下单流程的开发者参考。包内共 111 个文件,以 44 个 jar 依赖、11 个 java 源码、19 个 class 编译文件与 16 个 xml 配置为主,另含 jsp 页面、properties 参数文件等,压缩包约 18.29MB;导入工程后按说明填写商户号、密钥与证书即可运行。代码围绕 WXPayUtil、WXPayRequest、WXPayConfigImpl 等核心类展开,覆盖统一支付参数封装、签名校验、请求发送与结果解析等环节,能帮助读者快速理解 JSAPI 支付的调用链路。该资源已有 4243 人学习下载,可用于对照排查配置错误、验证支付回调逻辑,也可作为企业项目接入时的参考雏形。
1. 支付按钮转了三圈就失败:wechatpad.zip 解决的正是这一段
页面上的「立即支付」点下去,转三圈,弹出chooseWXPay:fail,回头查日志,签名、参数、openid 表面上看着都对——这是公众号内做 JSAPI支付最常见的现场。wechatpad.zip 针对的正是这段链路:把网页授权取 openid、统一下单拿prepay_id、前端调起收银台、异步回调验签、订单状态回写这几步拆成独立模块,配好参数就能跑通一条完整链路,不需要你自己对着接口文档拼。它并不是一套 SDK,也不能替代官方接口,重点在于把最容易出错的签名、回调、幂等这几块固化成能读、能改、也能断点调试的代码。适合已经拿到公众号配置、要在一个 H5 页面里把支付真正跑起来的前后端开发者,也适合接手历史代码、订单状态偶发对不上的维护者。
2. 把调用链拆开:五次交接与一个签名函数
先把链路拆开来。JSAPI支付出问题,多半是某一次交接的输入不对,后端代码本身往往没问题。后端拼参时少了个openid,或者前端调起时timeStamp用了毫秒,都会让整条链路停在中途,而且报错信息往往指向不到真正的原因。
2.1 从点击到入账的五次交接
第一次交接是网页授权。用户从公众号菜单或分享链接进入 H5,页面先跳授权地址拿code,后端用code换openid。这一步的坑在于 scope:只取支付用,snsapi_base就够,静默授权不弹窗;如果业务还要昵称头像才用snsapi_userinfo。很多人图省事两个都写上,结果每次支付前弹一次授权框,转化率白白掉一截。
第二次交接是统一下单。后端拿openid、金额、订单号、回调地址调下单接口,trade_type固定JSAPI,返回里最重要的是prepay_id。这个值有效期两小时,且一单一次,别缓存起来给多个订单复用——复用会直接报单号已存在或参数错误。
第三次交接是二次签名。prepay_id不能直接丢给前端,前端调起收银台需要一组appId / timeStamp / nonceStr / package / signType / paySign,其中paySign是后端用同一把密钥对前五个字段重新签的。这一步是整条链路里最容易翻车的地方,因为参与签名的字段和后端下单时完全不是同一组。
第四次交接是前端调起。页面通过WeixinJSBridge.invoke('getBrandWCPayRequest', ...)唤起收银台。这里有安卓和 iOS 的差异:安卓上WeixinJSBridge可能还没注入,直接调用会报WeixinJSBridge is not defined,得监听WeixinJSBridgeReady事件再执行。
第五次交接是异步回调。用户付完钱,支付网关往notify_urlPOST 一段报文,后端验签、核对金额、改订单状态,然后回一个成功响应。注意:前端res.err_msg === 'get_brand_wcpay_request:ok'只代表用户在收银台点了确认,不代表钱到账;真正算数的只有这条异步回调,加上主动查单兜底。
把五次交接画成一张表,排查时对着看,基本能定位到是哪一段断的:
| 交接点 | 输入 | 输出 | 典型失败表现 |
|---|---|---|---|
| 网页授权 | code、appid、secret | openid | 授权回调页 400、openid 为空 |
| 统一下单 | openid、金额、单号 | prepay_id | 签名错误、单号重复 |
| 二次签名 | prepay_id、时间戳 | paySign | 调起即 fail,无收银台 |
| 前端调起 | 六个字段 | 用户确认 | 白屏、err_msg 非 ok |
| 异步回调 | 加密报文 | 订单状态 | 已扣款仍待支付 |
2.2 签名只是排序拼接,为什么老是错
签名机制本身并不复杂:把所有非空参数按字段名 ASCII 升序排列,拼成k1=v1&k2=v2...的形式,末尾追加&key=商户密钥,再做 MD5 转大写。它属于校验手段,不涉及加密,所以能反推、能本地复现,也应该在本地复现。
错的地方集中在四个细节上。第一,空值到底参不参与。约定是null、空字符串不参与签名,但有些语言里0和"0"处理不一样,金额字段传了0会被误判为空丢掉,签名就对不上。第二,sign字段本身必须剔除,二次签名时容易忘了把自己上一次算出来的sign剔掉。第三,编码。中文商品名走 UTF-8,如果某处用了 GBK,签名串就变了。第四,MD5 和 HMAC-SHA256 的拼法不同:MD5 是把&key=拼在串尾一起哈希,HMAC-SHA256 是把待签串本身做消息、密钥单独当 HMAC key,这一点抄错的人特别多。
还有一个容易忽略的:sign_type字段。统一下单时如果显式传了sign_type,它就要参与签名;不传则默认 MD5,不参与。前端二次签名里signType是必传字段,也必然参与。两边规则不一样,很多人一套函数到处用,结果后端过、前端不过。
2.3 手写一遍签名函数,把黑匣子打开
我一般先写一个纯粹的签名函数,脱离业务单独测,用官方文档里的示例参数对一遍,对上了再接下单逻辑。这样出问题时能立刻分清是签名错还是参数错。
import hashlib, hmac, random, string def nonce_str(length=32): # 随机串,字母数字混合即可,长度别超过 32 return ''.join(random.choices(string.ascii_letters + string.digits, k=length)) def build_sign(params, api_key, sign_type='MD5'): # 1. 剔除 sign 本身与空值,None 和 '' 都不参与 items = [(k, str(v)) for k, v in params.items() if k != 'sign' and v is not None and str(v) != ''] # 2. 按字段名 ASCII 升序 items.sort(key=lambda kv: kv[0]) # 3. 拼成 k=v&k=v 形式 raw = '&'.join(f'{k}={v}' for k, v in items) if sign_type == 'HMAC-SHA256': # HMAC 模式:待签串不含 &key=,密钥单独作为 HMAC key digest = hmac.new(api_key.encode('utf-8'), raw.encode('utf-8'), hashlib.sha256).hexdigest() else: # MD5 模式:&key= 拼在串尾一起哈希 digest = hashlib.md5((raw + f'&key={api_key}').encode('utf-8')).hexdigest() return digest.upper(), raw这段代码有三个地方值得说明。str(v)强制转字符串,是为了避免整型100和字符串"100"拼出来一样但比较时出岔子;items.sort用的是字段名元组的第一个元素,Python 的字符串比较本身就是按码位升序,等价于 ASCII 升序;返回值同时给出签名串raw,排查时把它打进日志,和对方给的示例串逐字符比对,比盯着最终签名值猜要快得多。
拿到签名后调统一下单:
def unified_order(order, cfg): params = { 'appid': cfg['appid'], 'mch_id': cfg['mch_id'], 'nonce_str': nonce_str(), 'body': order['body'][:128], # 商品描述,超长会截断报错 'out_trade_no': order['out_trade_no'], # 商户订单号,唯一 'total_fee': int(order['amount_fen']), # 单位分,整数 'spbill_create_ip': order['client_ip'], 'notify_url': cfg['notify_url'], 'trade_type': 'JSAPI', 'openid': order['openid'], } params['sign'], raw = build_sign(params, cfg['api_key']) log.debug('unified raw=%s sign=%s', raw, params['sign']) resp = post_xml(cfg['unified_order_url'], dict_to_xml(params)) data = xml_to_dict(resp) # 两层判断:通信层 return_code,业务层 result_code if data.get('return_code') != 'SUCCESS': raise PaymentError('通信失败: %s' % data.get('return_msg')) if data.get('result_code') != 'SUCCESS': raise PaymentError('业务失败: %s / %s' % (data.get('err_code'), data.get('err_code_des'))) return data['prepay_id']return_code和result_code是两层状态,通信层失败通常是网络或证书问题,业务层失败才是参数问题,两者要分开抛异常,否则线上只看得到「支付失败」四个字,无从下手。spbill_create_ip传用户真实出口 IP,不要传内网地址,部分风控场景会校验。total_fee必须整数,传浮点会被拒。
3. 拆包与落地:wechatpad.zip 的模块边界与两段关键代码
打开压缩包,先别急着跑。花十分钟把模块边界看明白,后面改配置、换商户号、加业务字段的时候,才不会牵一发动全身。
3.1 目录结构与各模块职责
这类工具包通常按「配置 / 签名 / 下单 / 回调 / 前端 / 工具」切分,我这边的习惯是按下面的职责划分来读:
| 模块 | 职责 | 改动频率 |
|---|---|---|
| config | appid、mch_id、密钥、回调地址 | 部署时改一次 |
| signer | 签名与验签,纯函数 | 基本不动 |
| order | 下单、查单、关单 | 加业务字段时动 |
| notify | 回调验签、金额核对、状态机 | 加幂等逻辑时动 |
| web | 前端调起与轮询 | 换 UI 时动 |
| utils | XML 序列化、HTTP 封装、日志 | 不动 |
读源码的顺序建议反过来:先看web里前端怎么调起,知道要哪些字段;再看order怎么产出这些字段;最后回到signer核对签名。这样读,每一步都有明确目的,不会读完一遍脑子里只剩一堆文件名。
3.2 后端初始化:配置项、密钥与环境隔离
配置这块最容易出事的是密钥管理。正式环境和测试环境用两套商户号、两把密钥,配置文件分开放,别用一个config.py加注释切换——上线那天忘了注释,钱就进了测试账户。
| 配置项 | 说明 | 常见错误 |
|---|---|---|
| appid | 公众号唯一标识 | 和多号商户混用 |
| mch_id | 商户号 | 复制时带了空格 |
| api_key | 签名密钥 | 复制时夹带换行 |
| notify_url | 回调地址 | 写成内网或 localhost |
| 证书路径 | 退款/撤销才用 | 支付阶段可不配 |
| 授权域名 | 网页授权回调白名单 | 域名没备案或不一致 |
密钥从环境变量读,代码里不落明文:
import os CFG = { 'appid': os.environ['PAY_APPID'], 'mch_id': os.environ['PAY_MCH_ID'], 'api_key': os.environ['PAY_API_KEY'].strip(), # strip 防换行 'notify_url': os.environ['PAY_NOTIFY_URL'], 'unified_order_url': os.environ['PAY_UNIFIED_URL'], } assert len(CFG['api_key']) in (16, 32), '密钥长度不对,八成为了好看改了值'strip()这行很关键。密钥从控制台复制到环境变量,末尾带一个不可见换行的概率不算低,签名就会一直报错,但打印出来的密钥看着一模一样。用长度断言兜一层,能省下两小时。
3.3 前端调起:时间戳、package 和 paySign 三件套
后端拿到prepay_id后,做二次签名下发前端:
import time def build_pay_params(prepay_id, cfg): params = { 'appId': cfg['appid'], 'timeStamp': str(int(time.time())), # 秒级,别用毫秒 'nonceStr': nonce_str(), 'package': f'prepay_id={prepay_id}', 'signType': 'MD5', } # 注意:这五个字段全部参与签名,字段名大小写要和下发的一致 params['paySign'], raw = build_sign(params, cfg['api_key']) log.debug('pay raw=%s', raw) return paramstimeStamp是秒级十位数字,用毫秒会直接调不起来。package的值是prepay_id=xxx整串,不是只拼值。appId首字母大写,和下单时用的appid全小写不同——这是很多人踩过的坑,因为两处命名风格不一致,复制粘贴时改了大小写,签名就废了。
前端接收这组参数并调用:
function invokePay(payParams, outTradeNo) { function doPay() { WeixinJSBridge.invoke('getBrandWCPayRequest', { appId: payParams.appId, timeStamp: payParams.timeStamp, nonceStr: payParams.nonceStr, package: payParams.package, signType: payParams.signType, paySign: payParams.paySign }, function (res) { if (res.err_msg === 'get_brand_wcpay_request:ok') { // 用户确认支付,但不等于到账,必须轮询后端订单状态 pollOrderStatus(outTradeNo); } else if (res.err_msg === 'get_brand_wcpay_request:cancel') { showTip('已取消支付'); } else { reportError(res.err_msg); // 把原始 err_msg 上报,别自己翻译 } }); } // 安卓上 WeixinJSBridge 可能未注入,需要等就绪事件 if (typeof WeixinJSBridge === 'undefined') { document.addEventListener('WeixinJSBridgeReady', doPay, false); } else { doPay(); } }err_msg原样上报这件事,值得单独强调。很多前端代码把它翻译成「支付失败」,线上出问题时完全没法判断是用户取消、参数错误还是浏览器环境问题。原始字符串里有:fail、:cancel、:error等后缀,是排查的第一手线索。
4. 参数怎么设:金额、单号、回调地址的边界
链路跑通只是开始。真正上线后,出问题的往往在于参数取值上的边界:金额差一分、订单号撞车、回调地址不可达,这些问题不会在测试环境出现,因为测试环境的流量太小了。
4.1 total_fee 的单位与精度
金额全程用整数分,从数据库字段到接口参数到日志,中间任何一环出现浮点都可能丢精度。前端展示用元,提交前乘 100 取整;后端接收后立刻转int,不做四舍五入,只做截断并校验,防止有人传0.001元。
def to_fen(amount_yuan): # 用 Decimal 而非 float,避免 0.1 + 0.2 这类问题 from decimal import Decimal, ROUND_DOWN d = Decimal(str(amount_yuan)) if d <= 0: raise ValueError('金额必须大于 0') fen = int((d * 100).to_integral_value(rounding=ROUND_DOWN)) if fen != d * 100: raise ValueError('金额最多两位小数') return fen用Decimal(str(x))而不是Decimal(x),因为Decimal(0.1)拿到的是0.1000000000000000055...。校验「乘 100 后是否为整数」这一步,是为了拦住三位小数,避免出现用户看到 9.99 元、实际付 9.99 元但订单记 9.98 元的情况。
4.2 out_trade_no 的生成规则
订单号要求 32 字符以内、只含字母数字和部分符号、全局唯一。常见的错误写法是直接用数据库自增 ID,一是暴露订单量,二是分库后不唯一;另一种是纯时间戳,高并发下同一毫秒会撞。
我的做法是「业务前缀 + 日期 + 分片位 + 随机串」:
import time, random def gen_trade_no(biz='P', shard=0): ts = time.strftime('%Y%m%d%H%M%S') rand = ''.join(random.choices(string.digits, k=6)) return f'{biz}{ts}{shard:02d}{rand}' # 形如 P20240512103015010123456前缀让人一眼看出业务线,日期段方便按天归档和对账,分片位为将来分库留位,随机数兜住同一秒并发。生成后先落库再做唯一索引,用数据库的唯一约束保证不重复;靠代码判断「存在就重试」,在并发下不成立。
4.3 notify_url、报文与验签顺序
回调地址必须是公网可达的完整 URL,用 https,不带自定义 query 参数——部分链路上 query 会被丢弃,导致回调进来后认不出是哪个环境。路径里带上环境标识更稳妥,比如/pay/notify/prod。
回调处理的顺序不能乱:先验签,再校验商户号和 appid,然后核对金额,最后才改状态。顺序反了会出现「未验签就改单」的严重问题。
def handle_notify(raw_xml, cfg): data = xml_to_dict(raw_xml) sign = data.pop('sign', '') expect, raw = build_sign(data, cfg['api_key']) if not hmac.compare_digest(expect, sign): log.warning('notify sign mismatch raw=%s', raw) return xml_resp('FAIL', '签名校验失败') if data.get('mch_id') != cfg['mch_id'] or data.get('appid') != cfg['appid']: return xml_resp('FAIL', '商户信息不匹配') order = load_order(data['out_trade_no']) if int(data['total_fee']) != int(order['amount_fen']): log.error('amount mismatch order=%s notify=%s', order['amount_fen'], data['total_fee']) return xml_resp('FAIL', '金额不一致') if data.get('return_code') == 'SUCCESS' and data.get('result_code') == 'SUCCESS': mark_paid_once(data['out_trade_no'], data['transaction_id']) return xml_resp('SUCCESS', 'OK')compare_digest用常量时间比较,避免时序侧信道,虽然在小系统里不算必须,但成本为零,没理由不用。校验金额用通知里的total_fee和本地订单的原始金额比,不要用cash_fee——那是用户实际付款,有优惠券时和订单金额不同。响应体必须是 XML 且首字段为return_code,返回纯文本success会被当成失败并触发重试。
5. 避坑:五类线上翻车现场与排查路径
这一节是血泪经验堆出来的。下面五类问题,覆盖了我见过和踩过的绝大多数支付故障,每条按「现象 → 原因 → 解决」写,照着对症状找。
5.1 签名参数看着一样,就是报签名错误
现象:后端日志里打出来的签名串,和文档示例逐字符比,肉眼看不出差异,接口一直返回「签名错误」。
原因:九成是隐藏字符或编码问题。密钥末尾带换行、商品名里有全角空格、某个字段是从 JSON 里取的null被序列化成字符串"null"参与签名、或者某语言把Boolean序列化成true而对方期待1。
解决:把签名串按字节打出来,而不是按字符串打,repr()或xxd看一遍;密钥加载时strip()并断言长度;对所有参与签名的值统一str()并显式处理None;中文商品名在插入数据库前就统一 UTF-8。我习惯在签名函数里加一行「原始串」日志,只在 debug 级别开,出问题时临时调级别,比事后猜快得多。
5.2 用户说付了钱,订单还是待支付
现象:用户截图显示扣款成功,后台订单状态一直停在待支付,几分钟后用户来投诉。
原因:三种可能。一是前端把err_msg === 'ok'当成支付成功直接改单,用户点了确认但实际支付失败或中途退出;二是notify_url不可达,网关重试几次后放弃;三是回调处理超时,网关等不到响应体,认为失败。
解决:前端永远不能作为支付成功的依据,只做「跳转到结果页并轮询」。后端必须实现主动查单兜底:前端轮询到一定次数仍未支付成功,就触发一次查单接口;同时搞一个定时任务,把超过 N 分钟仍待支付的订单批量查一遍。notify_url上线前用外网工具实测一遍,确认从公网能打到你的服务,且响应在超时时间内返回。回调处理里不要做耗时操作,改单后立刻返回,发消息、发邮件这类事情异步做。
5.3 回调重试把库存扣了两次
现象:一个订单扣了两次库存,或者用户收到了两条发货通知。
原因:异步回调不保证只到一次,网关在没收到成功响应时会按策略重试,同一笔交易可能进来三四次。代码里如果是「收到回调就扣库存」,重试即重复扣。
解决:幂等键用out_trade_no,在订单表上做状态机 CAS:UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='pending',影响行数为 1 才继续往下走,为 0 说明已经被处理过,直接返回成功。库存扣减、积分发放、消息推送全部放在 CAS 成功之后。另外建议单独建一张支付流水表,以transaction_id做唯一索引,重试插入会直接冲突,等于多一道保险。
5.4 换个手机就调不起来
现象:开发同学的手机上一直正常,测试同事的安卓机点了没反应,或者弹出「参数错误」。
原因:安卓上WeixinJSBridge注入时机不确定,页面加载完就去调,对象还不存在;另外多公众号场景下openid和appid不匹配,A 号拿到的 openid 拿去 B 号下单,必然失败;还有授权 scope 用了snsapi_base却去取昵称,接口返回空。
解决:调起前判断typeof WeixinJSBridge === 'undefined',监听就绪事件;openid 与 appid 在数据库里成对存储,下单前断言一致性;授权 scope 按实际需要选,支付场景只用 base。跨端测试至少要覆盖 iOS 某版本、安卓某品牌、以及内置浏览器和外部浏览器各一次,别只在自己机器上验收。
5.5 金额差一分钱
现象:对账时发现订单金额和实际收款差 0.01 元,量不大但每天都有几笔。
原因:前端用浮点算价(比如打折、满减),9.9 * 0.9得到8.91还是8.909999取决于实现,取整方式不同就差一分;或者后端把total_fee从元转分时用了四舍五入,而前端展示用了截断。
解决:所有金额计算统一在后端用整数分完成,前端只负责展示和把用户选择的商品 ID 传上来,价格以后端为准。凡是有折扣、优惠券的场景,total_fee用订单原价,实付金额单独存字段并和回调里的cash_fee核对。把这个约定写进接口文档,前端不要自己算钱。
6. 进阶:把支付状态做成可回放的事件流
上线三个月后我改了一版设计,把订单状态从「一个字段」改成「一张事件表」,起因是一次对账对不上,翻遍日志也说不清那笔订单到底经历了什么。做法很简单:每次收到回调、每次主动查单、每次前端轮询到状态变化,都往pay_events表插一条记录,字段大致是这些:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| out_trade_no | varchar(32) | 商户订单号,索引 |
| source | varchar(16) | notify / query / poll |
| from_status | varchar(16) | 变更前状态 |
| to_status | varchar(16) | 变更后状态 |
| raw_digest | varchar(64) | 原始报文摘要,便于比对 |
| created_at | datetime | 落库时间 |
有了这张表,任何一笔订单的历史都能按时间轴回放:几点几分收到第一次回调、验签是否通过、金额核对结果、状态从 pending 变成 paid 是哪条事件触发的。对账时不用再猜,直接SELECT * FROM pay_events WHERE out_trade_no = ? ORDER BY id。
配套的兜底查单我写成一个可重入的任务:
def reconcile_pending(limit=200): # 捞出超过 5 分钟仍是 pending 的订单,主动查一次 orders = db.query( "SELECT out_trade_no FROM orders " "WHERE status='pending' AND created_at < NOW() - INTERVAL 5 MINUTE " "LIMIT %s", limit) for o in orders: try: result = query_order(o['out_trade_no']) if result.get('trade_state') == 'SUCCESS': # 复用和回调完全相同的入账函数,保证两条路径行为一致 mark_paid_once(o['out_trade_no'], result['transaction_id']) record_event(o['out_trade_no'], 'query', 'pending', 'paid') except Exception as e: log.exception('reconcile failed %s', o['out_trade_no'])这里有个关键点:主动查单和异步回调必须走同一个入账函数mark_paid_once,不要各写一套。两条路径行为不一致,是很多「偶发重复发货」的根源。mark_paid_once内部用 CAS 加唯一索引双保险,谁先到谁生效,后到的直接返回成功。
另外值得做的一件事是把raw_digest存下来。回调报文里的字段可能随接口演进增加,全量存原文会越来越大,存个摘要加关键字段就够定位问题。真需要原文时,网关侧一般也能查到,配合摘要比对得上就行。
从那以后我每次接支付,都会先把事件表和幂等函数写完,再去写业务逻辑——顺序反了,后面补这两块的成本要高得多。希望帮到你。
本文还有配套的精品资源,点击获取