ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot支付网关实战:从自动装配到聚合支付设计

Spring Boot支付网关实战:从自动装配到聚合支付设计 简介这是一份免费开源的支付网关项目源码面向需要快速集成多通道支付能力的中小型系统开发者与支付中台建设者。项目以支付宝、微信、云闪付等主流渠道为基础拓展储值卡、现金卡等支付方式完整覆盖收单、退款、聚合支付、组合支付、对账与分账流程通过纯HTTP方式对外提供服务与业务系统低耦合并附带可视化后台便于统一管理支付信息。压缩包共635个文件约1.08MB其中以583个Java源码文件为核心辅以SQL数据库脚本、XML/YML配置文件、License说明及Dockerfile等目录划分清晰适合直接导入工程进行二次开发也能帮助开发者理解多支付渠道的对接与状态机设计。目前已有149人学习适合支付开发入门、技术方案选型以及自建支付网关的落地参考。1. 支付通道割裂的解法这个 Spring Boot 支付网关做了哪些事做过支付集成的都有体会支付宝一套接口、微信一套接口、云闪付又一套签名算法、回调通知、退款规则全都不一样每接一个新渠道就要重新写一遍下单、验签、异步通知处理少说要折腾一两周。这个开源支付网关把支付宝、微信、云闪付等通道统一封装成 HTTP 接口收单、退款、查单一个入口搞定还把聚合支付、组合支付、对账、分账这些高频业务场景直接做成了可配置的功能另外扩展了储值卡、现金卡等虚拟支付方式。对需要快速搭起支付中台的团队来说它是一个可以直接拿来改的基座项目前端提供可视化配置界面后端通过 HTTP 方式调用不侵入业务系统适合 Java 技术栈、正在做支付系统选型的开发者。2. 网关架构与自动装配机制从 AutoConfiguration 看模块化设计2.1 自动装配清单是怎么被加载的项目里能看到多个org.springframework.boot.autoconfigure.AutoConfiguration.imports文件这个文件名在 Spring Boot 2.7 之后取代了旧的spring.factories自动配置声明方式。每个子模块在自己的META-INF/spring/目录下放一个同名文件内容是一行一个自动配置类的全限定名Spring Boot 启动时会扫描 classpath 下所有 jar 里的这个文件再逐个加载对应的AutoConfiguration类。这样做的好处是模块边界清晰——支付核心、渠道适配、对账任务都是独立 jar不用的模块可以从pom.xml里直接剔除配置项也不会互相污染。// 一个典型的自动配置类写法 AutoConfiguration ConditionalOnProperty(prefix pay.channel.alipay, name enabled, havingValue true) EnableConfigurationProperties(AlipayProperties.class) public class AlipayAutoConfiguration { Bean ConditionalOnMissingBean public AlipayChannelProcessor alipayChannelProcessor(AlipayProperties properties) { return new AlipayChannelProcessor(properties); } }这段代码是渠道模块的开关逻辑。ConditionalOnProperty控制只有在配置文件里设置了pay.channel.alipay.enabledtrue时才创建支付宝处理器没开通的渠道连 Bean 都不会注册启动日志里也不会出现多余加载项。EnableConfigurationProperties把alipay.app-id、alipay.private-key这些前缀配置绑定成属性对象。我一般建议把这个开关和运营后台的渠道启停页面联动运营点一下按钮就改配置中心的值然后调/actuator/refresh动态生效不用重启网关。2.2 渠道适配层如何屏蔽支付宝和微信的差异统一收单 API 能成立的前提是渠道适配层做了足够的抽象。网关对外暴露的下单参数包含channel字段业务方传alipay、wechat或unionpay内部通过策略工厂拿到对应的渠道处理器。public interface UnifiedOrderProcessor { PayOrderResult createOrder(UnifiedOrderRequest request); PayOrderResult queryOrder(String outTradeNo); PayOrderResult refund(RefundRequest request); String getChannelCode(); } public class ChannelProcessorFactory { private final MapString, UnifiedOrderProcessor processorMap; public ChannelProcessorFactory(ListUnifiedOrderProcessor processors) { this.processorMap processors.stream() .collect(Collectors.toMap(UnifiedOrderProcessor::getChannelCode, Function.identity())); } public UnifiedOrderProcessor getProcessor(String channel) { UnifiedOrderProcessor processor processorMap.get(channel); if (processor null) { throw new UnsupportedOperationException(unsupported channel: channel); } return processor; } }ChannelProcessorFactory利用 Spring 的List注入拿到所有UnifiedOrderProcessor实现然后转成channelCode - processor的映射。新增一个渠道时只需要实现接口、加一个Component工厂代码完全不用改。这里有个细节渠道代码用枚举管理不要直接吃字符串否则配置中心写错一个字母就要到运行时才暴露。枚举值固定为alipay、wechat、unionpay同时兼容旧系统的ALIPAY大写形式转换逻辑放在网关入口过滤器中。对外报文统一用outTradeNo作为业务方订单号渠道侧单号channelTradeNo在异步通知回调时才回填。2.3 统一收单 API 的参数契约与超时处理网关把收单接口收敛成POST /api/v1/gateway/pay报文统一使用 JSON。RequestId 由调用方生成网关用它做幂等在实现中网关收到重复 RequestId 时直接返回上一次的处理结果而不是重新下单。同步返回的payParams是渠道侧需要的收银台参数支付宝是一段 form 表单字符串微信是 prepay_id 拼接出的调起参数业务方拿到后原样传给前端 SDK 即可。参数名类型必填说明requestIdstring是幂等键同一笔订单重试时必须一致channelstring是渠道编码alipay / wechat / unionpayoutTradeNostring是业务方订单号32 位以内totalAmountint是订单金额单位分subjectstring是商品描述会展示在支付账单里payModestring否聚合支付传 auto普通支付传 directsplitRulesjson否分账规则格式见第 4 章超时处理上网关默认下单 15 秒读不到渠道响应就标记为UNKNOWN同时启动一个延迟任务去渠道侧查单用查单结果校正订单状态而不是直接置为失败。这也是支付系统常见的一个陷阱HTTP 超时不代表支付失败可能渠道已经扣款成功只是响应包在网络里丢了。查单补偿机制能在这种情况下把状态掰回正确轨道。3. 聚合支付与组合支付实现路由策略、状态机设计与回调验签3.1 聚合支付的路由逻辑与优先级权重聚合支付就是对业务方隐藏渠道选择逻辑网关根据订单金额、支付方式可用性、渠道费率、历史成功率等因子自动选择一个最合适的渠道去下单。这里用的是一种带优先级的加权路由策略。public class AggregatePayRouter { private final ListChannelWeight channelWeights; public String route(AggregatePayContext context) { // 金额区间匹配优先于权重计算 OptionalChannelWeight matched channelWeights.stream() .filter(w - w.matches(context.getAmount())) .sorted(Comparator.comparing(ChannelWeight::getPriority).reversed()) .findFirst(); if (matched.isPresent()) { return matched.get().getChannelCode(); } // 无精确匹配时按权重随机权重来自最近30天渠道成功率动态调整 int totalWeight channelWeights.stream() .filter(w - w.isAvailable(context)) .mapToInt(ChannelWeight::getWeight).sum(); int seed ThreadLocalRandom.current().nextInt(totalWeight); for (ChannelWeight w : channelWeights) { if (w.isAvailable(context)) { if (seed w.getWeight()) { return w.getChannelCode(); } seed - w.getWeight(); } } throw new NoAvailableChannelException(no available channel for amount: context.getAmount()); } }路由先做金额区间匹配。比如 5 万以上的大额订单优先走云闪付因为它的借记卡限额高普通小额订单走微信或支付宝的默认权重。匹配不到精确区间时再按动态权重做随机兜底。ChannelWeight里的成功率权重是网关后台的定时任务每 30 分钟重算一次的算法很简单最近 30 天该渠道的成功率除以所有可用渠道成功率之和再乘以基础配额就是新权重。这样某个渠道出现大面积抖动时它的权重自动下跌流量自然迁移到健康渠道。3.2 组合支付的状态机模型组合支付是一笔订单拆成多个子支付单分别用不同渠道完成支付。例如一个 1000 元的订单500 元用微信零钱另外 500 元用支付宝余额。难点在于子支付单的状态必须聚合出总单状态而且部分成功、部分失败的情况要允许用户继续把剩余金额补完。总单状态条件可触发动作INIT订单创建完成发起子支付单PARTIAL_PAID部分子单成功总实付金额 订单金额继续支付剩余金额 / 关闭订单PAID总实付金额 订单金额通知业务方触发分账CLOSED用户主动关闭或超时未支付退回已成功的子单走原路退款代码里用状态机而非简单的 if-else 做流转因为组合支付的非法状态组合比较多——比如同一笔订单不允许出现两个同为PAYING的微信支付子单这个约束用 if-else 很难写清楚。实际项目里状态机用的是枚举驱动加状态迁移表校验每个迁移要满足from、event、guard三者同时匹配才能执行不满足的迁移直接抛出IllegalStateTransitionException并把原始事件记录到审计表。3.3 同步回调与异步通知验签的完整链路回调处理是整个网关里最容易出问题的环节。支付宝回调是 POST 表单微信支付 v3 是 HTTP 头带Wechatpay-Signature、Wechatpay-Timestamp、Wechatpay-Nonce验签方式完全不同。我先说支付宝比较常见。public boolean verifyAlipayCallback(MapString, String params) { String sign params.get(sign); String content AlipaySignature.getSignCheckContentV1(params); // 使用支付宝公钥验签非应用私钥 return AlipaySignature.rsaCheckV1(content, sign, alipayProperties.getPublicKey(), UTF-8, RSA2); }getSignCheckContentV1会把参数按 key 做字典序排序拼接剔除sign和sign_type两个字段再按 RSA2 算法用支付宝公钥验签。这里有个常见误用有人拿应用私钥去验签结果永远返回 false。记住一个原则——私钥签出来的数据只有公钥能验验签永远用对方公钥签名才用自己私钥。微信 v3 的验签逻辑是先用Wechatpay-Timestamp \n Wechatpay-Nonce \n body \n拼出待验签串再用微信支付平台证书的公钥做 SHA256withRSA 验签。验签之后要到渠道侧调用查询接口二次确认订单真实状态防止伪造回调。确认渠道单号、金额、订单号三项匹配之后才能更新本地状态。这个动作虽然多一次网络请求但能挡掉篡改回调报文和重放攻击。处理完业务逻辑之后返回success字符串给支付宝注意不要返回 JSON支付宝只认明文success其他响应都会被视为回调失败并触发多次重推。4. 对账与分账模块数据核对算法、分账规则设计与幂等保障4.1 渠道账单拉取与逐笔核对算法对账模块没有做成实时核对因为渠道侧账单 T1 才能拉取所以对账任务按日跑批。核心流程是凌晨拉取各渠道前一日账单文件解析成统一的流水模型然后和网关本地支付流水表做全量比对。比对算法用哈希分桶而不是逐条嵌套循环避免 O(n*m) 的时间复杂度。public MapDiffType, ListDiffRecord compare(ListBillLine channelBills, ListPayRecord localRecords) { MapString, PayRecord localIndex localRecords.stream() .collect(Collectors.toMap(r - r.getChannel() : r.getChannelTradeNo(), r - r, (a, b) - a)); MapDiffType, ListDiffRecord result new EnumMap(DiffType.class); for (BillLine bill : channelBills) { String key bill.getChannel() : bill.getChannelTradeNo(); PayRecord local localIndex.get(key); if (local null) { result.computeIfAbsent(DiffType.ONLY_IN_CHANNEL, k - new ArrayList()) .add(toDiffRecord(bill, null)); } else if (local.getAmount() ! bill.getAmount()) { result.computeIfAbsent(DiffType.AMOUNT_MISMATCH, k - new ArrayList()) .add(toDiffRecord(bill, local)); } // 已匹配的记录从索引移除最后剩下的就是本地有而渠道无的 localIndex.remove(key); } localIndex.values().forEach(r - result.computeIfAbsent(DiffType.ONLY_IN_LOCAL, k - new ArrayList()) .add(toDiffRecord(null, r))); return result; }算法先把本地流水按渠道:渠道单号建索引渠道侧每一条账单记录都在索引里做 O(1) 查找。中间有金额不一致的进AMOUNT_MISMATCH渠道有本地无的进ONLY_IN_CHANNEL最后索引里剩余的进ONLY_IN_LOCAL。时间差导致的掉单通常在ONLY_IN_LOCAL大多是支付成功但异步通知没收到对账任务自动触发补单流程ONLY_IN_CHANNEL则标记为高危需要人工介入处理。4.2 分账规则设计百分比与固定金额的组合模式分账在网关里的实现方式是支付成功之后由事件监听器触发分账执行器分账规则从订单扩展字段splitRules读取。规则支持两种类型——固定金额和百分比同一笔订单可以混用但总和必须等于订单实付金额否则校验失败。{ splitRules: [ { merchantId: M10001, type: PERCENT, value: 60 }, { merchantId: M10002, type: PERCENT, value: 30 }, { merchantId: M10003, type: FIXED, value: 1000 } ] }这段规则的语义是M10001 分 60%M10002 分 30%M10003 分 1000 分即 10 元前提是订单金额 100 元。分账执行时先处理 FIXED 类型再处理 PERCENT 类型因为固定金额会先从基数中扣除剩余部分再按百分比分配。分账与下单不同——子账务写的是平台内部的商户余额表不走渠道接口所以对账时只需要校验分账金额总和是否等于支付流水金额。分账流水表里记一条总账下面挂多条分账明细明细为SUCCESS状态的人工调账才有意义。4.3 幂等保障回调重放与分账重复触发的防护支付回调、分账动作都是天然的重放高风险操作。支付宝和微信的异步通知都做了多次重推机制第一次没返回成功渠道会间隔重推多次。如果每次回调都执行一遍分账商户余额会超发。CREATE TABLE pay_callback_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, channel VARCHAR(16) NOT NULL, callback_no VARCHAR(64) NOT NULL, notify_type VARCHAR(32), payload TEXT, status TINYINT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_channel_callback (channel, callback_no) ) COMMENT 渠道回调幂等表;网关收到回调后先往这张表插入一条记录依赖(channel, callback_no)唯一索引做去重。插入冲突说明这笔回调已经处理过直接返回 success 不再走业务逻辑。分账触发的幂等用的是订单号加事件类型的分布式锁锁的 key 是split:lock:{orderNo}有效期 30 秒持锁期间执行分账事务事务提交后释放。这套方案处理了互联网金融聚合支付场景下最常见的重复分账和重复加款问题。5. Docker 化部署与验证状态码、日志关键词与压测参数这个网关提供了 Dockerfile多阶段构建把编译和运行分开。构建阶段用maven:3.9-eclipse-temurin-17镜像跑打包运行阶段只拷入target目录下的可执行 jar用非 root 用户启动。配合docker compose可以把网关、MySQL、Redis 一起拉起来本地几分钟跑通完整环境。services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: paypass MYSQL_DATABASE: pay_gateway volumes: - ./init.sql:/docker-entrypoint-initdb.d/init.sql ports: - 3306:3306 redis: image: redis:7-alpine ports: - 6379:6379 gateway: build: . depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: dev DB_HOST: mysql REDIS_HOST: redis ports: - 8080:8080启动完成后用 curl 验证健康状态和下单链路完整连通# 健康检查 curl -s http://localhost:8080/actuator/health | jq .status # 模拟一笔支付宝直连收单 curl -s -X POST http://localhost:8080/api/v1/gateway/pay \ -H Content-Type: application/json \ -d { requestId: req-20240521-001, channel: alipay, outTradeNo: order-20240521-001, totalAmount: 1000, subject: test product }返回体中应该包含一个payParams字段其中带有alipay_sdk、biz_content等支付宝网关参数这就是收银台要用的支付串。本地没有真实商户号时可以用支付宝沙箱环境测试把pay.channel.alipay.gateway-host指向沙箱网关地址应用私钥和支付宝公钥都换成沙箱密钥配置即可。排查问题有两个常用抓手。第一个是看网关里统一记录的回调日志框架把所有通知报文打成了一个 JSON 行里面包含notify_id、trade_status、sign三个关键字段验签失败时对比一下sign和日志里的签名源串八成是密钥配错了。第二个是看聚合路由日志关键字是route_result它会打出来本次路由选中的渠道、候选渠道列表、各自权重。压测时重点观察两张表——pay_order表状态流转延迟和回调幂等表的插入冲突数冲突数过高说明业务系统响应慢渠道在重推回调这时候优先处理业务接口的耗时而不是扩容网关。网关和业务系统之间完全是 HTTP 调用没有共享数据库或内存缓存所以业务系统无论是 PHP、Go 还是 .NET只要按统一报文格式来请求都能接入同一套收单、退款、分账能力。源码里还带了 Lombok 配置和标准目录结构clone 下来改完数据库连接就能二次开发渠道层的策略模式扩展点也保留得很干净新增一个支付通道的实现成本主要集中在渠道官方文档的对接上了。本文还有配套的精品资源点击获取
返回列表