简介针对 Java 后端接入 H5 微信支付的常见需求这份示例包面向电商网站、移动端网页交易等场景的初中级开发者围绕统一下单、预支付会话标识获取、H5 支付链接生成、异步回调验签、订单查询及异常重试等环节提供可参考的代码骨架与实现思路。压缩包共 11 个文件、约 16KB其中 5 个 Java 源文件承载支付逻辑3 个 XML 与 1 个 properties 文件用于项目依赖和参数配置另附 1 个 doc 部署说明和 1 个 HTML 测试页面结构紧凑便于快速定位关键代码与配置项。已有 1322 人学习下载。借助其中的流程代码、回调处理示例和部署文档读者可以梳理 Java 后端与微信支付接口的对接路径理解签名生成、支付结果校验、测试环境与正式环境切换等易错点并以此为模板搭建自己的 H5 支付联调环境减少从接口文档到可运行代码之间的摸索成本。1. 一句需求背后藏着一整条链路某开发者接了个活客户的原话就一句手机上点“立即支付”跳到微信把钱付了付完回到我们页面订单自己变成已支付。他在群里问有没有现成的 JavaH5微信支付 方案我把手上这套代码发了过去。这份资源解决的就是这条链路外部手机浏览器下单、跳转、异步回调、主动查单、退款整套在 Java 后端跑通。它适合两类人——手上有个 Spring Boot 项目要快速接上 H5 支付的以及已经接了但回调老丢、订单状态对不上的。它不负责商户号申请、资质审核那些运营动作只管钱从下单到落账这一段代码怎么写才不出错。2. 项目定位与技术选型H5 支付在整个系统里站在哪一层先把这个模块的边界划清楚。它不是一个独立跑的服务而是嵌在业务系统里的一个支付能力层上游是订单、商品、营销这些业务模块下游是支付渠道的 HTTP 接口。业务侧调用时只传业务订单号、金额、商品描述、用户 IP拿回一个可以跳转的 URL支付结果由渠道回调进来模块负责验签、解密、落库、通知业务方业务方只管发货或者改状态。这个边界定得好不好直接决定了后面加渠道、换渠道有多痛。我见过不少项目把 HTTP 调用、签名、业务状态判断全糊在一个 Service 里两百多行加个新渠道要复制一遍改个金额字段要全文搜索。这套代码的分层就是照着我被坑过的方向切的。2.1 三条链路缺一条都会出事故H5 支付看起来只有“下单—付款—回调”三步实际跑起来是三条并行又互相关联的链路少做一条线上就等着收投诉。第一条是下单链路后端调渠道下单接口拿到h5_url前端跳过去。这条链路的核心是参数拼对、金额单位对、回调地址对。第二条是回调链路用户付完款渠道主动 POST 到你的notify_url。这是订单状态变更的主要来源但它不可靠——网络抖动、服务重启、验签失败被拒都可能让你漏掉。而且渠道会重试同一条通知可能来七八次。第三条是查单链路定时任务扫出“待支付超过 N 分钟”的订单主动去渠道问一句“这单到底付了没”。这是兜底也是唯一可信的真相来源。用户付完钱没跳回来、回调丢了、回调处理到一半服务挂了全靠它捞回来。提示别把回调当唯一信号。我一般约定“回调负责快、查单负责准”两个入口走同一段状态流转代码谁先到谁生效后来的因为是幂等更新直接忽略。三条链路最后汇到一个订单状态机上这也是整套代码里最值得看的部分。状态大致是INIT → PAYING → PAID超时未付走CLOSED付款后走REFUNDING → REFUNDED。每次状态流转都带条件WHERE status 期望的旧值保证并发下只成功一次。2.2 技术栈与分层骨架项目的技术栈没什么花活就是国内 Java 后端最常规的一套组合Spring Boot 做容器MyBatis-Plus 或者 JPA 做持久化MySQL 存订单和流水Redis 做回调去重和分布式锁渠道对接用官方提供的 Java SDK。Spring Boot 2.x 和 3.x 都能跑差异主要在javax.*和jakarta.*的包名上迁移时改个 import 就行。依赖部分最关键的是渠道 SDK不建议自己手撸 HTTP 和签名!-- 渠道官方 Java SDK负责签名、验签、证书下载、报文加解密 -- dependency groupIdcom.github.wechatpay-apiv3/groupId artifactIdwechatpay-java/artifactId version${wechatpay.version}/version /dependency !-- 下面几个是常规配角按需引入 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId /dependency自己拼签名串的年代我也经历过SHA256withRSA、拼\n、Base64、再算Authorization头少一个换行符就是 401日志里还看不到具体原因纯玄学调试。SDK 把这些都收走了代价是你得理解它内部的证书自动更新机制否则出了证书问题一样抓瞎。分层上这套代码切了四层每层职责单一层次包名示例职责不该做的事接口层controller接收业务下单请求、接收渠道回调不写业务判断不碰 SDK业务层service订单创建、状态机流转、幂等控制不拼 HTTP 报文不处理证书渠道网关层gateway调 SDK、组装请求、解析响应不直接改业务订单表基础设施层config/mapper配置绑定、密钥装载、持久化不含任何支付语义渠道网关层是这套设计里我最满意的一块。它对外只暴露一个接口prepay(订单信息) → 支付跳转地址内部怎么调 SDK、用哪个接口、参数怎么组织业务层完全不知道。将来要接第二个支付渠道加一个实现类就行业务代码一行不改。技术上还有两个容易忽略的选型点。一是金额类型全程用long存“分”别用double也别用BigDecimal一路传到底——数据库存bigintDTO 里用BigDecimal接前端传来的元再乘 100 转成long只在最外层做一次转换。二是时间渠道要求的时间格式是带时区的 RFC3339比如2024-05-01T10:00:0008:00用LocalDateTime直接toString()会少时区格式化成字符串这一步不能省。3. 目录结构与核心模块拆解从 Controller 到签名工具拿到一份支付代码我第一眼看目录结构第二眼看配置类第三眼看签名工具。这三处对了中间的流程基本不会有结构性问题。3.1 工程目录与数据表src/main/java/com/example/pay ├── controller │ ├── PayController.java # 业务侧下单、查单入口 │ └── PayNotifyController.java # 渠道异步回调入口 ├── service │ ├── PayOrderService.java # 订单创建与状态机 │ └── impl/PayOrderServiceImpl.java ├── gateway │ ├── PayChannel.java # 渠道抽象 │ ├── WxH5PayChannel.java # H5 支付实现 │ └── model/ # 下单/查单的入参出参对象 ├── config │ ├── WxPayProperties.java # 配置绑定 │ └── WxPayConfig.java # SDK 客户端与证书装载 ├── domain │ ├── entity/PayOrder.java │ └── enums/PayStatus.java └── mapper/PayOrderMapper.java数据库这边最少要两张表做扎实一点是四张CREATE TABLE pay_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_order_no VARCHAR(64) NOT NULL COMMENT 业务订单号, out_trade_no VARCHAR(32) NOT NULL COMMENT 提交给渠道的商户订单号, amount BIGINT NOT NULL COMMENT 金额单位分, status VARCHAR(16) NOT NULL COMMENT INIT/PAYING/PAID/CLOSED/REFUNDED, transaction_id VARCHAR(64) DEFAULT NULL COMMENT 渠道支付单号, notify_url VARCHAR(255) NOT NULL, expire_time DATETIME NOT NULL COMMENT 订单过期时间, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_out_trade_no (out_trade_no), UNIQUE KEY uk_biz_order (biz_order_no), KEY idx_status_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE pay_notify_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_trade_no VARCHAR(32) NOT NULL, body TEXT NOT NULL COMMENT 原始报文排查用, verify_result TINYINT NOT NULL COMMENT 1 通过 0 失败, handled TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_out_trade_no (out_trade_no) );pay_order上的两个唯一索引是防重的第一道闸门业务订单号唯一防止用户连点两次生成两笔支付单商户订单号唯一防止重复提交到渠道。idx_status_expire是给补偿定时任务用的扫“超时未支付的 PAYING 单”走这个索引别让它全表扫。pay_notify_log这张表很多人会省掉我劝你别省。回调出问题时第一件事就是看原始报文长什么样、验签过没过。没有这张表你只能靠渠道后台的日志去猜那才是真正的黑匣子。3.2 配置绑定与密钥装载配置用ConfigurationProperties绑定别用Value散落一地——二十个字段散在十个类里改配置就得全局搜索。wxpay: app-id: wx0000000000000000 # 应用唯一标识 mch-id: 1900000000 # 商户号字符串别写成数字 merchant-serial-number: 5157F09EFDC096DE15EBE81A47057A72 # 商户证书序列号 private-key-path: /opt/certs/apiclient_key.pem # 商户私钥 api-v3-key: 32位随机字符串 # 用于回调解密不是签名 notify-url: https://pay.example.com/api/pay/notifyData ConfigurationProperties(prefix wxpay) public class WxPayProperties { private String appId; private String mchId; private String merchantSerialNumber; private String privateKeyPath; private String apiV3Key; private String notifyUrl; }参数说明上有两个坑我要单独拎出来。mch-id一定要声明成StringYAML 里也要加引号否则会按数字解析某些场景下前面的 0 会被吃掉。api-v3-key是回调报文解密用的对称密钥不是用来签名的和商户私钥完全是两回事我见过填反了查三天的情况。SDK 客户端的初始化放在配置类里全局一个 BeanConfiguration EnableConfigurationProperties(WxPayProperties.class) public class WxPayConfig { Bean public RSAAutoCertificateConfig rsaConfig(WxPayProperties props) throws IOException { // 私钥从文件读不要硬编码进代码或配置文件 PrivateKey merchantPrivateKey PemUtil.loadPrivateKey( new FileInputStream(props.getPrivateKeyPath())); return new RSAAutoCertificateConfig.Builder() .merchantId(props.getMchId()) .privateKey(merchantPrivateKey) .merchantSerialNumber(props.getMerchantSerialNumber()) .apiV3Key(props.getApiV3Key()) // 自动下载并缓存平台证书证书轮换时自动更新 .build(); } Bean public H5Service h5Service(RSAAutoCertificateConfig config) { return new H5Service.Builder().config(config).build(); } }RSAAutoCertificateConfig这个类是整套对接里最省事的东西它启动时用商户私钥调接口把平台的证书拉下来缓存在内存里验签时按回调头里的Wechatpay-Serial找对应证书遇到平台换证书也会自动重下。老版本 SDK 需要你手动下载证书、手动定时刷新那块代码我写过一次定时任务加证书序列号比对出问题还特别难查。注意私钥文件权限设成600放在容器镜像外面挂载进去别打进 jar。线上出过一次私钥被提交到仓库的事故回滚代码没用得去渠道后台重置证书流程走了一周。3.3 签名与验签理解串是怎么拼的用 SDK 不代表可以不理解签名。排查问题时你迟早要手工复算一次。APIv3 的请求签名串由五行组成每行以换行符结尾最后一行也有换行符HTTP请求方法\n URL带 query string\n 时间戳\n 随机串\n 请求报文主体\n/** * 手工构造签名串并计算签名仅用于排查问题与自检正常调用走 SDK */ public static String buildSignature(String method, String url, long timestamp, String nonce, String body, PrivateKey key) throws Exception { // 注意每一段后面都要跟 \n包括最后的 body String message method \n url \n timestamp \n nonce \n body \n; Signature signer Signature.getInstance(SHA256withRSA); signer.initSign(key); signer.update(message.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(signer.sign()); }method必须是大写url必须是带 query 的完整路径比如/v3/pay/transactions/h5如果有参数要带?a1body是原始 JSON 字符串——不能是重新序列化后的对象字段顺序变了签名就对不上这是最典型的翻车点。算出来的 Base64 串拼进Authorization: WECHATPAY2-SHA256-RSA2048 mchid...,nonce_str...,signature...,timestamp...,serial_no...这个头里。回调的验签串只有三行时间戳\n随机串\n报文主体\n用的证书是平台证书按回调头Wechatpay-Serial选不是商户自己的。这两个串格式不一样别搞混。4. 下单与调起支付一次 H5 支付的完整代码路径这一章是能直接抄的部分。从业务方调接口开始到用户在浏览器里看到支付页中间每一步都有具体写法。4.1 构造下单请求字段填错就是 400先看网关实现类的核心方法。这里用h5_url的写法对应新版接口老项目如果还在用返回mweb_url的旧接口逻辑一样只是字段名不同。Service RequiredArgsConstructor public class WxH5PayChannel implements PayChannel { private final H5Service h5Service; private final WxPayProperties props; Override public String prepay(PrepayCommand cmd) { Amount amount new Amount(); amount.setTotal(cmd.getAmountFen()); // 单位分long 类型 amount.setCurrency(CNY); // 场景信息H5 支付这两个字段是必填的 SceneInfo sceneInfo new SceneInfo(); sceneInfo.setPayerClientIp(cmd.getClientIp()); // 用户真实公网 IP H5Info h5Info new H5Info(); h5Info.setType(Wap); // 固定值 sceneInfo.setH5Info(h5Info); PrepayRequest request new PrepayRequest(); request.setAppid(props.getAppId()); request.setMchid(props.getMchId()); request.setDescription(cmd.getSubject()); // 商品描述不能有特殊字符 request.setOutTradeNo(cmd.getOutTradeNo()); // 商户订单号6-32 位 request.setNotifyUrl(props.getNotifyUrl()); // 必须是公网 https不能带参数 request.setAmount(amount); request.setSceneInfo(sceneInfo); request.setTimeExpire(cmd.getExpireTime()); // RFC3339 格式带时区 PrepayResponse response h5Service.prepay(request); return response.getH5Url(); } }逐段说。amount.setTotal()的单位是分是long不是BigDecimal。前端传 9.9 元你这里必须是 990。这个字段我见过至少三次事故一次是乘了 100 又乘了一次用户付了 990 元一次是int强转溢出金额变成负数被渠道拒绝。payerClientIp传的是用户的公网出口 IP。取值方式上如果前面有 Nginx从X-Forwarded-For取第一个非内网地址别直接取request.getRemoteAddr()那样拿到的全是网关的内网 IP。取错了不会立刻报错但渠道侧的风控会受影响表现是偶发下单失败。description有字符限制中文、字母、数字没问题、#、这类特殊字符会导致下单失败服务端最好做一次清洗。outTradeNo只能由字母、数字和_、-、|、*组成长度 6 到 32 位。很多项目喜欢用 UUID 去掉横线正好 32 位能用但如果你的业务单号里有中文或者点号必须做一层映射别硬塞。timeExpire的格式必须是yyyy-MM-ddTHH:mm:ssXXXJava 里用DateTimeFormatter.ofPattern(yyyy-MM-ddTHH:mm:ssXXX)格式化OffsetDateTime直接用LocalDateTime会丢掉时区导致解析失败。4.2 返回跳转地址前端怎么用后端要不要 302拿到h5_url之后有两条路。前端拿到 JSON 自己跳// 前端拿到 h5_url 后直接跳转 const res await fetch(/api/pay/prepay, { method: POST, body: JSON.stringify({ bizOrderNo: B20240501001 }) }); const { h5Url, outTradeNo } await res.json(); // 记下 outTradeNo回来后轮询查状态 sessionStorage.setItem(payOrderNo, outTradeNo); window.location.href h5Url;或者后端直接返回 302让浏览器自己走GetMapping(/pay/redirect/{bizOrderNo}) public void redirect(PathVariable String bizOrderNo, HttpServletResponse response) throws IOException { String h5Url payOrderService.prepayAndGetUrl(bizOrderNo); // 回跳地址必须 URLEncode 后拼在 h5_url 后面 String redirect URLEncoder.encode(https://m.example.com/pay/result?no bizOrderNo, UTF-8); response.sendRedirect(h5Url redirect_url redirect); }两条路都行后端 302 的好处是回跳地址由服务端控制前端改不了坏处是前端拿不到out_trade_no没法做支付结果轮询。我一般用第一种回跳地址在下单时就通过redirect_url参数带上。这里有两个必须满足的前置条件不满足的话页面直接打不开一是下单域名要在商户后台报备不在白名单里会被拦截二是redirect_url必须是 https 且已备案否则支付完成后回跳会失败。这两个都是后台配置不是代码能解决的本地调试时经常卡在这一步。注意H5 支付只对外部浏览器生效。在微信内置浏览器里打开同一个页面会提示“不支持该支付方式”那种场景要用公众号支付靠openid走另一套接口。很多人第一次测就在微信里点然后怀疑代码有问题白折腾半天。4.3 查单与关单补偿任务的正确写法查单是兜底链路靠定时任务跑Scheduled(fixedDelay 60_000) public void compensate() { // 1. 捞出 2 分钟前还是 PAYING 且未过期的单子 ListPayOrder orders payOrderMapper.selectPending( LocalDateTime.now().minusMinutes(2), LocalDateTime.now()); for (PayOrder order : orders) { try { QueryOrderByOutTradeNoRequest req new QueryOrderByOutTradeNoRequest(); req.setMchid(props.getMchId()); req.setOutTradeNo(order.getOutTradeNo()); Transaction t h5Service.queryOrderByOutTradeNo(req); // SUCCESS 才流转状态其余保持不动 if (SUCCESS.equals(t.getTradeState())) { payOrderService.markPaid(order.getOutTradeNo(), t.getTransactionId(), t.getSuccessTime(), QUERY); } } catch (Exception e) { log.warn(查单失败 outTradeNo{}, order.getOutTradeNo(), e); } } }参数说明selectPending的查询条件不要用“创建时间在 2 分钟前”就完事要同时卡住status PAYING和expire_time now()否则会去查一堆早就该关单的订单白白浪费调用量。查单返回的tradeState有好几种值SUCCESS是支付成功NOTPAY是未支付CLOSED是已关闭REFUND是转入退款。只有SUCCESS才改状态其他值原样保留等下一轮再查。这里千万别写成“不是 SUCCESS 就当失败关单”——用户可能正在输密码。关单走单独的接口用于订单超时后主动关闭CloseOrderRequest req new CloseOrderRequest(); req.setMchid(props.getMchId()); req.setOutTradeNo(order.getOutTradeNo()); h5Service.closeOrder(req);关单也会失败最常见的原因是用户其实已经付了但回调还没到。所以关单失败时不要立刻置为CLOSED先查一次单确认是NOTPAY或CLOSED再改状态。这个顺序反了就会出现“用户付了钱、订单被关掉”的对账事故。5. 避坑与排查H5 支付最容易栽的五个地方下面这五条都是真实踩过的按“现象 → 原因 → 解决”摆出来遇到问题可以对号入座。5.1 下单与跳转阶段一、下单接口报 400返回体里只有一个笼统的错误码。现象本地调试正常发到测试环境就 400日志里看不出哪个字段有问题。原因接口对字段校验很严description里有特殊字符、out_trade_no长度不够或者带非法字符、time_expire格式少时区都会返回 400 且不告诉你具体字段。另外mch-id在 YAML 里没加引号被当成数字序列化后位数不对也是 400。解决把请求体原始 JSON 完整打到日志里脱敏后逐字段对照文档mch-id、app-id一律用字符串time_expire统一走同一个格式化工具方法别在每个调用点手写。上线前用一份固定的检查清单过一遍字段比事后排查快得多。二、跳转过去提示“当前域名未配置”或者直接白屏。现象h5_url拿到了浏览器一打开就报错。原因三种可能——下单域名没在商户后台报备redirect_url不是 httpsh5_url被复制时把后面的参数截断了。还有一种隐蔽的payer_client_ip传了内网地址风控判定异常跳转页直接不给展示。解决先确认报备域名和实际访问域名完全一致含子域名redirect_url强制 https 并做URLEncoder.encode取 IP 时检查X-Forwarded-For第一段是不是公网。这三步走完90% 的跳转问题能定位。三、在微信里打开测试一直提示不支持。现象同事用微信扫二维码打开测试页点了没反应。原因H5 支付的设计场景就是外部浏览器微信内置浏览器里不适用需要走另一套基于openid的支付方式。解决测试时明确用系统浏览器或者把链接发到别的 App 里打开。产品文档里最好写清楚“微信内打开请引导用户点右上角在浏览器中打开”别让用户自己猜。5.2 回调与对账阶段四、回调收不到或者收到但验签一直失败。现象用户说付款成功了后台订单还是待支付回调日志里verify_result 0。原因notify_url是 http、带了 query 参数、指向内网地址或者非 443 端口渠道根本推不过来。验签失败的常见原因是把商户私钥当成了平台证书来验签或者api-v3-key填错了这个 key 只用于报文解密不参与验签但填错会导致解密抛异常看起来像验签失败。解决notify_url必须是公网可访问的 https 地址不带任何参数路径别用中文验签用 SDK 的NotificationParser它会按回调头里的证书序列号自动选证书别自己拼本地调试时用内网穿透工具映射一个公网地址临时改配置指向它调完记得改回来。回调入口一定要打日志把原始报文和请求头都存下来否则出了问题全靠猜。五、同一笔回调来了五次货发了五份。现象日志里同一个out_trade_no的处理记录出现多条库存被扣了好几次。原因渠道的重试机制决定了回调必然重复至少一次投递。如果处理逻辑只是“查到订单 → 改成已支付 → 通知业务发货”没有任何幂等控制就会重复执行。解决三层防护。数据库层在pay_order上对out_trade_no加唯一索引状态流转用条件更新UPDATE pay_order SET statusPAID WHERE out_trade_no? AND statusPAYING影响行数为 0 就直接返回成功不再往下走业务发货那一步单独做幂等用out_trade_no做业务侧的唯一键。三层都加上才算稳。注意回调接口返回给渠道的响应体是有格式要求的成功要返回特定的 JSON 结构失败返回另一套并带message。返回错了渠道会一直重试一天几百次把服务打挂。这个细节文档里有但很容易被忽略。6. 二次开发加一个渠道、加一层对账这套代码真正值钱的地方不是“能付钱”是它留出了改的余地。渠道抽象那层接口大概是这样的public interface PayChannel { /** 渠道标识用于路由 */ String channelCode(); /** 下单返回可跳转的支付地址 */ String prepay(PrepayCommand cmd); /** 按商户订单号查单 */ PayQueryResult query(String outTradeNo); /** 关单 */ void close(String outTradeNo); /** 回调报文解析与验签返回统一结果 */ PayNotifyResult parseNotify(MapString, String headers, String body); }加一个新渠道就是写一个实现类注册成 Bean业务层通过MapString, PayChannel注入后按channelCode路由。业务代码里那个if (channel WX)的写法可以彻底删掉这是我接手老项目后做的第一件事。对账这块建议加一个日终任务按日期拉取渠道的账单文件和本地pay_order逐笔比对输出三类差异——本地有渠道无、渠道有本地无、金额不一致。第二类最危险通常是回调丢了且查单也没兜住第一类多半是本地状态没更新。差异结果落一张表第二天人工处理别做成自动补单金额的事让人看一眼更踏实。验证方法我一般分三步走。第一步用固定的测试金额跑通全链路确认状态机流转正确第二步用回调重放工具把同一条通知连发十次看订单状态和库存有没有被改两次第三步故意把notify_url改成一个不通的地址验证查单补偿任务能不能在几分钟内把订单捞回来。这三步过完基本可以上灰度了。说个教训。早期我图省事把回调处理和业务发货写在同一个事务里想着“要么都成功要么都失败”最安全。结果一次发货接口超时整个事务回滚回调返回失败渠道重试又超时循环了一晚上。后来改成回调只负责改支付状态并落一条待发货消息发货由消费方异步做各自幂等世界就清净了。从那以后我每次接支付都强制把“状态变更”和“业务动作”拆成两段中间用消息或者任务表隔开再没在这上面翻过车。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?