简介:面向需要对接主流支付平台的后端开发者,围绕Java服务器端微信、支付宝支付及退款集成展开。内容梳理统一下单、签名生成与验签、HTTP请求封装、前端调起字段返回、退款接口调用及回调处理等关键环节,并给出WXPay与Alipay工具类中的核心代码片段,涵盖参数构造、XML/JSON解析、异常处理、事务一致性、日志监控与安全设计思路,覆盖微信统一下单、支付宝预支付、退款与回调通知等场景。强调HTTPS通信、敏感信息不落库等要点,帮助读者构建稳定、安全且易扩展的支付服务。压缩包为1个PDF文件,体积约68KB,便于在移动端或电脑端查阅,目前已吸引396人学习。整体内容紧凑实用,适合已有Java基础、希望快速掌握微信支付宝支付退款实现细节的开发者参考。
1. Java服务端接住微信支付和支付宝支付:从下单到退款的一条完整链路
上周我一同事在电商项目里接支付,前后折腾了四天,最后卡在退款回调上:钱退了,订单状态没变,用户投诉到客服。他用的就是一套Java服务端微信支付、支付宝支付的实现,核心逻辑没问题,但边界条件没处理干净。这个资源说白了就是一套能落地的服务端代码:微信支付、支付宝支付的下单、异步通知验签、原路退款、退款回调、订单状态联动,整条支付闭环都在里面。适合两类人:一是Java后端刚接到支付需求,想把两个渠道一次打通的新手;二是已经接过多家支付、想快速对照参数和踩坑点的老手。我拆了一遍,把实现思路、参数来源和容易翻车的位置全都展开讲。
2. 支付集成的地基:商户参数、密钥与回调地址的配置约定
支付集成第一步不是写代码,是把渠道参数理清楚。很多人在这一步就埋下隐患:微信回调验签失败、支付宝退款金额对不上,多半是参数拿错或放错位置。
2.1 参数清单:这些配置项分别从哪个后台拿
微信支付和支付宝的参数体系完全不一样,但落地的原则相同:能放配置文件的就不硬编码,能按环境隔离的就别混着用。我拆这套资源时先列了一张参数清单,照着填就不会漏。
| 渠道 | 参数 | 获取位置 | 用途说明 |
|---|---|---|---|
| 微信支付 | mchid(商户号) | 微信商户平台-账户中心 | 下单、退款请求的商户标识 |
| 微信支付 | appid(应用ID) | 微信开放平台/公众号后台 | 绑定App或小程序的凭证 |
| 微信支付 | api v3 key(APIv3密钥) | 商户平台-API安全 | 解密回调敏感信息和部分签名场景 |
| 微信支付 | 商户私钥 apiclient_key.pem | 商户平台-API安全生成 | 请求签名,必须保密 |
| 微信支付 | 平台证书序列号 | 下载平台证书后解析 | 回调验签和响应验签用 |
| 支付宝 | appId(应用ID) | 支付宝开放平台-应用详情 | 下单、退款请求的应用标识 |
| 支付宝 | 应用私钥 | 开放平台工具生成 | 请求签名用 |
| 支付宝 | 支付宝公钥 | 开放平台-应用详情 | 异步通知验签用 |
| 支付宝 | 网关地址 | 开放平台文档 | 沙箱与生产两个网关,必须分开 |
这里最容易被忽略的是微信支付「同一个小程序/App可以绑定多个商户号」的对应关系。资源里一般会预留 multi-merchant 的配置结构,也就是把appid + mchid作为一组,后面下单和退款都基于这一组来取参数。真实项目里一个平台可能同时服务多个子商户,不做好分组,后续分账和对账会非常痛苦。
2.2 证书与密钥的存放约定
证书和密钥的存放方式,直接影响本地开发和生产部署的差异。我见过直接把apiclient_key.pem塞进src/main/resources就提交进 Git 仓库的,那等于把线上密钥公开了。
- 配置目录要按环境分离,比如
resources/cert/dev/和resources/cert/prod/,生产证书不进开发环境 - 微信商户私钥通常命名为
apiclient_key.pem,支付宝私钥则是 PKCS8 格式的文本串,以-----BEGIN PRIVATE KEY-----开头 - 支付宝公钥在配置里要和应用私钥配对放,沙箱环境下必须用沙箱公钥,不能复用生产的
- 建议启动时增加一个配置检查方法,缺失私钥或证书直接抛异常,不要让服务在缺参数的状态下假启动
这套资源里一般会提供一个WxPayConfig和AlipayConfig的加载类,把上面的参数从application.yml中读取并初始化。我习惯在加载完后打印一段脱敏日志,只显示商户号和序列号后四位,方便排查环境切换问题。
2.3 第一笔交易:服务端下单接口的完整链路
下单接口是支付流程的入口。以微信支付V3的 Native 下单为例,服务端要做的事是:收到前端请求 → 生成订单号 → 组装支付参数 → 调微信下单接口 → 拿到code_url返回给前端生成二维码。
public Map<String, Object> createNativeOrder(OrderCreateRequest req) { // 1. 订单幂等检查:同一笔业务订单不能重复创建支付单 PayOrder payOrder = payOrderMapper.selectByBusinessNo(req.getBusinessNo()); if (payOrder != null && "PENDING".equals(payOrder.getStatus())) { throw new BizException("订单已存在待支付记录"); } // 2. 落库生成内部支付单,状态为待支付 String outTradeNo = generateOrderNo(); payOrder.setOutTradeNo(outTradeNo); payOrder.setStatus("PENDING"); payOrderMapper.insert(payOrder); // 3. 组装微信下单参数 Map<String, Object> params = new HashMap<>(); params.put("appid", config.getAppid()); params.put("mchid", config.getMchid()); params.put("description", req.getProductName()); params.put("out_trade_no", outTradeNo); params.put("notify_url", config.getNotifyUrl()); params.put("amount", Map.of("total", req.getAmountInFen(), "currency", "CNY")); // 4. 调用微信V3下单接口,返回 code_url String codeUrl = wxPayClient.postJson("/v3/pay/transactions/native", params); return Map.of("codeUrl", codeUrl, "outTradeNo", outTradeNo); }这段代码里最关键的是幂等检查和金额单位。幂等检查防止用户重复点击下单生成多笔支付记录;金额单位则必须保持一致,微信支付V3接口里total的单位是分,不是元。如果项目里数据库存的是元,这里要做一次换算,我一般写死为BigDecimal乘法和intValue()转换,避免浮点精度问题。
支付宝的下单方式和微信不同,它返回的是一段会自动跳转的页面代码或表单,服务端只需要把收银台地址或表单字符串返回给前端。下单逻辑的骨架是一样的:先落库、再调渠道接口,但支付宝的out_trade_no可以直接用,不需要像微信那样额外拆出out_trade_no和transaction_id两个概念。
3. 微信支付V3与支付宝的签名逻辑:同一笔交易两套做法
下单接口能跑通,紧接着就是回调处理。回调是支付业务里最容易出 bug 的地方,因为两个渠道的签名机制差异很大,不能靠着「差不多」的想法来写。
3.1 微信支付V3:商户私钥签名请求,平台证书验签回调
微信支付V3 的签名逻辑是:所有请求用商户私钥做 RSA-SHA256 签名,请求头带上Authorization;收到回调时,微信用平台私钥签名,商户用平台证书验签。这种双向签名设计比老版 V2 的 MD5 加盐安全得多,但代价是参数多了一套。
验签的核心不是自己实现 RSA 算法,而是正确加载平台证书并调用 SDK 的方法。这套资源里一般会封装一个payNotifyHandler,处理逻辑是这样:
public String handleWxNotify(String body, Map<String, String> headers) { // 1. 先验签,验签失败说明回调可能被篡改或证书不匹配 boolean verify = wxPayClient.verifyNotifySign(headers.get("Wechatpay-Signature"), body); if (!verify) { return "FAIL"; // 微信要求返回 FAIL,否则会持续重试 } // 2. 验签通过后再解密,V3 回调的敏感字段是 AES-256-GCM 加密的 String plaintext = wxPayClient.decryptNotify(body); JSONObject data = JSONObject.parseObject(plaintext); // 3. 处理业务:只有金额一致且订单未处理时才更新状态 PayOrder payOrder = payOrderMapper.selectByOutTradeNo(data.getString("out_trade_no")); if (payOrder == null) { return "FAIL"; } String tradeState = data.getString("trade_state"); if ("SUCCESS".equals(tradeState) && "PENDING".equals(payOrder.getStatus())) { payOrder.setStatus("PAID"); payOrder.setTransactionId(data.getString("transaction_id")); payOrderMapper.updateById(payOrder); } return "SUCCESS"; // 处理完成返回 SUCCESS,微信停止重试 }这里要特别说明两点。第一,微信V3回调的out_trade_no是在解密之后的 JSON 里,不是在请求头里,不能直接拿原始 body 里的字段去查订单。第二,验签时需要用到请求头里的Wechatpay-Signature,如果前面经过了 Nginx 转发,必须让 Nginx 把原始头透传过来,否则验签一定失败。这两个坑我在实际项目里都踩过。
3.2 支付宝:应用私钥签名、平台公钥验签与参数排序
支付宝的签名机制相对直白一些,但参数排序规则很死板:所有参数按 key 的 ASCII 码升序排列,拼成字符串,做 RSA2 签名。异步通知验签时,同样按这个规则把参数重排,然后用支付宝公钥验签。
public boolean verifyAlipayNotify(Map<String, String> params) { // 1. 剔除掉 sign 和 sign_type 两个字段,其余参数参与验签 Map<String, String> signParams = new TreeMap<>(params); signParams.remove("sign"); signParams.remove("sign_type"); // 2. 拼接原始字符串并调用 SDK 验签 String content = AlipaySignature.getSignContent(signParams); return AlipaySignature.rsaCheckV1(content, config.getAlipayPublicKey(), "UTF-8", "RSA2"); }注意这一步是最容易翻车的:有的实现里把sign_type也拿去参与拼接,导致验签永远失败。rsaCheckV1默认会把空值参数也拼进去,所以TreeMap排序后直接交给getSignContent就行,不要在外部手工拼接字符串。
既然两个渠道的异步通知都要先验签再处理业务,控制器的写法就统一成:先验签,失败返回特定标识,成功处理业务后返回约定内容。微信返回SUCCESS/FAIL,支付宝返回success/fail,字符串不一致也会导致回调被重复发送。
3.3 两个渠道的差异对照表
模型建对,上线后能少一半问题。下面是实操中最容易踩的差异点,我建议整理成团队文档:
| 对比项 | 微信支付V3 | 支付宝 |
|---|---|---|
| 下单方式 | JSAPI/Native/H5 分开接口 | 统一接口 + 返回跳转表单 |
| 金额单位 | 分(整数) | 元(保留两位小数) |
| 异步通知协议 | 平台私钥加密 + 平台证书验签 | 参数重排 + 公钥验签 |
| 通知返回 | SUCCESS/FAIL | success/fail |
| 接受重复通知 | 会重试最多 24 小时 | 会重试,间隔逐渐拉大 |
| 同步返回结果 | 只返回 code_url,无支付结果 | 同步页面跳转,不代表支付成功 |
金额单位这一行值得单独强调:微信传分、支付宝传元,如果直接在同一个订单服务里复用同一个金额字段,必须明确保存的基准单位是分。这属于那种「上线前看着没问题,上线后第一笔退款就出问题」的坑。
4. 退款功能实现:原路退回、部分退与状态机联动
支付做完,退款是第二个大头。退款比支付更容易出问题,因为有大量边界:能不能部分退、退款单号怎么生成、要不要等渠道结果、退款失败怎么处理。
4.1 微信退款:原路退回与部分退实现
微信支付V3的退款接口支持全额退和多次部分退,只要累计退款金额不超过订单金额即可。和下单一样,退款也是异步的:提交退款请求后,微信先受理,退款结果通过单独的退款回调通知服务端。
public void refundByOutTradeNo(String outTradeNo, int refundAmount, String refundReason) { // 1. 校验订单状态:只有 PAID 的订单才能发起退款 PayOrder order = payOrderMapper.selectByOutTradeNo(outTradeNo); if (order == null || !"PAID".equals(order.getStatus())) { throw new BizException("订单状态不允许退款"); } // 2. 累计已退金额不能超过订单金额 int refunded = payOrderMapper.sumRefundedAmount(outTradeNo); int maxRefund = order.getAmountInFen() - refunded; if (refundAmount > maxRefund) { throw new BizException("退款金额超出可退金额"); } // 3. 生成退款单号,这个号在微信侧是幂等标识 String outRefundNo = "RF" + System.currentTimeMillis() + RandomUtil.randomNumbers(4); Map<String, Object> params = new HashMap<>(); params.put("out_trade_no", outTradeNo); params.put("out_refund_no", outRefundNo); params.put("reason", refundReason); params.put("amount", Map.of( "refund", refundAmount, "total", order.getAmountInFen(), "currency", "CNY" )); // 4. 先落退款记录表,再调微信接口,保证本地有据可查 RefundRecord record = new RefundRecord(); record.setOutRefundNo(outRefundNo); record.setStatus("PROCESSING"); refundRecordMapper.insert(record); wxPayClient.postJson("/v3/refund/domestic/refunds", params); }退款接口的构造点在于:out_refund_no就是幂等键,同一个退款单号提交两次,第二次会报「退款单号已存在」或返回原结果。因此我一般把退款记录先落库,再去调微信接口。如果网络超时导致结果未知,可以拿同一个out_refund_no查退款状态或重新提交,微信能识别为同一笔请求。
微信的退款结果是通过异步通知下发的,不是退款接口同步返回。所以服务端必须有一个监听退款通知的入口,拿到refund_status是SUCCESS才把退款记录更新为已退款,同时把订单状态改为 REFUNDED。这套资源里通常会有refund_notify_url和支付回调共用一个 controller,但验签逻辑要区分:支付回调解密出的是trade_state,退款回调解密出的是refund_status。
4.2 支付宝退款:退款单号唯一性与重复请求处理
支付宝的退款接口和微信差别挺大:它支持单笔退款和批量退款,退款请求是同步返回结果的,结果里带fund_change标记当前请求是否真正发生了资金变化。还有一个关键限制:支付宝同一笔退款请求(out_request_no)只能使用一次,不能像微信那样用同一个单号提交两次查状态。
public AlipayTradeRefundResponse refund(AlipayRefundRequest req) { // 1. 校验原订单是支付宝渠道且已支付 PayOrder order = payOrderMapper.selectByBusinessNo(req.getBusinessNo()); if (!"ALIPAY".equals(order.getChannel())) { throw new BizException("订单渠道不是支付宝"); } // 2. 组装退款参数,注意支付宝金额单位是元,不能用分直接传 AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); request.setBizContent("{" + "\"out_trade_no\":\"" + order.getOutTradeNo() + "\"," + "\"refund_amount\":\"" + order.getAmountInYuan() + "\"," + "\"out_request_no\":\"" + UUID.randomUUID().toString().replace("-", "") + "\"," + "}"); AlipayTradeRefundResponse response = alipayClient.execute(request); if (response.isSuccess()) { order.setStatus("REFUNDED"); payOrderMapper.updateById(order); } else { // 注意:同步返回失败不一定是退款失败,也可能是网络抖动 log.warn("ali refund respond code={}, msg={}", response.getCode(), response.getMsg()); } return response; }支付宝这里有个容易误判的点:execute抛出异常或者返回失败码,不代表退款一定没成功。正确做法是当结果不明确时,用out_trade_no和out_request_no去支付宝查询退款状态,而不是直接给用户提示「退款失败」。这个资源里如果封装了查询接口,建议保留并复用同一个out_request_no来查,保证幂等。
4.3 订单状态机:支付、关单、退款三态间的边界
支付和退款放在一起,最怕的就是状态乱跳:已退款订单又被支付回调更新为已支付,已关单订单又收到退款请求。要守住这个边界,核心是每个状态更新前都要做前置校验。
| 当前状态 | 允许的操作 | 触发后状态 | 关联渠道事件 |
|---|---|---|---|
| PENDING(待支付) | 支付成功回调 | PAID | 支付异步通知 trade_state=SUCCESS |
| PENDING(待支付) | 超时关单 | CLOSED | 微信关单接口/本地定时任务 |
| PAID(已支付) | 发起退款 | REFUNDING | 退款接口受理 |
| REFUNDING(退款中) | 退款成功回调 | REFUNDED | 退款异步通知 refund_status=SUCCESS |
| REFUNDING(退款中) | 退款失败回调 | PAID | 退款异步通知 refund_status=FAILED |
状态变更的代码不要写散落在各处,我建议收敛到一个方法里:updateOrderStatus(id, expectedStatus, targetStatus)。更新时带上WHERE status = expectedStatus作为乐观锁。这样即使支付回调在极端情况下重复到达,第二次更新时因为状态已经是 PAID 而不是 PENDING,更新行数为 0,也就不会把已退款订单重新刷回已支付。
5. 支付集成避坑指南:六类线上翻车现象与排查方法
支付集成的坑集中爆发在后端联调和上线初期,下面这几类是我在拆项目和实际开发中遇到最多的,按「现象 → 原因 → 解决」记录。
5.1 用户支付成功订单仍是待支付,且等很久才恢复
现象:用户明明付了钱,订单状态一两个小时还是待支付,催单客服被骂惨。
原因:支付回调没到,常见有两种。一是回调地址配错,渠道方的notify_url指向了测试环境;二是回调收到后处理失败,代码里没有 return 正确的 SUCCESS,渠道方会反复重试,重试期间不做幂等,订单状态被覆盖。
解决:先查渠道后台的「回调记录」确认是否送达;再看服务端日志里回调入口有没有进来。处理逻辑必须幂等:回调进来先查订单状态,已支付就直接返回成功,不再重复更新。通知入口要同时支持支付通知和退款通知的路由拆分。
5.2 异步通知验签失败,但同步结果正常
现象:微信支付的同步结果一切正常,一旦真正付完钱,回调验签就报错,日志里全是 verify fail。
原因:最常见的是拿错了验签公钥。微信要拿平台证书里的公钥,不是商户私钥;支付宝容易把沙箱公钥和正式公钥混用。还有一个隐蔽原因:Nginx 请求头过滤掉了Wechatpay-Signature或Wechatpay-Timestamp,导致验签数据不完整。
解决:在验签失败时把请求头和原始 body 打印到日志,先肉眼对比是哪个参数异常。如果是 Nginx 转发导致请求头丢失,在proxy_pass前显式带上proxy_set_header Wechatpay-Signature $http_wechatpay_signature;。证书类问题,去商户平台重新下载最新平台证书,替换后重启服务。
5.3 退款金额始终对不上账
现象:部分退 9.9 元,账上退了 990 分;另一笔退 0.07 元的订单账账不平。
原因:99% 是金额单位问题。微信接口要求分,支付宝接口要求元,项目库内金额可能存的是元或分,三处不一致就会出现玄学式差异。
解决:定性一个内部基准单位,全工程统一用分存储。调用微信时直接传整数分,调用支付宝时用一个单独的工具方法把分转成字符串元:longToYuan(amountInFen)返回BigDecimal.valueOf(amountInFen).divide(ONE_HUNDRED, 2, RoundingMode.HALF_UP).toPlainString()。所有渠道边界做单测,确保金额换算不丢精度。
5.4 下单接口偶发报错,但过一会又好了
现象:运行时下单接口报「无效的证书」或「签名错误」,重启后恢复,但过段时间又出现。
原因:微信平台证书是有有效期的,到期后本地缓存的旧证书私钥验签失败。支付宝应用公钥配置错误也会周期性出现这个问题。另外,微信的 APIv3 密钥如果重置过,本地配置没同步。
解决:给证书和密钥设置监控任务,有效期剩余 30 天时发告警;更换证书后优先在测试环境验证下单和回调两条链路。配置项建议用配置中心管理,换证书不用重新发版。
5.5 回调接口公网无法访问,本地联调只见 request 不见 response
现象:本地起服务,渠道方回调一直报「无法访问」,微信后台看不到通知记录。
原因:回调地址要求公网可达。本地开发没有公网 IP,或者安全组没有放行对应端口;还有一种情况是内网网关把回调请求拦截了。
解决:本地联调用内网穿透把本地端口映射成临时公网地址,跑通支付链路后马上关掉。生产环境检查安全组放行 HTTPS 443 和回调入口路径;顺便确认域名有备案,国内云厂商对未备案域名做拦截比较常见。
5.6 退款请求提交成功,但用户迟迟没收到钱
现象:服务端日志显示退款接口返回成功,但用户银行账户一两天都没到账。
原因:支付宝/微信的退款是逐级清算的,接口受理成功不代表资金已到账。微信退款回调refund_status=SUCCESS才代表渠道确认退款成功;支付宝同步返回的fund_change字段能标识本次请求是否真的发生资金转移。
解决:不要用退款接口的同步结果当最终状态。服务端只管记录退款受理成功,真正的状态迁移必须等退款异步通知。在退款记录表增加一个渠道最终状态字段,与订单状态解耦,定时任务扫描长时间未终态的退款单做渠道查询。
6. 从沙箱到生产:三个验证场景与上线前的强制检查
6.1 沙箱里必跑的链路
支付宝有沙箱环境,微信没有公开沙箱,常见做法是用一毛钱真实交易来验证全链路。我在验证这套资源时,会依次跑通三组脚本:
- 微信:创建订单 → 扫码支付 0.01 元 → 收支付回调 → 发起全额退款 → 收退款回调
- 微信:创建订单 → 支付 → 部分退一半 → 再部分退剩余 → 确认累计退款金额不超过原金额
- 支付宝:创建订单 → 沙箱账号支付 → 收异步通知 → 发起退款 → 确认
fund_change为 true
这三条跑完,支付主链路才算基本可信。注意支付宝沙箱的账号余额有限,退款测试时不要用大额,避免把测试号余额刷没。
6.2 用对账脚本验证状态一致性
线上跑的订单多了,总会有些状态错乱的脏数据。我习惯每天凌晨跑一个对账任务,把本地订单表、支付记录表、退款记录表三方比对,金额不平就告警:
-- 找出支付成功但本地状态不是 PAID/REFUNDING/REFUNDED 的订单 SELECT o.business_no, o.amount_in_fen, o.status FROM pay_order o WHERE o.status NOT IN ('PENDING', 'CLOSED') AND o.channel_transaction_id IS NULL; -- 找出退款金额累计超过订单金额的记录 SELECT r.out_trade_no, SUM(r.refund_amount) AS total_refund FROM refund_record r WHERE r.status = 'SUCCESS' GROUP BY r.out_trade_no HAVING SUM(r.refund_amount) > (SELECT amount_in_fen FROM pay_order WHERE out_trade_no = r.out_trade_no);第一条 SQL 能查出「渠道侧已扣款但本地没落支付单」的记录,多半是回调丢失后的脏数据;第二条能直接揪出退款超额的问题。把这两个查询做成定时任务,比人工对账靠谱得多。
6.3 上线前的强制检查清单
接支付功能上线,我每次都会强制自己走一遍下面这份清单,少一项都不允许发版:
- 私钥和证书不进 Git 仓库,生产证书由发布系统注入
- 支付回调入口有验签、有幂等、有状态乐观锁
- 退款单号每次生成且落库,渠道未知结果时可幂等重查
- 金额统一以分为单位,渠道边界处做显式转换并有单测覆盖
- 沙箱配置和生产配置互相隔离,启动时打印当前环境标记
这套资源的代码本身覆盖了微信支付、支付宝支付、退款和回调的核心链路,我拆完之后最大的收获是把两个渠道的差异点收在一张表里,而不是各自维护一套逻辑。从那以后我每次接支付,都强制走一遍「下单 → 回调 → 退款 → 重试」的验证链路,微信和支付宝各跑一次才算完;凡是偷懒跳过的环节,后面都在线上用事故补了课。希望这套笔记能帮你少走这几步弯路,也希望帮到你。
本文还有配套的精品资源,点击获取