☰
Java支付系统源码实战:SpringBoot对接支付宝微信银联全攻略
2026/10/4 1:11:16 网站建设 项目流程

简介:这款Java互联网支付系统源码基于SpringBoot构建,完整集成了支付宝、微信支付与银联支付三大主流渠道,面向需要学习支付接口开发或搭建支付模块的Java开发者,也适用于电商、服务预订、在线教育等业务场景。包内共250个文件,以Java源码为核心,辅以js、html、css等前端资源、gif演示图以及证书和配置文件,压缩包整体仅1.53MB,目录结构清晰,便于按需查阅和二次开发。资源内容覆盖支付请求构造、回调验签、异步通知处理、退款与交易查询等关键代码,并包含了签名算法、HTTPS通信等安全实现细节,能够帮助读者理解三种支付方式的完整交互流程。目前已有774人学习下载,适合希望快速掌握SpringBoot整合多支付渠道、并参照真实项目进行实践的中级开发者。

1. Java互联网支付系统:从订单到账单的闭环,这套源码帮你跳过三个月试错

做支付接入这件事,说难不算难,说简单也绝不容易。这套基于SpringBoot的Java互联网支付系统源码,把支付宝、微信、银联三条主流接入路径全部打通,统一下单、回调验签、状态更新、退款查询都有完整代码案例。别看文档写得厚,实际上光支付宝一家就有当面付、网站支付、App支付好几个产品线,签名规则各不相同;微信支付又有一套证书体系,银联更是证书文件和报文格式的堆叠。很多团队第一个支付需求排期三个月,一半时间耗在环境配置和联调上。这套源码适合两类人:一是要接入支付的Java后端工程师,拿来改改配置就能跑通闭环;二是想系统看懂支付系统设计的人,读代码比看文档快得多。下面按「架构设计→支付宝→微信与银联→避坑→跑通验证」的顺序拆。

2. 支付系统的地基:订单状态机、回调闭环与三渠道统一抽象

2.1 支付系统到底要管哪几件事

拿到这套源码,第一件事不是去看支付宝的代码,而是先把它整个包结构过一遍。一个生产级支付系统,本质上就是四件事:下单、支付、回调、对账和退款。如果哪个项目把这四件事混在一个Service里,后面改渠道的时候一定会吃苦头。这套源码的包结构拆得很清楚:controller层只做参数接收,不写业务逻辑;service层按渠道拆了alipay、wechat、unionpay三个包;core里面是订单、流水、回调通知的公共逻辑。

我一般建议团队照这个结构来拆,因为支付渠道是最容易变动的地方。支付宝升级SDK、微信调整API版本、银联改报文格式,都是你不可控的变化,渠道代码必须能独立替换,否则每次渠道调整都要把整个支付模块翻一遍,回归测试工作量成倍增长。这也是这套源码第一个值得抄作业的地方:渠道隔离不是设计洁癖,是实实在在的维护成本问题。

下单这个动作,核心产物是订单号和支付参数。订单号由业务系统生成,传给支付网关,回调的时候再原样返回。这套代码里对订单号的处理值得注意:它不是UUID直接拿来用,而是拼上了渠道标识和日期,比如20250101_8001_000001。这样日志里扫一眼就知道是哪天哪个渠道的单子,排查问题时非常顺手。UUID当订单号也不是不行,但出问题的时候你对着日志里的乱码字符串,想按时间维度筛选都做不到。

回调是支付系统的命门。用户扫码付完钱,支付平台会异步通知你的服务器,你收到通知后要验签、查单、改状态、通知业务方。这套源码里回调处理是全部代码里最厚的部分,因为它要处理验签失败、重复通知、金额不一致这些情况。后面我会单独开一章讲坑,这里先记住一个结论:回调处理得越保守,后续对账越轻松。

2.2 订单状态机与数据库设计

支付订单的状态设计,决定了整个系统的复杂程度。状态设少了,退款、关单、超时这些场景没法表达;设多了,每个状态流转都要写一堆判断,容易把人绕晕。这套源码用的是经典五状态:待支付、已支付、已退款、已关闭、部分退款。

数据库设计上,核心就是订单表和流水表。订单表的核心字段我列出来:

CREATE TABLE `pay_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `out_trade_no` varchar(32) NOT NULL COMMENT '商户订单号', `channel` varchar(16) NOT NULL COMMENT '渠道:alipay/wechat/unionpay', `trade_no` varchar(64) DEFAULT NULL COMMENT '支付平台流水号', `amount_fen` int(11) NOT NULL COMMENT '订单金额,单位分', `status` tinyint(4) NOT NULL COMMENT '0待支付 1已支付 2已退款 3已关闭 4部分退款', `notify_time` datetime DEFAULT NULL COMMENT '最后回调时间', `create_time` datetime NOT NULL, `update_time` datetime NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_out_trade_no` (`out_trade_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里要特别说明两个字段的用意。第一个是amount_fen,金额以「分」为单位,用整数存。这是支付系统最基础也最容易被新手忽略的纪律,用浮点数存金额必出精度问题,数据库层面用整数分比DECIMAL更省心,也方便和微信、银联的接口直接对齐。第二个是uk_out_trade_no唯一索引,这是幂等处理的第一道防线,同样的订单号在物理上不可能插入两次。

流水表pay_flow则是每次状态变更都记一条,类似于银行流水。下单记一条、回调成功记一条、退款记一条,每一条都带上渠道返回的交易号。这套源码里所有对账、出账的操作都从流水表读数据,而不是直接读订单表。这个习惯很实用,查问题的时候能看到每一步发生了什么,而不是只看到一个孤零零的最终状态。

2.3 三渠道的差异与统一支付接口设计

支付宝、微信、银联三家接口风格完全不一样,有传JSON的,有传表单的,有传XML的,但这套源码把它们收口到了一个统一的PaymentService接口上。接口定义大概这样:

public interface PaymentService { /** 下单,返回支付参数(二维码串/跳转链接) */ PayResult placeOrder(PayRequest request); /** 处理异步回调,验签后返回是否标记成功 */ CallbackResult handleNotify(Map<String, String> notifyParams); /** 主动查单,用于补偿回调丢失 */ QueryResult queryOrder(String outTradeNo); /** 退款,返回退款流水号 */ RefundResult refund(String outTradeNo, int refundAmountFen); }

每个实现类里,才是各渠道的SDK调用。这样做的好处很明显:业务系统只需要依赖PaymentService接口,不需要知道当前跑的是哪个渠道。切换渠道时改配置的default指向即可,业务代码一行不动。源码里三个实现类还各自维护了自己的配置类,用@ConfigurationProperties绑定,配置项的命名和渠道SDK的术语保持一致,看代码的时候不会产生「这个字段到底是支付宝的还是微信的」这种困惑。

三家渠道的风格差异,这里先给一个总览,后面两章分别展开:

渠道签名方式金额单位回调节点主要SDK
支付宝RSA2公私钥字符串元异步通知,可配公钥验签alipay-sdk-java
微信APIv3 + 商户证书整数分回调加密需解密+验签wechatpay-java
银联证书文件签名整数分同步+异步双通知官方acp-sdk

这个抽象还有一个隐藏价值:对账系统只需要面向PaymentService接口做轮询查单,不需要知道各渠道查单协议差异。后面第6章我会演示怎么用这个接口写一个每30秒自动查单的补偿任务。

3. 支付宝接入实战:沙箱配置、下单签名与异步回调验签

3.1 开放平台配置与SDK依赖引入

支付宝接入第一步不是写代码,是去开放平台拿到三样东西:应用AppID、应用私钥、支付宝公钥。这套源码用的是「自管证书」模式,也就是用支付宝官方工具生成RSA2密钥对,公钥上传给平台,私钥自己保存。RSA2是现在唯一推荐的签名算法,源码里signType字段直接写RSA2。如果哪天看到旧代码写RSA,那是SHA1签名,新商户已经申请不到了,遇到就得升级。

源码里支付宝配置集中在application-alipay.yml:

alipay: app-id: 2021000123456789 private-key: MIIEvQIBADANBg... # 应用私钥,PKCS8格式 alipay-public-key: MIIBIjANBg... # 支付宝公钥 gateway: https://openapi.alipay.com/gateway.do notify-url: https://api.example.com/pay/alipay/notify return-url: https://example.com/pay/result

注意两个容易出错的地方。第一,private-key必须是PKCS8格式。很多人在开放平台下载的是PKCS1格式的私钥,直接贴进去会报「密钥格式错误」,需要在本地用openssl转成PKCS8,一行命令:openssl pkcs8 -topk8 -inform PEM -in pkcs1.pem -out pkcs8.pem -nocrypt。第二,gateway生产环境是openapi.alipay.com,沙箱环境要换成openapi.alipaydev.com。这两个地址写反了,报错信息是「网关鉴权失败」,但真正的根因是域名配错,排查起来很迷惑。

SDK依赖方面,这套源码用的是支付宝官方alipay-sdk-java:

<dependency> <groupId>com.alipay.sdk</groupId> <artifactId>alipay-sdk-java</artifactId> <version>4.39.57.ALL</version> <!-- 具体版本以Maven仓库官方最新为准 --> </dependency>

这个依赖会把所有支付产品接口类打包进来,体积不小,但胜在一个包搞定。版本升级时重点看两点:接口签名有没有变、底层HTTP客户端有没有换。我自己遇到过SDK从4.x升到5.x后execute方法签名变化的兼容性问题,所以源码里这块特意把SDK调用封装在AlipayClientHolder里,升级SDK时只需要改一个类。

3.2 统一下单与二维码生成

扫码支付场景,源码里用的是支付宝「预下单 + 返回二维码」方案,也就是当面付的alipay.trade.precreate。为什么不直接用alipay.trade.page.pay?因为业务是PC端生成二维码让用户用手机扫,不需要页面跳转。precreate返回一个qrCode字符串,转成二维码图片挂在页面上即可。核心代码长这样:

AlipayClient alipayClient = new DefaultAlipayClient( gateway, appId, privateKey, "json", "UTF-8", alipayPublicKey, "RSA2"); AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest(); request.setNotifyUrl(notifyUrl); request.setBizContent( "{\"out_trade_no\":\"" + outTradeNo + "\"," + "\"total_amount\":\"" + amountYuan + "\"," + "\"subject\":\"" + subject + "\"," + "\"timeout_express\":\"30m\"}" ); AlipayTradePrecreateResponse response = alipayClient.execute(request); if (response.isSuccess()) { return response.getQrCode(); // 拿到二维码串,前端转成二维码图片 }

这里有个极易踩的坑:total_amount是字符串格式的「元」,不是分。源码里在构造参数前已经把分转成了元,用String.format("%.2f", amountFen / 100.0),但如果你直接把整数分传进去,比如1.5元写成"150",支付宝会直接拒绝。反过来,如果把1.5写成"1.5"而不是"1.50",虽然支付宝能解析,但部分字段回显时会出现格式不一致,所以统一的%.2f格式化不能省。

timeout_express这个参数也值得留意,它控制二维码的有效期,源码里设了30分钟。这个值要和你订单表里「待支付」状态的超时关单任务保持一致,否则会出现二维码还能扫、本地订单已经被关掉的情况。生产环境我一般用15到30分钟,太短用户来不及支付,太长占着库存不放。另外要注意这个字段的单位是分钟,拼字符串的时候别带上其他单位后缀。

3.3 异步回调验签与订单状态流转

支付成功之后,支付宝会异步通知你的notifyUrl,最多通知8次,间隔逐渐拉长。源码里回调处理的思路很清晰:验签 → 取参 → 幂等更新 → 返回success或failure。

@RequestMapping("/pay/alipay/notify") public String alipayNotify(HttpServletRequest request) throws AlipayApiException { Map<String, String> params = ServletUtils.getParamMap(request); boolean signVerified = AlipaySignature.rsaCheckV1( params, alipayPublicKey, "UTF-8", "RSA2"); if (!signVerified) { return "failure"; // 验签失败,告知支付宝不要继续重试 } String outTradeNo = params.get("out_trade_no"); String tradeNo = params.get("trade_no"); String tradeStatus = params.get("trade_status"); // 只处理 TRADE_SUCCESS / TRADE_FINISHED if ("TRADE_SUCCESS".equals(tradeStatus) || "TRADE_FINISHED".equals(tradeStatus)) { payService.markPaid(outTradeNo, tradeNo); } return "success"; }

两个细节必须说清楚。第一,回调通知里的参数验签用的是rsaCheckV1,它会把所有参数按字典序拼接后再验签,这个方法名里的V1容易让人误以为是旧版签名,其实它对应的是RSA2签名算法,参数拼接顺序和另一套rsaCheck有一处不同,用错方法会导致验签诡异失败——签名的参数顺序是这里最典型的玄学问题。第二,处理完业务逻辑后必须返回纯文本"success",一个字都不能多,不能返回JSON,否则支付宝会判定处理失败继续重试。源码里这里用了@ResponseBody返回字符串,写法上很克制。

回调里的金额校验,源码在markPaid里面做了:params.get("total_amount")转成整数分,和订单表里的amount_fen比对,不一致就直接返回failure并记录异常日志。这条校验不能省,虽然概率极低,但万一出现金额不一致而系统标记成功,财务对账会非常难受,到时候要翻所有渠道的流水才能定位问题。

另外要注意trade_status的取值。支付宝的回调里会有WAIT_BUYER_PAY、TRADE_SUCCESS、TRADE_FINISHED三种状态。WAIT_BUYER_PAY是用户还没付,不需要处理;TRADE_SUCCESS是一般意义上的支付成功;TRADE_FINISHED表示交易完成且不可退款。源码里把两个成功态都纳入markPaid,但退款场景下需要额外判断:TRADE_FINISHED之后支付宝是不允许发起退款的,所以如果你的业务允许用户支付后一段时间内退款,必须单独记录收到的是哪个状态。

4. 微信支付V3与银联接入:证书体系差异与统一收银台

4.1 微信支付V3的APIv3签名与回调

微信支付的接入体验和支付宝差别很大。支付宝是一对公私钥,微信支付在V3版本里是「商户API证书 + APIv3密钥 + 商户号」三个要素,任何一个缺失都跑不通。源码里微信支付的配置长这样:

wechat: mch-id: 1900001234 app-id: wxd2f5c... api-v3-key: 32位随机字符串 merchant-private-key: -----BEGIN PRIVATE KEY-----... merchant-serial-number: 证书序列号

微信支付V3的所有请求,请求头里要带上Authorization: WECHATPAY2-SHA256-RSA2048签名。签名过程是:构造签名串(请求方法 + 换行 + 请求路径 + 换行 + 时间戳 + 换行 + 随机串 + 换行 + 请求体),用商户私钥做SHA256-RSA签名,再拼上商户证书序列号。这段逻辑如果自己写,非常容易在换行符或请求体编码上翻车。源码里直接用了微信官方wechatpay-javaSDK,签名头由SDK自动构造,省掉了一大批低级错误。

下单接口调用代码如下:

WechatPayClient client = new WechatPayClient.Builder() .merchantId(mchId).privateKey(merchantPrivateKey) .merchantSerialNumber(merchantSerialNumber) .apiV3Key(apiV3Key).build(); NativePayService nativePayService = new NativePayService.Builder(client).build(); NativePayTransactionRequest txRequest = new NativePayTransactionRequest() .setAppid(appId).setMchid(mchId) .setOutTradeNo(outTradeNo) .setAmount(new Amount().setTotal(amountFen)) // 注意:微信单位就是分 .setDescription(subject) .setNotifyUrl(wechatNotifyUrl); Transaction result = nativePayService.createOrder(txRequest); String codeUrl = result.getCodeUrl(); // 拿这个串生成二维码

一个关键差异:微信支付的amount.total单位就是分,和支付宝按「元」传字符串完全不同。如果按支付宝的习惯把1.5元传成1.50,微信支付会直接报参数错误;反过来把微信的150分传给支付宝也会被拒。这套源码在三渠道各自的Service实现里做了单位转换,统一入口都用分,这是统一抽象带来的实在好处。

微信V3回调的处理比支付宝多一步:回调内容经过了AES-256-GCM加密,需要先用api-v3-key解密报文,再验签。解密后的resource字段里才是out_trade_no、transaction_id、trade_state这些真正的业务字段。源码里用SDK的NotificationParser解析回调,解析出来的参数结构和支付宝完全不同,但最终都收口到payService.markPaid这个方法。有一个常见坑:解密失败时,很多人会怀疑是api-v3-key配错,其实更常见的是回调头里的Wechatpay-Signature和Wechatpay-Timestamp没有被正确传递,代理层把请求头过滤掉了。源码里回调接口的日志把整个请求头打出来了,这个习惯值得保留。

4.2 银联网关支付的证书与跳转

银联支付是三家里最「传统」的,它的签名体系建立在证书文件上:商户需要申请「签名证书」「验签证书」两套,加上银联的「验签证书」,三个证书文件缺一不可。这套源码里银联的配置集中在acp开头的一组参数里,启动时要求证书文件放在指定目录,路径配错会直接抛FileNotFoundException,而且报错信息容易让人误以为是网络问题。我建议把这几个证书文件的路径做成配置项,而不是写死在代码里,万一证书到期更换,只需要改配置重启,不用重新编译。

银联的主流方案是网关跳转:用户提交订单后,后端拼接一份表单POST到银联网关,银联在用户完成支付后先跳回frontUrl(浏览器回调),再通过backUrl(后台通知)把结果告诉服务器。源码里两个URL都做了处理,但代码注释写得很清楚:frontUrl只是给用户看的「支付完成」页面,不能当作业务成功的依据,业务结果必须以后台backUrl通知为准。

银联回调验签的核心代码:

AcpService acpService = new AcpService(); Map<String, String> data = acpService.decodeFormResponse(params); boolean verify = acpService.validate(data, "UTF-8"); if (!verify) { log.error("银联回调验签失败: {}", outTradeNo); return "error"; } String respCode = data.get("respCode"); if ("00".equals(respCode)) { // respCode为00才是交易成功 payService.markPaid(data.get("orderId"), data.get("queryId")); }

银联和支付宝、微信有个明显差异:响应码respCode不只是「00 / 非00」两种,还有03(不确定)、06(部分成功)等中间态。源码里对非00响应做了单独处理:不标记成功,也不直接标记失败,而是把状态留在「待支付」,等待后续查单任务去确认。这块逻辑新手容易写成「非00就是失败」,一旦用户其实支付成功但本地订单显示失败,后续退款和客服处理会非常痛苦。

银联还有一个细节:queryId是银联返回的交易查询流水号,退款的origQryId就是用它。源码里把queryId存进了流水表的channel_flow_no字段,退款时直接取出来用,不用重新查。这个字段在三家渠道里含义不同——支付宝存的是trade_no,微信存的是transaction_id——所以统一用trade_no字段名,语义上保持对齐。

4.3 收银台聚合:三种渠道如何共存

收银台是前端到支付后端的第一个入口,这套源码把它设计成了一个普通的下单接口:前端传channel字段(alipay/wechat/unionpay),后端根据channel从Spring容器里拿到对应的PaymentService实现类。

代码在PaymentServiceFactory里用了最简单的策略模式:

@Component public class PaymentServiceFactory { private final Map<String, PaymentService> serviceMap; public PaymentServiceFactory(List<PaymentService> services) { serviceMap = services.stream() .collect(Collectors.toMap(s -> s.channel(), s -> s)); } public PaymentService get(String channel) { PaymentService service = serviceMap.get(channel); if (service == null) { throw new UnsupportedOperationException("不支持的支付渠道: " + channel); } return service; } }

List<PaymentService>依赖注入会把三个实现类全部收进来,channel()方法返回各自渠道名,工厂负责按名字取实现。后续要接第四家渠道,比如云闪付,只需要新写一个实现类标注好channel,工厂代码一行都不用改。这个模式比在统一Service里写if-else要干净得多,排查问题时沿着channel字段就能定位到具体实现类。

这里分享一个源码之外的实践经验:收银台的下单接口一定要做防重复提交。同一订单号重复下单,支付宝和银联会返回「订单已存在」,微信会报ORDER_EXISTS错误。源码里通过@Transactional加订单号唯一索引双重保证,但HTTP层的防重也可以配合加上,用Redis的SETNX out_trade_no做一分钟内的幂等控制,体验会更好。另外收银台接口建议把三个渠道的响应结构统一成{ channel, payParams, expireAt },前端拿到payParams后按渠道分别渲染二维码或跳转链接,这样前端逻辑也只需要写一次。

5. 支付系统避坑指南:回调丢失、重复通知与金额精度

支付这一行,文档看得再多,不如真实踩一次坑记得牢。这一章我把这套源码里标注了大量注释的踩点整理成五条,每条都是「现象 → 原因 → 解决」的结构,照着排查能省一半联调时间。

5.1 回调丢失:用户付了钱,服务器没收到通知

现象:用户手机显示支付成功,但平台订单一直停在「待支付」,查支付宝或微信后台又确实有这笔交易。

原因:回调通知是网络请求,丢包、服务器重启、部署期间接口短暂不可达,都会导致通知丢失。支付宝和微信会重试,但重试有次数上限,而且都在支付成功后的短暂窗口内,过了窗口就再也不通知了。只依赖回调做状态更新,等于把业务成功寄托在网络请求的运气上。

解决:以「主动查单」为主,「被动回调」为辅。这套源码里PaymentService接口的queryOrder就是干这个的,配一个定时任务,每30秒扫描一次超过2分钟仍未支付且未关闭的订单,主动向渠道发起查单。如果查询结果是已支付,走一遍和回调相同的markPaid逻辑。我一般把这个任务的调度间隔按订单量调整,线上量少就30秒一次,量大可以考虑只对高金额订单做高频查单。

@Scheduled(fixedDelay = 30000) public void compensatePendingOrders() { List<PayOrder> pendingList = orderMapper.findPendingTimeout(2, 30); for (PayOrder order : pendingList) { PaymentService service = serviceFactory.get(order.getChannel()); QueryResult result = service.queryOrder(order.getOutTradeNo()); if (result.isPaid()) { payService.markPaid(order.getOutTradeNo(), result.getTradeNo()); } else if (result.isClosed()) { orderMapper.updateStatusClosed(order.getOutTradeNo()); } } }

这段代码里findPendingTimeout的两个参数含义是:超过2分钟、且创建时间在30分钟内的待支付订单。这里要注意不要扫全部历史待支付单,否则几十万条死单每次都被拉出来查一轮,渠道查单接口有频率限制,很容易触发风控。合理的做法是只扫最近30天或最近N小时的订单。

5.2 重复通知:同一笔订单被回调了多次

现象:日志里看到同一笔订单的markPaid被调用了好几遍,有时是回调重复,有时是「回调 + 查单补偿」同时在跑。

原因:支付宝和微信都承诺通知可能重复多次,而且回调请求可能在中间链路被重放;补偿查单任务也可能和回调在时间上撞车。如果没有幂等保护,第一次标记成功后,第二次执行会把流水表写重,甚至触发订单状态回退。

解决:幂等处理三道闸。第一道是数据库唯一索引,pay_flow表针对out_trade_no + type建唯一约束,重复插入直接异常退出。第二道是状态判断,markPaid方法进入事务后先查订单状态,已经是「已支付」就直接return,不重复记账。第三道是路由层面,回调接口再加一层RedisSETNX,同一笔订单号在10秒内只允许一个处理请求进入。

@Transactional public void markPaid(String outTradeNo, String tradeNo) { PayOrder order = orderMapper.selectByOutTradeNoForUpdate(outTradeNo); if (order == null) { log.error("订单不存在,回调异常: {}", outTradeNo); return; } if (order.getStatus() == PAY_STATUS_PAID) { log.info("订单已支付,忽略重复回调: {}", outTradeNo); return; } // 校验回调金额 if (order.getAmountFen() != currentNotifyAmountFen) { throw new NotifyAmountMismatchException(outTradeNo); } order.setStatus(PAY_STATUS_PAID); order.setTradeNo(tradeNo); flowMapper.insert(PayFlow.successFlow(outTradeNo, tradeNo)); }

这里selectByOutTradeNoForUpdate用了行锁,保证同一时刻只有一个线程在处理这笔订单的支付状态更新,这是并发回调场景下最稳妥的写法。注意ForUpdate必须配合事务使用,锁在事务提交时才释放,所以markPaid上的@Transactional不能去掉,否则行锁立即失效,幂等就破功了。

5.3 金额精度:一分钱引发的对账事故

现象:订单金额在对账时出现几分钱差异,比如用户付了1.50元,平台账单上却是1.49元或1.51元。

原因:用double或float存金额。Java里0.1 + 0.2 = 0.30000000000000004,一旦在JSON序列化、数据库类型转换过程中出现精度丢失,就会在某个角落露出这个差值。尤其是在金额做加减法时,比如订单金额加运费再减优惠券,浮点数每一步都可能累积误差。

解决:两个禁用,一个统一。禁用浮点类型存金额,数据库全部用整数分或DECIMAL(10,2);禁用double做金额运算。渠道交互时,支付宝要求字符串形式的「元」,微信和银联要求整数「分」,在Service内部统一使用「分」(int/long),只有和渠道SDK交互的那一层做格式转换。这套源码里所有金额字段都叫amountFen,命名即约束,看到这个后缀就不会有人敢往里传小数。退款接口的入参同样用refundAmountFen,退款金额不能大于订单剩余可退金额,这个校验在退款Service里也写了。

还有一个隐藏坑:JSON序列化时,整数分如果被Jackson转成科学计数法或者带小数点的数字,微信支付会报「金额无效」。所以和渠道交互的DTO里,金额字段建议用Integer或Long类型,而不是int基本类型,避免反序列化的边界问题。

5.4 证书与密钥管理的翻车点

现象:配置了私钥和公钥,订单请求却报「签名验证失败」或「证书格式错误」;测试环境好好的,上生产就不行,或者过了一段时间突然开始报错。

原因:三个常见问题。第一,私钥格式不对,支付宝要求PKCS8,微信要求PKCS1,格式混用必然报错。第二,证书序列号和私钥不匹配,微信支付V3请求头里的merchant-serial-number必须是当前有效证书的序列号,证书续期或轮换后序列号会变,代码里旧的序列号没同步就一直验签失败。第三,密钥硬编码在配置文件里,代码仓库泄露后别人就能拿私钥发起退款请求,这个风险比签名失败严重得多。

解决:格式问题看报错关键词,Bad base64是密钥内容错误,invalid signature多数是格式或序列号不匹配。证书轮换时,先把新证书序列号写进配置并发布,再等15分钟确认签名正常后,再废弃旧证书,避免切换窗口导致验签失败。密钥管理上,生产环境至少要把私钥从配置文件挪到环境变量或配置中心,源码里的demo虽然写在yml里,但注释里明确标注了「生产环境必须外部化」这个提示。

5.5 沙箱和生产是两个世界

现象:沙箱环境用模拟账号支付一切正常,切到生产环境后,同样的代码报「应用未授权」或「商户号不存在」。

原因:沙箱环境的AppID、商户号、证书和生产的完全独立,而且沙箱里没有微信支付的真实商户号和证书。很多人的误区是「代码在沙箱通了,生产改个key就能跑」,实际上生产环境还需要在开放平台配置应用权限——支付宝要签约「当面付」产品,微信要开通「Native支付」,银联要申请商户证书后做终端绑定。这些权限审批走完通常要1到3个工作日,不提前申请就会卡在联调最后一步。

解决:项目排期时,上线前两周就把支付功能的权限申请提交上去。切生产环境时,使用一套独立的配置文件(application-prod.yml),逐项核对:网关地址、AppID、商户号、证书序列号、回调域名。我一般会在切完生产后,真扫一块钱验证整条链路,再进对账流程。另外沙箱和生产两套配置建议用Spring的profile机制隔离,防止测试环境误连生产网关。

6. 把这份源码跑起来:配置清单、沙箱联调与一条验证命令

6.1 启动前的配置清单

实际把这套源码拉到本地跑通,最快的路径是「先沙箱、后生产」。沙箱阶段只需要申请一个支付宝沙箱应用,微信和银联可以先写死测试数据。启动前把三样东西配好:MySQL库表(源码自带的schema.sql建库建表)、application.yml里的数据源地址、application-alipay.yml里的沙箱网关和应用密钥。按这个清单核完,项目基本能直接起。

6.2 沙箱联调的关键步骤

支付宝沙箱的流程是:开放平台进入沙箱环境,创建应用后生成沙箱AppID、沙箱私钥和沙箱公钥,用配套的沙箱账号在支付宝App里扫码支付。回调地址这一环要注意,沙箱同样要求回调地址是公网可访问的URL,本地开发时可以指向一台有公网IP的测试服务器,把后端服务部署上去,或者利用云厂商的容器映射能力做转发,让沙箱平台能访问到本地接口。

6.3 验证整条链路的检查点

代码跑通后,我建议你按这个顺序验证:下单接口返回二维码 → 扫码支付 → 看日志里回调到达且验签通过 → 查订单状态变成已支付 → 等30秒看是否被重复通知(应被幂等拦截)。一条命令验证支付状态:

curl http://localhost:8080/pay/query?outTradeNo=20250101001

返回{"status":"PAID","tradeNo":"202501012200..."}就说明整条链路闭环了。把这个检查点走完,这套源码才算真正变成你自己的东西。

做支付系统这些年,我最大的教训就是:永远不要相信回调一定会到,永远不要相信金额一定精确,永远不要跳过幂等直接上线。从那以后我每次接新渠道,都强制走一遍「下单 → 沙箱扫码 → 回调验签 → 查单补偿 → 幂等重放 → 生产小额真付」的流程,一次都不敢少。希望帮到你。

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

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

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

立即咨询