
简介面向Java开发者的微信支付宝支付与退款集成资料聚焦服务器端统一下单、签名生成、请求响应处理及退款回调等核心环节。资源为单个PDF文档大小仅68KB内容紧凑适合有一定基础、需要快速落地支付模块的工程师。目前已有396人学习下载。文档不仅对比了微信统一下单与支付宝直连支付的差异还提供了WXPay、Alipay等工具类的代码片段涵盖prePay、getRep等关键方法包括统一下单参数构造、XML响应解析、前端调起支付字段封装等具体实现并强调签名验签、HTTPS加密、异步回调与事务管理等安全实践。通过这份资料开发者可以理解如何将支付与退款封装为独立服务统一处理异常和状态通知构建电商、O2O等场景下健壮可维护的支付系统。1. java服务器端微信、支付宝支付和退款功能先想清楚它到底在解决什么java服务器端微信、支付宝支付和退款功能听起来就是“调三个接口”的事下单、回调、退款。但只要你做过一次生产接入就会明白真正的难点从来不在接口调用而在状态管理。支付平台只认钱不认订单它把一笔支付结果用异步通知抛给你能否在验签、解密、幂等、并发、丢单这些环节里把订单状态扭对才是服务端工程师的活。这篇文章打算把两个渠道的服务端接入放在同一套订单体系里讲清楚微信支付V3和支付宝的密钥体系、下单查单关单、回调验签解密、退款幂等以及上线后最容易被咬一口的对账和自动补偿。适合正在做Java服务端支付模块、或者准备从单渠道切换到双渠道的工程师。你不用懂支付平台的全貌只需要照着这条链路走下去就能搭出一个能上线、能对账、能退款的支付服务。2. 接入前先定架构支付单状态流、渠道隔离与建表设计2.1 服务端的职责边界从下单、查单到关单哪些事必须落在Java服务端先说一个最常见的错误认知有人为了让“支付快一点”让客户端直接拿着金额去调微信或支付宝的统一下单接口。结果就是用户可以篡改金额参数订单记录和支付记录完全对不上。支付平台在设计接口时把“发起支付”的权限交给你服务端就是为了让你在中间做一次金额和商品信息的核对。服务端在整条支付链路里要管的事按顺序排列是这样的接收客户端的下单请求创建业务订单锁住库存或标记预占状态。调用微信/支付宝的统一下单接口传入订单号、金额、回调地址等参数拿到平台的预付单标识微信的prepay_id、支付宝的trade_no或二维码串。把平台返回的支付参数原样返回给客户端由客户端拉起微信小程序支付、支付宝App支付或扫码页。接收平台异步通知验签、解密后更新订单为已支付。提供查询订单接口在用户中途退出收银台或网络异常时用主动查单兜底。超过支付有效期的订单服务端主动调用关单接口避免用户事后付款导致语义混乱。用户发起退款时校验退款金额和原订单支付状态调用退款接口记录退款单。这里第2步容易踩的坑是回调地址。notify_url必须是一个公网可访问的HTTPS地址不能带query参数微信和支付宝对回调地址的校验都不太一样。微信支付V3要求回调地址能处理POST请求并返回HTTP 200支付宝则要求返回纯文本success或failure。这两个差异我会在后面的章节单独讲。2.2 四张核心表与状态机用MySQL和MyBatis Plus落地支付、退款与回调流水我的习惯是支付模块至少建四张表业务订单表、支付单表、退款单表、渠道回调流水表。业务订单表是你们自己业务域的支付单表才是和支付平台打交道的核心。下面是一组可以直接拿来改的建表SQL我一般用MyBatis Plus的代码生成器把实体类建出来但表结构先手工定好。-- 支付单表一单一次支付 CREATE TABLE t_pay_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_order_no VARCHAR(64) NOT NULL COMMENT 业务订单号, pay_order_no VARCHAR(64) NOT NULL COMMENT 支付单号服务端生成, channel VARCHAR(16) NOT NULL COMMENT 支付渠道WECHAT / ALIPAY, channel_order_no VARCHAR(64) DEFAULT NULL COMMENT 渠道侧订单号如微信prepay_id、支付宝trade_no, amount DECIMAL(10,2) NOT NULL COMMENT 支付金额单位元, currency VARCHAR(8) DEFAULT CNY, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已关闭 3已退款, payer_uid VARCHAR(64) DEFAULT NULL COMMENT 支付渠道内的用户标识微信openid/支付宝buyer_id, paid_at DATETIME DEFAULT NULL COMMENT 实际支付时间以渠道回调为准, expired_at DATETIME NOT NULL COMMENT 订单过期时间超过后禁止支付, notify_url VARCHAR(255) DEFAULT NULL COMMENT 本单使用的回调地址留作排查, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_pay_order_no (pay_order_no), KEY idx_channel_order_no (channel, channel_order_no), KEY idx_biz_order_no (biz_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT支付单表; -- 退款单表一单可多次退款 CREATE TABLE t_refund_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, refund_no VARCHAR(64) NOT NULL COMMENT 退款单号, pay_order_no VARCHAR(64) NOT NULL COMMENT 关联支付单号, channel_refund_id VARCHAR(64) DEFAULT NULL COMMENT 渠道退款单号, refund_amount DECIMAL(10,2) NOT NULL COMMENT 本次退款金额, refund_reason VARCHAR(255) DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0处理中 1成功 2失败 3关闭, channel_error_msg VARCHAR(512) DEFAULT NULL COMMENT 渠道返回的失败原因, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_refund_no (refund_no), KEY idx_pay_order_no (pay_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT退款单表; -- 渠道回调流水表所有通知先落库不直接改业务状态 CREATE TABLE t_channel_callback_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, channel VARCHAR(16) NOT NULL, pay_order_no VARCHAR(64) DEFAULT NULL COMMENT 解析后对应的支付单号可能为空, callback_body TEXT NOT NULL COMMENT 平台回调原始报文, decrypt_body TEXT DEFAULT NULL COMMENT 解密后的报文, verify_result TINYINT DEFAULT NULL COMMENT 验签结果 1成功 0失败, process_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未处理 1处理成功 2处理失败, cost_time_ms INT DEFAULT NULL, error_msg VARCHAR(512) DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_channel_order_no (channel, pay_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT渠道回调流水表;这套表结构的关键点有三个。第一业务订单号和支付单号分开业务订单号是你们内部对外的单号不能直接暴露给渠道。第二金额一律用DECIMAL禁止用FLOAT或DOUBLE原因后面避坑章节细说。第三回调流水表要记录完整报文体万一业务处理出错你还能拿到原始数据重新解析。状态机方面支付单的状态转换路径是待支付→已支付→已退款或者待支付→已关闭。退款单则是处理中→成功/失败失败后允许重新发起退款但要换一个新的退款单号。2.3 微信与支付宝的差异隔离把渠道差异关在Service层后面两个渠道的接入方式差异很大但上层业务不该感知到。我会在Service层定义一个PayChannel接口包含createPayOrder、handleNotify、queryPayOrder、closePayOrder、refund方法然后分别实现WechatPayChannelServiceImpl和AlipayPayChannelServiceImpl。先看一张对比表它决定了你的接口设计里哪些参数是公共的哪些是渠道私有的。对比项微信支付 V3支付宝报文格式JSONRESTful 风格JSON / Form 表单签名方式商户私钥签名平台证书验签应用私钥签名支付宝公钥验签回调通知POST 加密报文AES-256-GCM 解密POST 明文参数 sign 字段回调返回约定HTTP 200 即算成功返回 success 文本才算成功下单接口POST /v3/pay/transactions/jsapialipay.trade.precreate / wap / app订单号来源服务端生成 out_trade_no回调里叫 out_trade_no同名字段 out_trade_no金额单位整数分不能带小数元字符串最多两位小数退款幂等标识out_refund_noout_request_no这张表多看几遍很多联调时“为什么微信能付支付宝不行”的诡异问题最后都能在表里找到答案。比如金额单位不一致微信传的是分支付宝传的是元如果统一用一个BigDecimal字段不加转换就会遇上支付宝多付一分钱、微信少付一分钱这类翻车现场。3. 微信支付服务端接入V3接口、平台证书与回调解密落地3.1 为什么新项目一律选V3RSA2048签名与AES-256-GCM加密回调取代MD5微信支付V2接口已经是历史包袱。V2用MD5或HMAC-SHA256做签名回调报文是XML明文传输整体安全强度和报文解析体验都很差。V3改成RSA2048非对称签名回调报文用微信平台密钥做AES-256-GCM加密并且平台证书支持自动更新。新项目不用纠结直接选V3。老项目如果还在V2上至少要做两件事一是确认API key和商户号的权限已经申请了V3秘钥二是在新写的退款、查单逻辑里逐渐切换到V3接口不要在新代码里继续用V2。V3的接入依赖四个关键要素商户号mchid、商户API私钥、商户API证书用于某些接口双向认证、微信支付平台证书用于验签和加密。平台证书可以手动下载也可以通过SDK的自动更新能力动态获取我推荐后者。3.2 微信支付JSAPI下单用wechatpay-java生成预付单并组装调起参数微信支付在微信小程序、公众号里用的是JSAPI支付。服务端先调用下单接口拿到prepay_id再由客户端用这个prepay_id调起收银台。下面是一段我用wechatpay-java官方SDK写的最小下单代码// 1. 构建SDK配置。此处用商户私钥和商户号初始化 PublicKeyConfig config new PublicKeyConfig.Builder() .merchantId(mchId) // 商户号 .privateKey(privateKeyPem) // 商户API私钥PEM格式字符串 .publicKeyId(publicKeyId) // 公钥ID配置在微信支付商户平台 .publicKey(platformPublicKeyPem) // 微信支付平台公钥 .build(); // 2. 创建JSAPI服务对象 JsapiService jsapiService new JsapiService.Builder().config(config).build(); // 3. 组装下单请求amount的total是整数分 CreateOrderRequest request new CreateOrderRequest() .setAppid(appId) // 小程序或公众号的appid .setMchid(mchId) .setDescription(商品描述) .setOutTradeNo(payOrderNo) // 服务端生成的支付单号 .setNotifyUrl(notifyUrl) // 必须是公网HTTPS地址 .setAmount(new Amount().setTotal(totalFen).setCurrency(CNY)) .setPayer(new Payer().setOpenid(openId)); // 微信端用户的openid // 4. 返回prepay_id PrepayCreateResponse response jsapiService.createOrder(request); String prepayId response.getPrepayId();下单逻辑说明一下amount.total传入的是整数分不是元。我在项目里会写一个MoneyUtil.fenToYuan和yuanToFen放在渠道适配层里转换避免业务层每个地方都写一遍。参数里out_trade_no是服务端维护的支付单号最长32位只能用数字、字母、下划线。很多团队直接用订单号但如果你有退款部分退、订单退款后重新支付的需求一个业务订单号对多笔支付单会搞得状态很乱还是单独建支付单更干净。调起支付需要的prepay_id返回后客户端还需要用这个id和自己的appid、时间戳、随机串再签一次才能调起收银台。这个二次签名在服务端做更安全微信也推荐服务端算好签名参数再返回给客户端。3.3 回调接收与报文解密验签、解密、落库的成功路径微信V3回调是所有新人最容易卡住的地方。回调请求头里带了Wechatpay-Timestamp、Wechatpay-Nonce、Wechatpay-Signature以及平台证书序列号Wechatpay-Serial报文body里则是加密后的resource字段。用SDK处理时可以简化但你要理解它内部做了什么。// 接收回调的Controller PostMapping(/wechat/notify) public ResponseEntityString wechatNotify(RequestBody String body, RequestHeader(Wechatpay-Timestamp) String timestamp, RequestHeader(Wechatpay-Nonce) String nonce, RequestHeader(Wechatpay-Signature) String signature, RequestHeader(Wechatpay-Serial) String serial) { // 先落库保留原始报文再验签解密 callbackLogService.saveRaw(WECHAT, body, serial); try { // 1. 构造回调参数对象SDK内部会验证签名 RequestParam requestParam new RequestParam.Builder() .serialNumber(serial) .nonce(nonce) .signature(signature) .timestamp(timestamp) .body(body) .build(); // 2. 解密resource字段得到真实支付结果JSON AesUtil aesUtil new AesUtil(apiV3Key.getBytes(StandardCharsets.UTF_8)); String decryptBody aesUtil.decryptBody(requestParam.getBody()); // 把解密后的报文存入回调流水表 // 3. 从解密后的JSON中取出支付单号和支付状态 // 样例如下 // {out_trade_no:...,trade_state:SUCCESS,amount:{total:100,payer_total:100}} JSONObject json JSON.parseObject(decryptBody); String outTradeNo json.getString(out_trade_no); String tradeState json.getString(trade_state); if (SUCCESS.equals(tradeState)) { // 幂等更新支付单状态详见避坑章节 payOrderService.markPaidByChannel(outTradeNo, json); } return ResponseEntity.ok(); // 必须返回200微信认为通知成功 } catch (Exception e) { log.error(微信回调处理失败, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } }这段代码里authorization头在SDK内部读取报文签名。apiV3Key是在商户平台设置的API v3密钥它和API私钥、平台证书是三个不同的东西很多人把私钥当成v3密钥用导致解密一直报AEAD解密失败。security head中的serial是用来告诉SDK用哪张证书验签的。如果你的商户配置了自动更新平台证书SDK会自行匹配证书链不需要手写验签逻辑。但要注意自动更新不是一次启动就永远生效你要确认SDK版本里AutoCertificateService已经启用否则平台证书轮换后调试环境会突然报“证书不匹配”。3.4 查询订单与主动关单兜住用户“付了钱但没回调”的卡单场景回调不是100%可靠的网络抖动、服务重启、回调程序处理超时都会造成丢单。微信支付V3提供了查询订单接口和关闭订单接口它们在支付超时和用户退出收银台时是救命稻草。查询订单接口用out_trade_no去查微信侧的真实支付状态QueryOrderByOutTradeNoRequest queryRequest new QueryOrderByOutTradeNoRequest() .setMchid(mchId) .setOutTradeNo(payOrderNo); QueryOrderResponse response jsapiService.queryOrderByOutTradeNo(queryRequest); // 返回的trade_state有SUCCESS、REFUND、NOTPAY、CLOSED等我的服务端逻辑会在下面几个时机调查询接口客户端支付完成后主动请求服务端确认支付结果服务端先查一次支付单状态如果还是待支付则调渠道查单。定时任务扫描超过N分钟仍未支付的支付单对每单调一次查单接口如果渠道侧显示已支付但本地未更新立即补单。支付有效期过期后调用关单接口防止用户拿着过期的prepay_id继续支付。关单接口是PUT /v3/pay/transactions/out-trade-no/{out_trade_no}/close调用后该单不可再支付。注意关单只对“待支付”状态的订单有效如果渠道侧已经是已支付关单会报错这时就该走查单补单而不是关单。4. 支付宝服务端接入密钥体系、异步通知验签与退款幂等4.1 支付宝密钥体系与异步通知机制App私钥、支付宝公钥、AES密钥各管什么支付宝的接入方式和微信差异很大。你只需要一对密钥应用私钥和支付宝公钥。应用私钥由你在支付宝开放平台生成用来给请求参数签名支付宝公钥由支付宝提供用来验证回调通知的签名。这里的证书体系有两种模式公钥模式和证书模式。证书模式要为每个环境生成CSR文件再上传适合大型应用绝大多数项目用公钥模式就够了。还有一点容易弄混支付宝开放平台的“应用网关”不是回调地址它用于接收支付宝的一些系统消息和支付回调的notify_url没关系。支付宝的异步通知参数是放在POST表单里的没有加密核心参数包括notify_time、notify_type、trade_no、out_trade_no、trade_status、total_amount、buyer_id、sign。其中sign就是支付宝用私钥签出来的值你要用支付宝公钥验签。trade_status字段的取值要特别留意WAIT_BUYER_PAY表示等待付款TRADE_SUCCESS表示交易支付成功TRADE_FINISHED表示交易结束且不可退款。服务端只有在TRADE_SUCCESS和TRADE_FINISHED时才算支付成功不能把WAIT_BUYER_PAY当成成功。4.2 服务端预下单用AlipayEasySDK拿到可用的二维码或支付串支付宝服务端下单走的是alipay.trade.precreate扫码支付、alipay.trade.wap.pay手机网站支付或alipay.trade.app.payApp支付。这三个接口的入参基本一致我以扫码支付为例展示EasySDK的用法。// 构建支付宝客户端配置使用公钥模式 Factory.setOptions(new Config() .protocol(https) .gatewayHost(openapi.alipay.com) .signType(RSA2) .appId(appId) .merchantPrivateKey(privateKey) // 应用私钥字符串 .alipayPublicKey(alipayPublicKey)); // 支付宝公钥字符串 // 调用预下单接口 AlipayTradePrecreateResponse response Factory.Payment.Precreate() .create(商品标题, payOrderNo, totalAmountYuan); // 返回的是二维码内容客户端据此生成二维码 String qrCodeContent response.getQrCode();这里create方法第二参数是out_trade_no第三参数totalAmount是元字符串。支付宝对金额的要求是“元最多两位小数”和微信的“分”正好相反。我一般在服务层统一把金额转成元字符串传给支付宝适配层所有金额运算还是用BigDecimal做不直接拼接字符串。支付宝下单接口的响应里还有一个关键字段out_trade_no对应的渠道订单号trade_no它是支付宝侧的全局唯一单号。回调通知里带的就是这个trade_no你的流水表里必须把trade_no和本地支付单号关联起来否则后续对账你都不知道支付宝账单里那一笔是哪一单。4.3 异步通知验签与状态更新返回文本是success还是failure支付宝回调处理的核心是验签和TradeStatus转换代码骨架如下PostMapping(/alipay/notify) public String alipayNotify(HttpServletRequest request) { MapString, String params new HashMap(); MapString, String[] requestParams request.getParameterMap(); for (String key : requestParams.keySet()) { String[] values requestParams.get(key); params.put(key, String.join(,, values)); } // 1. 先落原始参数到回调流水表 callbackLogService.saveRaw(ALIPAY, JSON.toJSONString(params), null); // 2. 验签 boolean signVerified AlipaySignature.rsaCheckV1( params, alipayPublicKey, UTF-8, RSA2); if (!signVerified) { return failure; } // 3. 处理业务状态 String tradeStatus params.get(trade_status); String outTradeNo params.get(out_trade_no); String tradeNo params.get(trade_no); if (TRADE_SUCCESS.equals(tradeStatus) || TRADE_FINISHED.equals(tradeStatus)) { payOrderService.markPaidByChannel(outTradeNo, params); } // 4. 只有返回success支付宝才会停止重试 return success; }注意第4步的差异微信回调重试的条件是“非200响应”支付宝回调重试的条件是“没收到success文本”。如果你把微信的处理方式照搬到支付宝return的是一个空字符串支付宝会认为通知失败然后每隔25小时左右重发通知最久重试8次。很多团队反馈“支付宝重复入账”没准就是这里埋的雷。还有一个坑是参数值里可能包含逗号比如买家留言、商品名称如果直接按逗号拆分会导致验签失败。上面代码里用String.join(,, values)是兼容多值参数的写法验签时也使用这个处理后的Map不要用原始数组去验。4.4 退款接口与服务端幂等out_request_no是防重复退款的命根子支付宝退款接口是alipay.trade.refund微信退款接口是POST /v3/refund/domestic/refunds两者都支持部分退款和多次退款但幂等标识字段名不同支付宝叫out_request_no微信叫out_refund_no。先看支付宝退款// 按out_trade_no退款refundAmount是元字符串 AlipayTradeRefundResponse response Factory.Payment.Common() .refund(outTradeNo, refundAmountYuan); if (response.getCode().equals(10000)) { // 退款成功更新退款单状态 refundOrderService.markSuccess(refundNo, response.getTradeNo()); } else { // 记录渠道错误码例如“退款请求失败” refundOrderService.markFail(refundNo, response.getSubMsg()); }再看微信退款RefundService refundService new RefundService.Builder().config(config).build(); CreateRefundRequest request new CreateRefundRequest() .setOutTradeNo(payOrderNo) // 原支付单号 .setOutRefundNo(refundNo) // 服务端生成的退款单号 .setNotifyUrl(refundNotifyUrl) // 退款结果回调 .setAmount(new RefundAmount() .setRefund(totalFen) // 本次退款金额单位分 .setTotal(totalFen) // 原单实际支付金额单位分 .setCurrency(CNY)); RefundCreateResponse response refundService.create(request);这里微信退款的入参里setTotal、setRefund、setCurrency三个字段缺一不可。很多刚接退款的人只传setRefund结果微信报“参数错误”。退款单号在设计上要保证全局唯一。不能复用原支付单号这样每次退款都有独立的可追踪标识渠道侧才能做幂等。如果你不小心把相同的退款单号提交两次微信和支付宝都会返回“重复退款请求”或直接复用上一次的结果不会真退两笔钱但你的退款单表里会出现两条记录对账时很痛苦。支付宝退款接口是同步返回退款结果的微信V3退款默认是异步你调用后拿到HTTP 200不代表退款完成需要通过退款查询或退款回调确认最终状态。我一般在退款单状态为处理中时用一个延迟任务在30秒后查一次退款结果而不是一直挂起等待。5. 支付与退款的避坑清单证书、幂等、丢单和金额精度5.1 证书与密钥玄学周一早上支付全部失败接口却报“证书不匹配”现象新的一周开始线上订单大面积支付失败日志里报“java.security.cert.CertificateException: Certificate expired”。原因微信支付平台证书有效期是5年很多人接入后从不管证书更新。到了证书换绑期你本地的平台证书和微信侧新证书不一致所有验签都会失败。另一种常见情况是多人维护配置改动了application.yml里的私钥字符串多了一个换行符或空格。解决微信支付V3的SDK支持AutoCertificateService自动更新平台证书生产环境务必开启。对于手工管理证书的项目定一个每季度检查证书到期时间的定时任务提前一周替换。另外私钥字符串建议放在配置中心或环境变量里不要直接写进代码仓库很多证书读取失败问题其实只是文件路径不对或PEM格式被IDE格式化工具改坏了。5.2 重复回调与并发更新幂等判断的两种可靠写法现象用户支付成功后发现订单被更新了两次或者支付流水表出现两条成功记录。日志里能看到几秒内同一个回调报文被处理了两遍。原因微信和支付宝的通知机制都有重试。回调处理成功但返回响应超时平台会重新发送你的服务处理中遇到异常也会触发重试。如果不做幂等状态更新就会重复执行。解决幂等判断不能只靠“如果status待支付再更新”因为并发下两个请求同时读到待支付状态都会通过判断。我一般用两种方式组合数据库层用状态机约束UPDATE t_pay_order SET status1, paid_atnow() WHERE pay_order_no? AND status0返回受影响行数为0时说明已经被处理过。用回调流水表的唯一索引去重在t_channel_callback_log上建一个(channel, notify_id)唯一索引插入重复记录时捕获DuplicateKeyException直接返回成功不再处理。经验是先执行UPDATE再依据返回行数决定后续动作比“先SELECT再UPDATE”可靠得多。5.3 金额单位与时间精度数据库不要用FLOAT回调时间必须当作东八区处理现象微信支付回调金额用分代码里直接除以100转元结果出现19.99变成19.989999。账单核对时发现和渠道差几分钱。原因金额用Double或Float存储浮点数无法精确表示小数。另一个低级的坑是时区渠道回调时间戳是UTC或标准时间戳直接存进DATABASE里用了UTC时区的TIMESTAMP前端显示的时间比用户实际支付时间早了8小时。解决存钱一律用DECIMAL(10,2)Java侧用BigDecimal金额换算写一个工具方法BigDecimal.valueOf(totalFen).divide(BigDecimal.valueOf(100))。时间统一在MySQL连接串和JVM参数里设置Asia/Shanghai回调时间戳转换时也明确指定时区不依赖服务器默认时区。5.4 丢单与卡单排查回调到了、验签也过了、订单为什么还是待支付现象用户说“钱扣了但订单一直显示待支付”。去数据库查回调流水表里有记录验签结果也是成功但支付单的status没变。原因这类问题常见原因有三个。第一回调线程里事务没提交查单接口读到旧数据。第二回调更新时按业务订单号去更新但支付单表里的biz_order_no有多个支付单记录UPDATE影响多行或走错索引。第三处理回调时抛了异常但日志没打到业务日志里被全局异常处理器吞了。解决我的排查路径是——先看回调流水表process_status如果处理失败看error_msg同时拿支付单号去渠道侧查单确认渠道侧状态。处理回调时禁止把“落库原始报文”和“更新支付单”放在同一个事务里先落库、再验签、最后更新任一步失败都不影响原始数据留存。更新条件用pay_order_no不用biz_order_no因为一个业务订单可能对应多笔支付单。还有一个小技巧在联调环境用Charles抓一下平台发出的回调报文确认notify_url是否真的收到了POST请求。不少“回调没来”其实是沙箱环境配置了内网地址公网请求根本打不进来。5.5 对账不平先别慌微信账单和本地订单对不上是常态现象拉取微信或支付宝的日账单和本地支付单比对发现微信侧有一笔成功记录本地没有或者本地有退款账单里没看到。原因对账不平不一定是你代码错了。可能原因包括当天的回调处理延迟跨天才入账用户支付后立刻退款账单里同时出现支付和退款两条记录渠道侧支付成功但回调彻底丢失本地一直是待支付。还有微信的账单文件里退款和支付是分开的类型字段统计时容易漏。解决我建议的对账方式是按天跑批把渠道账单和本地支付单的金额、单号做全量比对不要只比“成功的支付单”。微信账单里trade_type包括SUCCESS、REFUND、REVOKED支付宝对账单也有对应的交易状态要把撤销和退款都纳入比对范围。对账发现差异后不自动改库而是先落差异单人工或半自动处理后入账。这个能力必须在支付上线第一天就建好等账单金额对不上了再开发你会被财务反复找。6. 支付模块上线前的最后一道工序日终对账与自动退款补偿6.1 日终对账跑批用一张差异表把财务问题变成技术问题每个渠道都提供了下载对账单的接口微信是GET /v3/bill/tradebill支付宝是alipay.data.bill.bill.query。我一般用一个定时任务每天凌晨2点拉取T-1天的账单文件解析后和本地支付单比对生成t_pay_diff_record表。对账逻辑不复杂但要对齐三类数据本地有支付单但账单没有、账单有但本地没有、两边都有但金额不一致。每一类都对应不同的处理路径。账单有本地没有的单子优先用渠道查单接口补单补不回来再人工介入。我在上线第一个月吃过一次亏只比较了支付成功单忽略了退款单结果每天对账都差一大截。后来把退款也纳入对账维度差异表里加上diff_type字段区分支付差异、退款差异、金额差异问题才真正收敛。6.2 自动退款补偿支付成功但订单超时未发货的处理闭环业务上还有一种常见的补偿需求支付成功但后来业务方判定无法履约需要自动退款。我建议把它做成一个定时扫描任务不是靠用户点“申请退款”。伪代码大致是这样Scheduled(fixedDelay 30000) public void autoRefundExpiredOrders() { // 找出支付成功、超过履约时限、退款状态为空的支付单 ListPayOrder orders payOrderMapper.selectExpiredUnfulfilledOrders(); for (PayOrder order : orders) { // 幂等创建退款单已存在的直接跳过 RefundOrder refund refundOrderService.createForOrder(order); try { refundChannel(refund); // 按渠道分发到微信或支付宝退款适配层 } catch (Exception e) { // 记录失败原因退款单保留为“处理中”下次扫描重试 refundOrderService.recordError(refund, e.getMessage()); } } }这个任务的关键是“幂等创建退款单”。我加了唯一索引refund_no用订单号退款原因生成固定refund_no这样即使任务重复执行也不会对一个订单发起两笔退款。退款失败的订单保留在“处理中”状态重试间隔拉长到5分钟、15分钟、1小时超过3次转人工。我自己的经验是支付模块的稳定性不是靠“不写Bug”而是靠把异常场景都变成可补偿的流程。回调丢了有对账补单退款失败有定时重试金额不对有差异表。把这三个闭环做好哪怕代码丑一点线上都不会出大事。最后一句话算是我这几年做支付服务的习惯每次上线前先把对账跑批的开关打开再谈支付体验优化。希望帮到你。本文还有配套的精品资源点击获取