拓冰建站拓冰建站
首页 / 资讯中心 / 正文

支付路由与渠道适配:Spring Boot聚合支付网关实战

简介面向企业级支付业务开发与技术学习者这是一套基于Spring Boot与Vue的互联网支付系统源码覆盖多渠道支付网关自动路由已对接微信支付V2/V3、支付宝RSA/RSA2、云闪付服务商接口支持分布式部署与高并发场景并提供HTTP接口及多语言SDK签名机制保障交易安全。包体仅2.66MB共984个文件其中531个Java文件实现服务端核心逻辑126个Vue文件与77个JS文件构成前后端分离管理界面辅以XML/yml配置、Dockerfile部署脚本及SQL初始化脚本便于快速搭建和二次开发。目前已吸引1026人学习浏览适合有Java基础、希望研究真实支付网关对接或搭建聚合支付平台的开发者。资源内含运营平台与商户系统双端管理端采用Spring Security做权限控制MQ异步通知保证消息可达支付渠道参数配置界面可自动生成能够帮助读者深入理解从商户入件、支付下单到网关路由、异步回调的完整闭环。1. 支付路由的复杂度比你想象的更大做支付系统的人都知道一个反直觉结论真正难的不是“调通微信支付”而是“同时调通微信、支付宝、云闪付还要让它们在同一个系统里稳定跑一年”。每家的签名算法、证书体系、回调验签规则、退款同步机制都不一样WX 的 V2 报文和 V3 报文甚至能让你在同一个项目里写出两套风格不同的 HTTP 客户端。这套基于 Spring Boot Vue 的互联网支付系统内核是把这些差异收敛到一个“支付网关”里对外只暴露一套 HTTP 接口和 SDK对内通过自动路由把请求分发到不同渠道。它适合两类人一类是公司要自建聚合支付中台的从业者另一类是接外包时被“多渠道对接”折磨过的 Java 工程师。下文从渠道适配细节讲起一直落到权限模型和分布式部署的边界条件。2. 渠道适配层微信 V2/V3、支付宝 RSA2 与云闪付的共存方案2.1 为什么不能直接在每个业务项目里写渠道代码如果每个业务系统直接引入微信 SDK、支付宝 SDK、云闪付 SDK初期开发很快但后续每一步都是灾难微信 V3 升级了签名算法所有调用方都要跟着改支付宝回调验签要求参数排序不同系统排序规则写错的人都能凑一桌云闪付的机构号在测试环境和生产环境不一样配置文件散落在多个项目里。这套系统把渠道适配收敛到pay-channel模块业务方只认一套内部调单接口渠道差异被隔离在网关内部。2.2 统一渠道抽象接口与参数模型网关里定义一个PayChannelAdapter接口所有渠道适配器都实现它。这样路由层才能以多态方式处理不同渠道。public interface PayChannelAdapter { // 渠道标识如 WX_V2、WX_V3、ALI_RSA2、YSF String channelCode(); // 下单参数转换把内部统一下单请求转为渠道私有参数 ChannelOrderResult createOrder(PayRequest request); // 退款申请 void refund(PayRequest request, RefundRequest refundRequest); // 回调验签与解析返回统一的回调结果 UnifyNotifyResult parseNotify(MultiValueMapString, String headers, String rawBody, ChannelConfig config); // 查询订单用于对账与补偿 ChannelQueryResult queryOrder(String outTradeNo, ChannelConfig config); }这里的关键是把“内部支付请求体”和“渠道私有请求体”分离。PayRequest里的amount以分为单位渠道适配器负责转换为支付宝的字符串元、微信的total_feeV2 为 intV3 为 string。ChannelConfig保存每个渠道在数据库中的配置包括商户号、证书序列号、API v3 密钥等而不是写死在application.yml里。参数说明channelCode用于路由到具体适配器parseNotify收到的headers和rawBody是原始 HTTP 请求因为微信 V3 的验签需要读取请求头里的Wechatpay-Timestamp和Wechatpay-Signature支付宝则要求从表单中取sign参数适配器内部自行处理即可。2.3 微信支付 V2 与 V3 的签名适配细节微信 V2 使用 MD5 或 HMAC-SHA256 对参数排序拼接后加key签名V3 则使用 RSA-SHA256且需要商户 API 证书的私钥。一个容易踩的坑是 V3 的Authorization头格式很多新手把它做成Bearer格式实际上微信要求如下Authorization: WECHATPAY2-SHA256-RSA2048 mchid1900009191,nonce_strxxxxxxxx,signatureBASE64(RSA-SHA256(...)),timestamp1700000000,serial_no...123对应的 Java 验签和签名字段拼接方式在系统中利用wechatpay-javaSDK 完成但网关层做了二次封装让 V2 和 V3 共用同一套内部接口。表格对比能看出差异的本质维度微信 V2微信 V3支付宝 RSA2云闪付签名算法MD5 / HMAC-SHA256RSA-SHA256SHA256withRSARSA2(SHA256withRSA)证书要求无证书仅 API key商户 API 证书 平台证书应用公钥 支付宝公钥商户证书每个机构不同下单 URL/pay/unifiedorder/v3/pay/transactions/jsapi/gateway.do/api/v1/...金额单位分int分string元string两位小数分string回调验签方式字符串排序 key获取平台证书验签公钥验签 参数排序证书验签 报文网关签名在适配器实现类里V2 的签名逻辑相对直观但要注意排在 URL encode 后的值很多参数值转义后与原串不一致。V3 则要使用AutoUpdateCertificatesVerifier自动更新平台证书否则微信侧证书轮换后系统会直接验签失败。系统中把验签失败信息原样返回给管理端日志便于快速定位是哪一步证书更新出了问题。2.4 支付宝 RSA2 与云闪付的机构配置支付宝适配器要区分服务商与普通商户。服务商场景下请求参数里多一个app_auth_token网关需要把它放到ChannelConfig的authToken字段。RSA2 验签时必须对收到的参数剔除sign和sign_type按 key 升序排列后拼接keyvalue...。云闪付的接口更特殊它走的是“商户号 机构号 证书”三层结构而且不同支付机构的上送报文版本可能不同因此该系统在配置界面里设计了“支付机构”下拉框选择机构后动态加载该机构的证书序列号和签名公钥。// 云闪付适配器初始化示例 ChannelConfig ysfConfig channelConfigService.getActive(YSF); YsFClient client YsFClientBuilder.newBuilder() .charset(ysfConfig.getCharset()) .signType(RSA2) .signPublicKey(ysfConfig.getPlatformPublicKey()) .signPrivateKey(ysfConfig.getCertPrivateKey()) .domain(ysfConfig.getGatewayUrl()) .build();这段代码是根据实际项目经验补充的常见配置方式。云闪付的reserved字段常用来传终端信息很多对接方忽略它结果某些机构下单时直接拒绝。说明一下getActive方法从 Redis 读取配置配置变更后无需重启网关进程。3. Spring Boot 网关核心自动路由、签名校验与 MQ 订单通知3.1 自动路由策略从分析商户请求到选择渠道网关对外接收POST /api/pay/unifiedOrder请求体里包含channelCode字段可选为空时路由层根据规则自动选择渠道。自动路由的典型逻辑是先看商户在管理端是否配置了“渠道优先级”再根据支付方式扫码、H5、JSAPI过滤支持该场景的渠道最后根据金额上限、是否需要退款等参数过滤。public String route(UnifiedOrderRequest req, MerchantConfig merchantConfig) { if (StringUtils.hasText(req.getChannelCode())) { return req.getChannelCode(); } ListChannelConfig channels channelConfigService .listEnabledByMerchant(merchantConfig.getMerchantNo()); // 按支付方式过滤 channels.removeIf(c - !c.supportsPayType(req.getPayType())); // 按金额区间过滤 channels.removeIf(c - req.getAmount() c.getMaxAmount()); // 按优先级排序后取第一个 channels.sort(Comparator.comparing(ChannelConfig::getPriority)); return channels.get(0).getChannelCode(); }这里的路由规则不是静态表而是支持运行时修改的。管理端保存配置后网关从 Redis 读取最新集合避免每次路由都查库。参数说明supportsPayType判断渠道是否支持微信扫码、支付宝手机网站等这部分映射在渠道适配器里用SetPayType维护。实际失败时常见的坑是把channelCode写死在前端导致路由层形同虚设本系统允许商户后台配置“默认渠道”把路由的选择权交给运营人员而非开发人员。3.2 请求签名与验签保证链路可信接入方调用网关接口时系统要求签名。内部签名标准参考支付宝的 RSA2 风格按参数名的 ASCII 码排序拼接成待签名字符串用商户私钥签名网关用商户公钥验签。这样接入方也能用支付宝 SDK 里的AlipaySignature.rsaCheckV2做客户端签名减少二次开发成本。# 接入方生成签名的 curl 模拟实际用 SDK paramsapp_id10001merchant_noM10001out_trade_no20250101120000pay_typeWX_JSAPItotal_fee100 sign$(echo -n $params | openssl dgst -sha256 -sign merchant_private_key.pem | base64)网关侧使用 Spring Interceptor 对/api/pay/**进行验签。验签失败的请求记录 IP 和请求体方便排查是否有人伪造签名。验签通过后网关会生成内部交易流水号tradeNo并以这个流水号作为后续查询、退款、回调的唯一关联键。提示签名串拼接时不要包含channelCode这类由网关路由决定后置填充的字段否则路由完成后签名验证会不一致。3.3 支付回调处理与 MQ 订单通知的可靠性支付渠道回调到网关notify接口适配器解析后统一转换为内部UnifyNotifyResult。网关更新本地订单状态然后向 MQ 发送订单支付成功的消息由商户系统监听消息并完成自己的业务处理。这里使用 MQ 而不是直连 HTTP 回调的好处是商户系统短暂宕机时消息不会丢失且支持重试。RabbitListener(queues pay.notify.queue) public void onPayNotify(OrderNotifyMessage message) { // 先查询订单当前状态避免重复消息导致重复处理 Order order orderMapper.selectByTradeNo(message.getTradeNo()); if (order null) { log.warn(订单不存在可能为非法消息 tradeNo{}, message.getTradeNo()); return; } if (order.getStatus() ! OrderStatus.WAIT_PAY) { log.info(订单已处理过忽略重复消息 tradeNo{}, message.getTradeNo()); return; } order.setStatus(OrderStatus.PAID); orderMapper.updateStatus(message.getTradeNo(), OrderStatus.PAID); // 业务方再通过 HTTP 回调或 MQ 继续通知自己的业务系统 businessNotifyService.notify(order); }这段代码的核心逻辑是“先查后改”的防重处理。MQ 的投递模式为手动确认RabbitListener在方法无异常时自动确认抛异常则将消息重回队列。表格列出消息配置常见的几个参数参数推荐值说明spring.rabbitmq.publisher-confirm-typecorrelated生产者确认消息是否到达交换机spring.rabbitmq.publisher-returnstrue消息无法路由到队列时不丢失listener.simple.acknowledge-modeauto方法执行成功自动确认失败重回队列listener.simple.retry.enabledtrue消费内部重试避免多次回调渠道接口实际部署中如果订单通知量不大也可以直接把交换机设为durabletrue队列绑定死信交换机用于记录处理失败的消息。但要注意死信消息不能直接简单重发需要人工去确认是业务问题还是数据问题。3.4 渠道接口参数配置界面自动化生成管理端把每个渠道的配置项渲染成动态表单而不是写死页面。例如微信 V3 需要商户号、AppId、API v3 密钥、商户证书序列号、私钥内容云闪付需要机构号、商户号、证书密码、网关地址等。前端根据渠道类型加载 JSON Schema生成对应的表单组件。{ channelType: WX_V3, fields: [ { key: mchId, label: 微信商户号, type: text, required: true }, { key: appId, label: 公众号 AppId, type: text }, { key: apiV3Key, label: API v3 密钥, type: password }, { key: serialNo, label: 证书序列号, type: text }, { key: privateKey, label: 商户私钥, type: textarea } ] }后端按此 JSON 保存到渠道配置表由后台渲染成页面。这样新增渠道适配器时只需在前端维护一份 schema 文件不用改页面代码。配置保存后系统提供“测试连接”按钮后台会根据渠道类型发起一笔 0.01 元下单或查询操作方便确认证书和密钥没问题。4. Vue 管理端与 Spring Security从菜单到接口的动态权限模型4.1 前后端分离下的权限数据流管理端分“运营平台”和“商户系统”两套界面共用一套后端接口但权限模型必须隔离。运营平台的用户能看渠道配置、订单异常、全量商户列表商户系统的用户只能看自己的订单、对账单和自己的支付渠道配置。这个差异不是靠前端隐藏按钮实现的而是由后端接口级权限控制决定。Spring Security 在这里被扩展为“动态权限过滤器”。启动时从数据库加载所有 URL 权限映射运行时根据用户角色列表判断当前请求是否有权。下面是过滤器的核心伪代码Component public class DynamicAccessFilter extends OncePerRequestFilter { Autowired private MenuPermissionService permissionService; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { String uri request.getRequestURI(); ListString requiredRoles permissionService.getRequiredRoles(uri); if (requiredRoles.isEmpty()) { chain.doFilter(request, response); return; } Authentication auth SecurityContextHolder.getContext().getAuthentication(); boolean allow auth.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .anyMatch(requiredRoles::contains); if (allow) { chain.doFilter(request, response); } else { response.setStatus(403); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\msg\:\无访问权限\}); } } }这里的requiredRoles来自接口 URL 与角色的关联表。运营平台和商户系统共用这个过滤器但维护不同的角色集合。对应地前端 Vue 路由也需要在登录后从/user/menus获取当前用户的动态菜单生成侧边栏路由而不是在代码里静态写死。4.2 商户系统的数据隔离多租户字段怎么设计商户系统里订单表、退款表、渠道配置表都必须有merchant_no字段并由后端从登录态中提取而不是从前端传参。这是比较常见的越权漏洞点。系统里通过 BaseController 封装了getCurrentMerchantNo()方法所有查询语句强制添加该条件。例如订单查询的 MyBatis SQLselect idselectByTradeNo resultTypeOrder SELECT * FROM pay_order WHERE trade_no #{tradeNo} AND merchant_no #{merchantNo} /select注意这里的merchantNo不是前端传的而是后端从 SecurityContext 中获取。这样即使一个商户猜到了另一个商户的交易号也无法越权查看。表格列出运营平台与商户系统在接口层面的权限差异接口路径运营平台角色商户系统角色/admin/channel/list有权无权/admin/merchant/list有权无权/merchant/order/list有权查看全部订单带筛选仅能查看当前商户订单/merchant/refund/apply有时需复审直接申请4.3 Vue 动态路由与按钮级指令控制前端拿到菜单数据后用router.addRoute动态添加路由。按钮级权限常用自定义指令v-permission没有权限的按钮直接从 DOM 移除避免用户看到按钮后点击得到 403 提示。例如商户系统的“退款申请”按钮只有审核角色可见。// permission-directive.js import { useUserStore } from /store/user export const permissionDirective { mounted(el, binding) { const required binding.value const roles useUserStore().roles if (required !roles.includes(required)) { el.parentNode el.parentNode.removeChild(el) } } }在组件中使用el-button v-permissionmerchant:refund:apply退款/el-button。记得后端接口也要加同样的权限码前端只是优化体验不能作为安全边界。实际开发中常见的问题是角色码不一致前端写merchant:refund:apply后端角色表里是MERCHANT_REFUND_AUDIT导致前端明明显示了按钮后端却拒绝。解决方案是把权限编码统一定义在常量类里前后端共享一份文档。5. 高并发部署与 SDK 对接边界分布式锁、回调去重与配置热更新5.1 分布式部署下回调通知的幂等处理如果网关部署多实例支付渠道回调可能同时被负载均衡转发到两台机器或者同一回调被渠道侧重试发送多次。单纯靠数据库状态更新不够需要先对callbackId加分布式锁。系统使用 Redis 缓存回调处理标记以渠道回调号作为 key设置 10 分钟过期。public boolean tryLockCallback(String callbackId, String tradeNo) { String lockKey pay:callback:lock: callbackId; Boolean success redisTemplate.opsForValue() .setIfAbsent(lockKey, tradeNo, Duration.ofMinutes(10)); if (Boolean.TRUE.equals(success)) { return true; } // 已存在且仍是同一 tradeNo可能是重复通知返回 true 走幂等逻辑 String value redisTemplate.opsForValue().get(lockKey); return tradeNo.equals(value); }这个设计允许重复消息进入业务处理但业务方法内部仍要执行“先查订单状态”的判断分布式锁只是用来减少数据库并发更新的概率。注意过期时间不能设太短否则渠道回调稍有延迟就释放锁导致重复处理也别设太长否则回调链路故障时锁不释放。5.2 渠道参数配置热更新与动态切换上线初期最容易犯的错是渠道参数改在数据库但网关已经加载到内存不重启不生效。本系统用 Redis 发布订阅 Spring 事件机制实现热更新。配置保存后管理端发一条CHANNEL_CONFIG_CHANGED事件网关实例收到后重新从数据库加载该渠道配置并替换内存中的引用。EventListener(ChannelConfigChangedEvent.class) public void reloadChannel(ChannelConfigChangedEvent event) { ChannelConfig newConfig channelConfigMapper.selectByChannelCode(event.getChannelCode()); channelConfigCache.put(event.getChannelCode(), newConfig); }这里需要特别提醒如果多个网关实例必须保证所有实例都收到事件。Redis pub/sub 是广播机制能够满足要求但如果 Redis 网络出现短时抖动部分实例可能收不到消息。保险做法是实例启动时全量加载运行中再监听增量事件同时管理端提供“手动刷新缓存”按钮作为兜底方案。5.3 SDK 对接方常见签名坑位与验证方法这套系统对外提供 HTTP 形式接口和 SDK接入方的签名正确性是支撑部门收到工单的第一来源。常见坑位有两个第一金额单位不一致系统定义总价为分为单位但接入方喜欢用元结果乘以 100 后出现浮点误差第二签名串里包含空值字段例如某些 SDK 会把空字符串也拼进去导致验签失败。我们提供给接入方的验证步骤一般是这样# 1. 生成待签名串参照 https://接口文档签名章节 # 2. 用 openssl 检查签名结果 echo -n app_id10001out_trade_no20250101120000total_fee100 \ /tmp/plain.txt openssl dgst -sha256 -sign merchant_private_key.pem -out /tmp/sign.bin /tmp/plain.txt base64 /tmp/sign.bin建议接入方先在本地生成签名然后使用“在线验签工具”与网关返回的签名进行对比。这样能把签名问题与网络传输问题隔离。如果接入方使用 Python 语言常见错误是requests库自动将字典中值为None的键值对丢弃导致服务端收到的参数少了一个签名串就对不上。解决方式是在 SDK 层面要求接入方填参时显式传空字符串而不是None。5.4 云闪付机构切换的验证要点云闪付渠道选择不同支付机构时不仅证书不同部分机构的云闪付网关地址也略有差异。系统在管理端渠道配置里加入“支付机构”字段切换机构后订单查询接口必须重新加载对应的证书和机构号。实际测试时先用 0.01 元小额下单验证下单和回调再验证一次退款。云闪付回调验签需要到对应机构站点下载平台公钥注意公钥文件可能不是 PEM 格式而是 Base64 串需要在配置时去除换行符。最后检查云闪付回调中的orderId与自己的outTradeNo映射关系通常我们使用云闪付的reqId或自定义reserved字段保存商户系统交易号避免依赖繁琐的映射表。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门