Java服务端支付对接实战:微信支付+支付宝下单回调退款全解析
2026/9/7 23:25:00 网站建设 项目流程

简介:Java 服务器端接入微信、支付宝支付与退款功能的实现方法被整理成一份 PDF 资料,面向电商及线上服务后端开发者,重点解决支付流程集成中的参数签名、统一下单、返回封装等核心问题。资源包仅含 1 个 PDF 文件,压缩包大小约 68KB,轻量精炼但覆盖完整。内容通过示例代码梳理了微信支付从统一下单、签名生成、发送请求到接收 prepay_id 的完整链路,同时对比支付宝支付接口的差异,并说明退款接口的调用与异常处理方式;此外,还介绍了将支付和退款操作封装为 PayService 模块、兼顾异步处理与事务管理、日志与安全监控的设计思路。已有 396 人学习过这份资料,对于需要快速掌握 Java 支付服务端要点并落地项目的开发者来说,是一份可直接参考的实践性文档。

1. 项目总览:服务端支付能力的搭建思路

前阵子公司接了个电商项目,需求很明确:用户在小程序里下单,能用微信支付付款,电脑端网页能用支付宝扫码付款,订单异常时运营后台能一键退款。说白了就是一套标准的 Java 服务端支付模块,同时覆盖微信支付和支付宝支付两条链路。这个需求几乎每个做电商、知识付费、SaaS 系统的团队都会遇到,跟着做一遍,能把支付对接的整个套路摸清楚。

我在设计阶段把系统拆成了三个核心链路:下单、回调、退款。下单负责拉起收银台并生成支付参数;回调负责被动接收支付结果并更新订单状态;退款则是运营侧的主动操作,把用户的钱原路退回去。选 Java 服务端来做这件事,最大的好处是生态成熟,微信支付和支付宝都有官方 Java SDK,社区资料也厚,出了问题搜一圈就能找到解法。这篇文章就把我这一轮完整落地的经验梳理出来,包含核心代码实现、参数说明、常见的坑和排查思路,给准备接支付的兄弟们做个参考。

先说下整体技术选型。项目是 Spring Boot 2.7 + MyBatis-Plus + Redis + MySQL,微信支付用的是 V3 接口(小程序支付,也就是 JSAPI 支付),支付宝用的是电脑网站支付(alipay.trade.page.pay)加手机网站支付(alipay.trade.wap.pay)。为什么没有选第三方聚合支付?虽然聚合支付的接入成本低,但手续费高、结算周期长,最关键的是资金流向不够透明,遇到客诉的时候处理起来很憋屈。既然公司本身有支付宝和微信的商户号,就老老实实直连官方。另外一个原因,支付这种强资金链路,每一步都应该掌握在自己手里,出了问题可以快速定位,第三方帮忙兜底反而容易扯皮。

1.1 核心需求解析

这个项目表面上是“接入两个支付渠道”,但抽开看其实有四个核心点:一是支付参数的生成与签名(保证请求合法);二是异步通知的安全校验(防止伪造回调);三是订单状态的准确流转(防止超卖、重复发货);四是退款资金的正确性(保证原路退回且金额准确)。这四个点里,回调处理是很多新手最容易翻车的地方,后面单独拿出来细讲。

1.2 为什么必须由服务端完成支付对接

可能有刚入行的朋友会问:小程序端不是可以直接调 wx.requestPayment 吗,为什么还要服务端介入?因为支付涉及商户私钥、证书、订单金额计算、库存扣减这些敏感操作,放在客户端就是裸奔。支付宝的签名私钥、微信的商户 API 证书如果发到小程序或者网页里,等于把保险柜钥匙交给路人。所以支付参数必须由服务端生成,客户端只负责调起支付控件。这也是支付安全的基本红线。

2. 支付对接前的准备:参数、证书与沙箱环境

正式写代码之前,最磨人的其实是各种账号、密钥、证书的申请和配置。我第一次接微信支付是把文档翻了三遍才理清楚,这里帮大家把关键项列出来,照着准备就行。

2.1 微信支付 V3 需要准备什么

微信支付商户平台(pay.weixin.qq.com)申请商户号后,主要拿这几个东西:

  • 商户号 mchid。
  • AppID(小程序或公众号的 AppID,需要在商户平台关联绑定)。
  • 商户 API 证书(pem 格式的商户私钥 apiclient_key.pem、商户证书 apiclient_cert.pem)。
  • APIv3 密钥(在商户平台手动设置的 32 字节对称密钥,用于回调数据解密)。
  • 平台证书/公钥(用于验签,新版的 SDK 可以开启自动更新平台证书)。

这里有个常见的误解:很多新人以为 APIv3 密钥就是商户 API 证书的私钥密码,其实不是。APIv3 密钥是你自己在商户平台设置的一串字符,用来解密微信支付回调里的敏感信息,比如解密 phone、 decrypt 回调资源。而商户私钥是用来生成请求签名的,两者用途完全不同,容易搞混。

提示:微信支付 V3 目前推荐使用 微信支付公钥 取代原来的平台证书。如果代码里配置的是平台证书模式,新申请商户号可能遇到“无可用的平台证书,请在商户平台-API安全申请使用微信支付公钥”的报错。解决办法是去商户平台“API 安全”里申请微信支付公钥,然后在代码里把证书加载逻辑换成公钥模式。

2.2 支付宝支付需要准备什么

支付宝开放平台创建应用后,在“应用详情”里能看到:

  • APPID。
  • 应用私钥(自己用支付宝提供的密钥生成工具生成,应用私钥保存在服务端)。
  • 支付宝公钥(在开放平台上传应用公钥后,平台给返回的公钥,用来验签)。
  • 接口加签方式(选 RSA2)。

支付宝的沙箱环境对开发调试非常友好。不需要真实商户号就能模拟支付,网关地址是 openapi.alipaydev.com,需要去“沙箱环境”页面获取沙箱版支付宝 APP(用于手机端模拟支付)。我强烈建议在沙箱里把流程跑通再切正式环境,省下来的全是联调时间。

2.3 Maven 依赖与配置文件

项目里用到的核心依赖就这几个:

<!-- 微信支付V3 SDK --> <dependency> <groupId>com.github.wechatpay-apiv3</groupId> <artifactId>wechatpay-java</artifactId> <version>0.2.11</version> </dependency> <!-- 支付宝SDK --> <dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.38.59.ALL</version> </dependency>

配置文件里不要写死密钥,要用环境变量或配置中心管理。我的做法是放在 application-prod.yml,但实际值从环境变量里读取,避免把私钥提交到 Git 仓库。

3. 核心接口实现:下单、回调与退款

这一节是全文的硬菜。我按照真实的调用时序来写:服务端生成支付参数 → 用户支付 → 微信/支付宝异步通知服务端 → 服务端更新订单 → 运营发起退款。每一步都贴了关键代码和注释。

3.1 微信支付统一下单(JSAPI 支付)

小程序场景走 JSAPI 支付,服务端拿到用户的 openid 后调用“JSAPI 下单”接口,拿到 prepay_id,再签名生成小程序端 wx.requestPayment 需要的参数。

核心代码如下:

public Map<String, String> wxJsapiPay(WxPayOrderDTO dto) throws Exception { // 1. 构建 HttpClient,使用商户私钥进行请求签名 PrivateKey merchantPrivateKey = PemUtil.loadPrivateKey( new FileInputStream("apiclient_key.pem")); // 证书序列号从商户证书中读取 String serialNo = CertUtil.getSerialNo("apiclient_cert.pem"); // 微信支付公钥/平台证书用于验签,可用公钥模式或证书模式 PublicKey wechatPayPublicKey = PemUtil.loadPublicKey( new FileInputStream("wechatpay_public_key.pem")); RSAAutoCertificateConfig config = new RSAAutoCertificateConfig.Builder() .merchantId(mchid) .privateKey(merchantPrivateKey) .merchantSerialNumber(serialNo) .privateKeyPath("apiclient_key.pem") .build(); // 2. 调起统一下单 API HttpService httpService = new ApacheHttpClientBuilder() .config(config) .build(); String url = "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi"; Map<String, Object> body = new HashMap<>(); body.put("appid", appId); body.put("mchid", mchid); body.put("description", dto.getSubject()); body.put("out_trade_no", dto.getOrderNo()); body.put("notify_url", wxNotifyUrl); body.put("amount", Map.of("total", dto.getAmount(), "currency", "CNY")); body.put("payer", Map.of("openid", dto.getOpenId())); // 3. 发送 POST 请求,拿到 prepay_id HttpResponse response = httpService.post(url, body); JSONObject json = JSON.parseObject(response.getBody()); String prepayId = json.getString("prepay_id"); // 4. 二次签名,生成小程序端调起支付所需的参数 String timestamp = String.valueOf(System.currentTimeMillis() / 1000); String nonceStr = RandomUtil.randomString(16); String message = appId + "\n" + timestamp + "\n" + nonceStr + "\n" + prepayId + "\n"; String sign = SignatureUtil.sign(message, merchantPrivateKey); Map<String, String> result = new HashMap<>(); result.put("appId", appId); result.put("timeStamp", timestamp); result.put("nonceStr", nonceStr); result.put("package", "prepay_id=" + prepayId); result.put("signType", "RSA"); result.put("paySign", sign); return result; }

这里特别注意:金额单位是“分”,不是“元”。用户支付 99.99 元,传给微信的就是 9999。这也是大量 bug 的来源,我见过不止一个项目因为单位换算问题导致订单金额对不上,最后退款对账一团糟。

3.2 支付宝电脑网站支付与手机网站支付

支付宝的接入比微信要省心,因为 SDK 封得很好,核心是组装请求对象、初始化 AlipayClient、调 execute。

public String alipayPagePay(AlipayPayDTO dto) { AlipayClient alipayClient = new DefaultAlipayClient( "https://openapi.alipay.com/gateway.do", appId, privateKey, "json", "UTF-8", alipayPublicKey, "RSA2"); AlipayTradePagePayRequest request = new AlipayTradePagePayRequest(); request.setNotifyUrl(alipayNotifyUrl); request.setReturnUrl(alipayReturnUrl); // 同步跳转地址,仅做展示 JSONObject bizContent = new JSONObject(); bizContent.put("out_trade_no", dto.getOrderNo()); bizContent.put("total_amount", dto.getAmount()); // 支付宝的单位是“元” bizContent.put("subject", dto.getSubject()); bizContent.put("product_code", "FAST_INSTANT_TRADE_PAY"); request.setBizContent(bizContent.toJSONString()); try { AlipayTradePagePayResponse response = alipayClient.pageExecute(request); return response.getBody(); // 返回一段自动提交表单的 HTML } catch (AlipayApiException e) { throw new RuntimeException("支付宝下单失败", e); } }

有一个细节容易被忽略:微信金额用的是“分”,支付宝金额用的是“元”(字符串类型)。如果你的金额实体类统一存的是分,在组装支付宝请求前一定要除以 100 并格式化为两位小数,比如 10.00,不然支付宝会直接报“订单金额格式错误”。

有朋友遇到过“支付宝电脑网站支付如何只返回一个二维码链接”的需求,其实很简单:不要直接拿 pageExecute 返回的 HTML 返给前端,而是让后端生成订单后用 pageExecute 拿到完整跳转 URL(response.getBody() 里可以提取出 action 地址),或者更优雅的方式是使用 AlipayTradePrecreateRequest(当面付预下单)接口,直接返回 qrCode 字段。

3.3 支付回调处理:验签、解密与幂等

回调是整个支付环节最核心、也最容易出问题的一步。微信和支付宝的异步通知有一个共同特点:可能重复推送,而且顺序不定。所以回调必须做两件事:验签确认来源合法,幂等防止重复处理。

我先说微信支付 V3 的回调,相比 V2 它安全了不少:通知报文里只有一个 encrypted 密文,需要用 APIv3 密钥做 AES-256-GCM 解密才能拿到订单数据。同时需要用微信支付公钥验签。代码拆成两步:

@PostMapping("/notify/wx") public String wxNotify(HttpServletRequest request, @RequestBody String body) throws Exception { // 1. 获取请求头:Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature String timestamp = request.getHeader("Wechatpay-Timestamp"); String nonce = request.getHeader("Wechatpay-Nonce"); String signature = request.getHeader("Wechatpay-Signature"); String serial = request.getHeader("Wechatpay-Serial"); // 2. 验签逻辑(省略证书加载,可复用上面的 config) boolean ok = verifyWxSign(timestamp, nonce, body, signature, serial); if (!ok) { return "FAIL"; // 微信要求返回 FAIL,超过一定次数会停止推送 } // 3. 解密 resource 里的密文 JSONObject json = JSON.parseObject(body); JSONObject resource = json.getJSONObject("resource"); String ciphertext = resource.getString("ciphertext"); String associatedData = resource.getString("associated_data"); String nonceForDecrypt = resource.getString("nonce"); String plaintext = AesUtil.decryptToString( apiV3Key.getBytes(StandardCharsets.UTF_8), associatedData.getBytes(StandardCharsets.UTF_8), nonceForDecrypt.getBytes(StandardCharsets.UTF_8), ciphertext); // 4. 解析解密后的 JSON,拿到 out_trade_no、trade_state、amount JSONObject data = JSON.parseObject(plaintext); String outTradeNo = data.getString("out_trade_no"); String tradeState = data.getString("trade_state"); Integer total = data.getInteger("total"); // 5. 幂等处理:如果订单已经是“已支付”状态,直接返回 SUCCESS Order order = orderMapper.selectByOrderNo(outTradeNo); if (order == null) { return "FAIL"; // 订单不存在,返回 FAIL 让微信重试 } if (OrderStatus.PAID.equals(order.getStatus())) { return "SUCCESS"; // 已处理过,防止重复 } if (!"SUCCESS".equals(tradeState)) { return "FAIL"; } if (total != order.getAmount()) { log.error("微信回调金额不一致, orderNo={}, total={}, dbAmount={}", outTradeNo, total, order.getAmount()); return "FAIL"; } // 6. 更新订单状态,加锁避免并发重复处理 // 推荐用 Redis 分布式锁 + 唯一索引双重保障 boolean updated = orderMapper.paySuccessByOrderNoAndStatus( outTradeNo, OrderStatus.WAIT_PAY, OrderStatus.PAID); if (!updated) { return "SUCCESS"; // 说明已经被其他线程处理了,也算成功 } return "SUCCESS"; }

支付宝的回调验签用的是支付宝公钥,SDK 提供了便捷方法:

public String alipayNotify(HttpServletRequest request) { Map<String, String> params = new HashMap<>(); request.getParameterMap().forEach((k, v) -> params.put(k, v[0])); try { // 验签,核心代码就这一行 boolean signVerified = AlipaySignature.rsaCheckV1( params, alipayPublicKey, "UTF-8", "RSA2"); if (!signVerified) { return "failure"; } String tradeStatus = params.get("trade_status"); String outTradeNo = params.get("out_trade_no"); String totalAmount = params.get("total_amount"); if ("TRADE_SUCCESS".equals(tradeStatus)) { // 同样的幂等处理逻辑:先查订单状态,再用乐观锁更新 return processPaidOrder(outTradeNo, totalAmount); } return "failure"; } catch (Exception e) { log.error("支付宝回调验签失败", e); return "failure"; } }

这里要特别说一下异步通知的“成功”返回语义。微信要求回调接口最终返回“SUCCESS”,支付宝要求返回“success”(全小写)。如果返回别的字符串或者异常,支付平台会认为通知失败,按照一定的频率重复发送通知。微信和支付宝的重试策略略有差异,但设计上都是指数退避,所以回调处理逻辑必须天然支持重复调用,绝不能因为重复回调产生两条支付流水。

3.4 退款功能实现

退款跟支付一样,也有两条链路:接口调用和结果确认。微信退款接口是 POST /v3/refund/domestic/refunds,支付宝对应的是 alipay.trade.refund。

微信退款的关键参数是 out_trade_no(原支付订单号)和 out_refund_no(本次退款单号),金额同样是“分”:

public void wxRefund(RefundDTO dto) { String url = "https://api.mch.weixin.qq.com/v3/refund/domestic/refunds"; Map<String, Object> body = new HashMap<>(); body.put("out_trade_no", dto.getOrderNo()); body.put("out_refund_no", dto.getRefundNo()); body.put("reason", dto.getReason()); body.put("notify_url", wxRefundNotifyUrl); body.put("amount", Map.of( "refund", dto.getRefundAmount(), // 单位:分 "total", dto.getTradeAmount(), // 原订单金额,单位:分 "currency", "CNY" )); // POST 发送请求,同步返回退款是否受理成功 }

支付宝退款就简单很多:

public String alipayRefund(RefundDTO dto) { AlipayTradeRefundRequest request = new AlipayTradeRefundRequest(); JSONObject bizContent = new JSONObject(); bizContent.put("out_trade_no", dto.getOrderNo()); bizContent.put("refund_amount", dto.getRefundAmount()); // 单位:元 bizContent.put("out_request_no", dto.getRefundNo()); request.setBizContent(bizContent.toJSONString()); AlipayTradeRefundResponse response = alipayClient.execute(request); return response.getBody(); }

关于退款的一点切身感受:退款不是一提交就立即成功的。微信和支付宝都是“受理制”,接口返回 success 只代表退款申请被接受,实际打款是异步清算的。所以退款模块必须做两件事:一是记录退款流水表并维护状态(申请中/成功/失败);二是靠回调或主动查询来确认最终结果。微信退款有单独的 refund notify_url,支付宝退款可以直接按原支付回调的 notify_url 来收。

提示:退款一旦成功,微信和支付宝都不支持“撤销退款”。所以在发起退款前,系统层面一定要做退款金额上限校验(累计退款金额不能超过支付金额),否则运营手滑多退一次,这笔差价只能公司自己担。

4. 常见问题与排查技巧实录

支付接入过程中踩坑是难免的,我把这一轮实际遇到过的问题整理成一个速查表,基本都是文档里翻不着的细节。

4.1 微信支付高频问题

报错“无可用的平台证书,请在商户平台-API安全申请使用微信支付公钥”。这是新商户号最常遇到的问题。原因很直接:新商户号默认使用“微信支付公钥”模式而非“平台证书”模式,但代码还按老 SDK 的规范去加载平台证书。解决方案就是去商户平台申请微信支付公钥,然后替换配置。说白了,微信在推动公钥模式替代证书模式,代码要同步升级。

回调验签失败。微信回调验签失败大概率是证书加载错了。检查三点:用没用对微信支付公钥(不是商户 API 证书);公钥有没有填成 APIv3 密钥;验签时用的 serial number 跟请求头里的 Wechatpay-Serial 是否一致。我遇到过把商户证书序列号拿去验微信平台签名的,能不失败吗。

API 请求返回 401 签名错误。这是最常见的网络请求报错,排查思路从这四个维度走:私钥是否匹配、证书序列号是否对应、请求体中的字符串与签名原文是否一致、时间戳是否偏差过大。你可以把官方提供的签名工具和本地生成的签名结果做对比,一秒能定位问题。

4.2 支付宝高频问题

沙箱环境能付,正式环境一直报“公钥不对”。这是环境串了的典型症状。开发环境用沙箱的支付宝公钥,切正式环境忘了换。支付宝沙箱地址是 openapi.alipaydev.com,正式环境是 openapi.alipay.com,两个环境的密钥完全独立。建议把网关地址、公钥、AppID 做成一套环境配置,切换环境时一次换全。

同步通知和异步通知搞混。支付宝的回调有两种:return_url 是用户支付成功后浏览器跳转,只做展示;notify_url 才是服务端真正要处理的异步通知。很多萌新在 return_url 里更新订单状态,会导致支付成功但服务端不知道,订单还是未支付。

金额校验不通过。支付宝回调参数 total_amount 是字符串的“99.99”,拿 BigDecimal 转没问题;但如果你直接 Double.parseDouble,再跟数据库里的分做比较,很容易踩浮点精度坑。我的做法是统一转成 BigDecimal,compareTo 方法比较,绝不直接用 equals。

4.3 服务端设计方案层面的坑

回调接口一定要设置超时短的熔断策略。支付平台在回调时如果发现你的接口迟迟不响应,它会持续重试。如果回调期间正好赶上数据库故障,也不要让线程死等支付平台,直接快速返回 FAIL,等支付平台自己重试。这个设计对运维非常友好。

幂等不能只靠数据库状态判断。最稳妥的幂等方案是“数据库唯一索引 + Redis 分布式锁 + 乐观锁状态更新”三层叠加。具体操作:支付回调处理前,先往支付流水表插入一条记录(用 out_trade_no 做唯一键),插入失败说明已经处理过,直接返回 SUCCESS。这个做法比先查后更新更安全,能挡掉并发重试的极端情况。

4.4 我踩过的一个典型坑

最后分享一个真实的翻车经历。有一回上线后用户反馈说“支付成功了但订单没发货”,一查日志,发现微信回调进来了两三次,但第一次处理时 Redis 锁超时释放了,第二次线程进来发现订单还是待支付,于是又执行了一遍状态更新。问题出在我只用了 Redis 锁,没有给数据库层加唯一约束,两个线程刚好在锁失效的间隙同时更新了订单表,第二次更新覆盖了第一次的处理结果。

从那以后,我的回调处理逻辑就改成了“先插流水表(唯一索引兜底)再改订单状态(乐观锁)”,这两步都成功才算处理完成。后来微信又重复回调了几次,流水表直接挡住了,再也没出现过重复发货的问题。这个教训值一万块,写出来给大家避坑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询