多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Jeepay开源聚合支付系统实战:从架构到二次开发全解析

Jeepay开源聚合支付系统实战:从架构到二次开发全解析 简介在Java后端开发中支付系统一直是高门槛领域聚合支付模式通过对微信、支付宝等渠道的统一封装大幅降低了接入复杂度。理解订单模型、回调验签、幂等控制与对账机制是搭建可靠支付中台的关键。Jeepay作为一款基于Spring Boot的开源聚合支付系统提供了从运营后台、商户中心到支付网关的完整解决方案可帮助Java团队快速搭建符合生产要求的支付平台。本文围绕Jeepay的架构设计、部署流程与二次开发要点结合具体代码与配置演示如何实现下单、回调、查单和对账的完整闭环并覆盖常见避坑指南。1. Jeepay是什么为什么java团队做三方支付绕不开它做java后端的人十有八九都遇到过这样一个需求领导说“我们要接微信支付、支付宝最好还能跑通退款、分账”然后你打开微信支付文档一看先要搞定商户号、证书、回调验签再考虑到后面换渠道、对账一套自研支付系统没三个月根本下不来。Jeepay就是在这个节骨眼上值得认真看的开源支付系统——它由java语言开发定位是一套聚合型三方支付系统把微信、支付宝这类渠道统统一层封装对外给商户提供统一下单、退款、查单能力。它的价值在于你不需要从零造支付轮子拿来改配置就能跑通一套含运营后台、商户端、支付网关的完整体系。适合谁手上已经有spring boot mybatis这类技术栈想快速给电商、小程序、SaaS平台补齐支付能力的java工程师。2. 先从架构看懂Jeepay一套三方支付系统的地基是什么2.1 管理端、商户端、网关三层为什么支付系统一定要拆开第一次打开Jeepay的代码仓库你会发现它不像普通单体项目那样一个应用跑到底而是拆成了几个独立应用。常见结构是manager运营管理端、merchant商户端、payment支付网关以及定时任务模块。这个拆分不是拍脑袋——支付业务里“运营平台管渠道、商户自己管订单、网关扛交易请求”三件事的流量特征、权限边界完全不同。运营管理端负责配置渠道参数、审核商户、查看全量订单和退款单面向的是平台内部人员商户端面向的是接入Jeepay的商家商家在这里查交易流水、配置自己的应用密钥、发起退款支付网关则是所有交易请求的入口商户的App、小程序或服务端把下单请求打到这里网关再通过渠道SDK转发给微信或支付宝。职责一旦混在一起权限管控和故障隔离都会很痛苦。2.2 订单模型为什么一张订单表能撑起下单、退款、分账Jeepay的核心订单模型值得花时间读一遍。与很多自研系统不同它把“支付订单”和“退款订单”分离但又通过订单号建立了强关联。主订单表里记录的是这笔交易的状态机——从“已创建”到“支付中”再到“支付成功”。其中最有价值的字段是“渠道订单号”和“渠道返回的凭证ID”因为对账时你必须拿本地订单和渠道侧订单做一一映射。CREATE TABLE t_order ( order_id varchar(30) NOT NULL COMMENT 商户订单号, mch_order_no varchar(64) NOT NULL COMMENT 商户侧订单号, way_code varchar(20) DEFAULT NULL COMMENT 渠道代码如 WXPAY / ALIPAY, amount bigint(20) DEFAULT NULL COMMENT 订单金额单位分, state tinyint(4) DEFAULT NULL COMMENT 订单状态: 0-订单生成, 1-支付中, 2-成功, 3-失败, channel_orderno varchar(64) DEFAULT NULL COMMENT 渠道方订单号, PRIMARY KEY (order_id), KEY idx_mch_order_no (mch_order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表的设计逻辑体现了一个重要原则金额一律以“分”为单位用bigint存储。为什么不是decimal因为浮点数参与多次运算会累积误差而支付场景不允许任何金额偏差。拿到一张订单表时先看是否满足“渠道代码可空但支付成功后必须填充”再看状态流转有没有对应的待支付、退款中的中间态。2.3 支付网关的请求链路聚合支付的真正价值在路由聚合支付的真正价值不是把URL包一层而是把“不同渠道的差异”封装在后端。商户侧下单时只需要传统一的参数mchOrderNo商户订单号、wayCode渠道代码、amount订单金额、notifyUrl回调地址。网关内部通过wayCode找到对应的渠道实现再根据渠道要求补充appId、证书等信息组装请求发出去。public PayOrder createPayOrder(PayOrderReq req) { // 1. 校验商户状态与应用状态 MerchantApp app merchantAppService.getById(req.getAppId()); Assert.isTrue(app.getState() 1, 应用已被禁用); // 2. 根据wayCode获取渠道适配器 IChannelService channelService channelFactory.get(req.getWayCode()); // 3. 渠道参数补全 ChannelParam param channelService.buildParam(app, req); // 4. 发起渠道请求 ChannelResult result channelService.pay(param); // 5. 记录支付订单等待回调 return saveOrderAndReturn(result); }这里的channelFactory就是路由核心。它内部维护了一个渠道代码和实现类的映射表比如wayCode为“WXPAY_NATIVE”时路由到微信支付Native实现为“ALIPAY_WAP”时路由到支付宝WAP实现。你在二次开发时新增渠道本质就是新增一个实现类并注册到工厂里。这个设计思路同样适用于业务侧不要在controller层堆if-else去判断渠道把渠道差异收敛到适配器里。3. 用Spring Boot把Jeepay跑起来从拉代码到出支付单3.1 环境准备JDK、MySQL、Redis一个都不能少Jeepay的技术栈是spring boot mybatis所以首先要保证本地的java环境是8或11版本MySQL建议5.7以上Redis必须得有因为支付网关里大量用到缓存来存渠道参数和防重令牌。如果你还没装Redis最快的方式是用Docker起一个。# 拉取Jeepay源码建议直接克隆主分支 git clone https://github.com/jeequan/jeepay.git cd jeepay # 创建数据库执行核心SQL脚本 mysql -uroot -p -e CREATE DATABASE jeepay DEFAULT CHARACTER SET utf8mb4; mysql -uroot -p jeepay db/init.sql # 启动Redis容器方式适合本地开发 docker run -d --name redis-dev -p 6379:6379 redis:6初始化脚本会一次性建立运营平台、商户中心、支付网关的全部数据表并插入默认的运营账号、角色权限、渠道参数占位数据。执行完脚本后不要急着改代码先确认三张关键表有数据系统用户表、商户信息表、渠道参数表。3.2 修改数据源配置让支付网关和运营后台连上库Jeepay的每个子应用都有自己的application.yml所以你要改的不是一个配置文件。拿payment服务举例核心配置是数据源、Redis连接和RPC通信地址。第一次跑通时最常犯的错是只改了支付网关的数据库地址结果管理端和商户端还在连旧的库。spring: datasource: url: jdbc:mysql://localhost:3306/jeepay?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: yourpassword redis: host: localhost port: 6379 timeout: 3000ms jeepay: # 网关对外暴露的访问地址回调时要用 site-url: http://localhost:9210 # 多商户支持的开关按实际需求开启 is-sandbox: trueis-sandbox这个参数值得展开说明一下。它决定了支付网关走真实渠道还是沙箱渠道。本地调试阶段老老实实开成true因为沙箱渠道不需要真实商户号和证书能让你先把订单流程走通。等确认业务逻辑没问题了再改成false去连真实渠道。3.3 启动三个应用把商户、运营、网关一次拉齐当所有子应用都在本地启动后访问的端口最好记在小本子上运营后台默认是9217端口商户中心默认是9218端口支付网关是9210端口。先启动支付网关再启动运营后台和商户中心因为后两个应用启动时会向网关做服务注册。# 在jeepay根目录下分别打开三个终端执行 cd jeepay-manager mvn spring-boot:run cd jeepay-merchant mvn spring-boot:run cd jeepay-payment mvn spring-boot:run启动日志里看到“Started Application in x.x seconds”才算成功。常见翻车点三个应用同时抢同一个端口会自动失败因为默认配置里可能都用8080。这时需要在各自的application.yml里把server.port改成上面提到的端口。另外一个隐蔽问题——运行时报“无法解析jeepay-core的依赖”多半是本地maven仓库没有父POM先在根目录执行一次mvn install。3.4 在商户中心创建一笔模拟支付走通最小闭环三个服务都起来后打开商户中心登录页用初始化SQL里预设的默认账号登录。进入“产品中心”创建一个应用拿到appId和appSecret再把应用级别的加签模式设好。最后在“开发配置”里配一个回调地址保证回调能回到你的本地服务。# 使用curl模拟一个Native下单请求验证网关是否通路 curl -X POST http://localhost:9210/api/pay/order \ -H Content-Type: application/json \ -d { appId: 你的应用ID, mchOrderNo: TEST202501010001, wayCode: WXPAY_NATIVE, amount: 1, notifyUrl: http://localhost:8080/notify }参数里的amount1表示1分钱这是调试阶段最实用的金额——真实支付时用最小金额测试退款、对账能最大限度降低资金风险。如果返回的响应里有payUrl字段说明下单链路通了。你会看到支付订单的状态停在“支付中”等沙箱渠道回调后状态变为“支付成功”。第一次跑通这个闭环整个Jeepay对你的意义就不一样了。提示别用微信沙箱的钱包账号去扫Native码微信沙箱的Native还是要真实扫码最好直接用支付宝沙箱的App或浏览器模拟支付。4. 二次开发最容易动的地方支付回调、查单、对账怎么做4.1 回调验签为什么不能直接信任回调内容支付回调是整个支付系统里最敏感的一环。想象一下如果攻击者伪造一个回调请求说“我支付成功了”而你的系统没有验签就直接更新订单状态那就会给未付款的用户发货这等于给攻击者开了一个免单窗口。Jeepay在网关层就做了验签处理但你接自己的业务系统时同样要验签。Jeepay的回调解密逻辑其实是对称密钥和公钥的组合。渠道回调时会把关键参数拼接后做RSA签名系统先用Jeepay平台的公钥验签确认“这确实是Jeepay发来的”再解密出真实订单数据。public String parseNotifyParam(MapString, String params) { // 1. 先拿appId和mchOrderNo定位到对应应用配置 String appId params.get(appId); MerchantApp app merchantAppService.getByAppId(appId); // 2. 使用应用私钥解密业务参数模拟代码 String bizData decryptByPrivateKey(params.get(bizData), app.getPrivateKey()); // 3. 内部解析出真实的状态字段 JSONObject result JSON.parseObject(bizData); if (SUCCESS.equals(result.getString(tradeState))) { // 更新订单状态触发后续发货逻辑 payOrderService.confirmSuccess(result); } return SUCCESS; }这段代码里的decryptByPrivateKey是关键它保证了传输过程中的数据不是明文可读的。写业务时你要注意回调返回给Jeepay的内容字符串必须是英文“SUCCESS”给它返回中文“成功”或其它内容Jeepay会认为回调失败然后按它的重试机制再次调用你的回调接口。4.2 主动查单与被动回调两个线程同时改订单状态会翻车做支付的人最怕一种bug回调通知和主动查单同时触发两个线程都进入“处理订单成功”的流程导致数据库里订单状态被更新两次甚至触发了两次发货。这个问题在Jeepay里也存在因为它同时实现了被动接收回调、定时任务主动查单两条补偿链路。实际上主动查单是这么用的渠道回调可能延迟几分钟甚至丢了如果完全依赖回调订单状态就会卡在“支付中”。Jeepay的定时任务模块会周期性地扫描超时未回调的订单去渠道侧查询真实支付结果。两条链路最终都要落到“确认订单成功”这个方法上。Transactional(rollbackFor Exception.class) public void confirmSuccess(OrderResult orderResult) { // 幂等控制更新时带上状态条件防止重复成功 boolean updated payOrderMapper.updateStateById( orderResult.getOrderId(), 2, // 目标状态成功 1 // 期望原状态支付中 ); if (!updated) { log.warn(订单状态已被修改跳过重复处理: {}, orderResult.getOrderId()); return; } // 只有第一次更新成功才执行后续业务 sendDelivery(orderResult.getOrderId()); }updateStateById里带了两个关键条件——目标状态和期望原状态。这个“乐观锁式”的更新策略是处理并发重复通知的底线因为数据库的行锁天然会串行化两个更新操作第一个执行成功后第二个的where条件已经不满足影响行数为0。如果你在二次开发里发现订单被重复处理十有八九是直接用了updateById而不是这种条件更新。4.3 对账文件与差错处理长款短款到底怎么查只要接手支付系统早晚会碰到“平台账和渠道账对不上”的夜晚。对账不是等出了问题才查而是每天定时跑一遍。Jeepay的运营后台里有一个对账管理模块它做的事情是拉取渠道侧的对账单和本地订单表做逐笔比对。对账逻辑里最核心的一个字段是orderId它同时存在于本地订单和渠道账单中。比对的时候主体关系是本地有、渠道没有定为“短款”需要去渠道侧查这笔单是否真实成功本地没有、渠道有定为“长款”优先确认是否是测试单或渠道异常单。# 常见的对账排查步骤 1. 找到对账差异列表按订单号查询本地支付订单 2. 用同一笔订单号去渠道商户平台搜索订单 3. 对比金额、时间、终端状态三个维度 4. 如果渠道侧成功但本地失败检查回调日志和主动查单任务是否被关闭这里有个血泪经验支付系统的对账功能一定要在测试阶段就打开用小额真实交易反复验证别等上线后第一个月跑对账再发现定时任务根本没配好。等你的订单量上了万级再去人工核对就是黑匣子摸瞎。5. 避坑Jeepay从部署到调试最常见的5个翻车问题5.1 页面能打开但下单后一直“支付中”现象商户后台正常登录发起下单请求后订单状态永远停在“支付中”无论怎么等都不变。原因有两种可能。第一种是支付网关的定时任务模块没有启动导致主动查单功能不工作第二种是回调地址指向的是localhost但支付网关在容器或远程环境里回调请求打不到你的本地服务。解决先在配置文件里把is-sandbox设为true确认沙箱渠道是否正常。接着检查支付网关所在机器的防火墙有没有放行9210端口。最后看定时任务模块的日志里有没有“task scan order”之类的输出。把这三步走完90%的“支付中”问题都能定位。5.2 微信支付回调通知收不到现象用微信支付沙箱完成支付后业务接口一直没触发。原因微信支付回调要求公网可访问的HTTPS地址做本地联调时很多人直接把notifyUrl写成了http://localhost:8080/notify。微信侧发出回调时这个地址无法被公网解析自然请求不到。另一个隐蔽原因是回调路径被网关拦截了Jeepay对回调有IP白名单或签名校验如果请求没带合法签名会被静默丢弃。解决本地联调时用内网穿透工具把本地端口映射成一个HTTPS地址把notifyUrl换成这个公网地址。如果仍然收不到在支付网关的访问日志里搜微信回调的请求记录看有没有进入应用。记住支付系统里“没有日志”本身就是一条重要线索。5.3 退款成功后商户统计余额对不上现象退款单状态已经变为“成功”但商户中心的账户余额是原路退回前的数字。原因Jeepay的账户体系里支付成功是加钱退款成功是减钱这两个动作可能落到不同的业务表。如果你的项目只用了订单表没联动账户流水表就会出现“交易成功但余额没变”的假象。另一个原因是精度问题——退款金额如果是浮点运算出现0.01的误差对不上账就不奇怪了。解决检查Jeepay的账户流水表看退款单号下有没有对应的一条“出账”记录。没有的话补充一段在退款成功回调中更新账户余额的逻辑。金额计算统一走long类型的分避免任何Double参与加加减减。5.4 换了数据库字符集后订单表索引失效现象部署到新环境时把MySQL默认字符集改成了utf8然后订单表查询奇慢无比。原因Jeepay初始化默认转成utf8mb4如果你手动改了库的字符集而表里还保留了emoji或生僻字varchar字段的实际存储编码与索引编码不一致索引就会失效。解决建表语句里显式声明ENGINEInnoDB DEFAULT CHARSETutf8mb4不要依赖数据库全局配置。改过字符集的库用ALTER TABLE t_order CONVERT TO CHARACTER SET utf8mb4修复一次再重新分析索引。5.5 重启后所有应用报错“重复的bean定义”现象连续启动manager和merchant两个服务时本地maven仓库的jar包冲突Spring容器里出现了两个同名Bean启动直接失败。原因Jeepay的多个子模块依赖了同一个公共模块而maven依赖仲裁时可能把不同版本的类都打进了同一个classpath。解决在根目录执行mvn clean install -DskipTests把公共模块统一安装到本地仓库后再启动子应用。这个问题在你同时改动了jeepay-core和jeepay-boot时非常典型不要只重新编译子应用本身。注意遇到支付类的疑难杂症先看日志再看配置最后再看代码。很多人一上来就翻源码定位问题反而容易在细节里迷失方向。6. 上线前最后过一遍这四件事幂等、日志、密钥与压测6.1 给回调处理加一张幂等表Jeepay本身有幂等处理但你自己的业务系统不能完全依赖它因为你的发货、积分、入账操作可能需要跨系统调用。最简单的做法是建一张幂等记录表在主流程开始时先插入一条占位记录成功后再更新状态。CREATE TABLE t_idempotent ( id bigint(20) NOT NULL AUTO_INCREMENT, biz_type varchar(32) NOT NULL COMMENT 业务类型: ORDER_PAY / ORDER_REFUND, biz_id varchar(64) NOT NULL COMMENT 业务主键: 订单号或退款单号, state tinyint(4) DEFAULT 0 COMMENT 0处理中 1完成, PRIMARY KEY (id), UNIQUE KEY uk_biz (biz_type, biz_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;用biz_type和biz_id做唯一索引是防止并发重复处理的最简单手段。insert成功代表你抢到了处理权insert冲突说明已经有人在处理了直接返回成功即可。6.2 密钥管理别把商户私钥写进application.yml很多从Jeepay起步的团队图省事把渠道的API密钥、商户私钥直接硬编码在配置里。这在联调环境没问题但一旦代码被push进公共仓库密钥就泄了。我一般会用环境变量或者独立的密钥管理服务来注入这些敏感信息。# 启动时通过环境变量注入配置文件里只留占位符 SPRING_APPLICATION_JSON{ jeepay.channel.wxpay.apiKey: ${WXPAY_API_KEY}, jeepay.channel.alipay.privateKey: ${ALIPAY_PRIVATE_KEY} } java -jar jeepay-payment.jar这样做的另一个好处是每个环境沙箱、生产的密钥配置互相隔离换环境时只需要调整环境变量不需要改代码重新构建。6.3 用JMeter压一遍“下单-回调”全链路压测不要只测下单接口那只能看到网关吞吐测不出业务系统的真正瓶颈。更贴近真实的做法是建一个循环线程组让下单请求和模拟回调请求以1:1的比例并发。观察订单从“支付中”到“支付成功”的延迟如果超过2秒优先排查回调接口里是否做了耗时的远程调用。6.4 日志的规范支付系统日志就是后悔药最后一条是我踩过最深的一个坑也是我现在做任何支付项目都坚守的习惯每个关键节点都要有日志并且要带订单号。排查线上问题时没有日志等于没有后悔药。日志至少要包含四类下单请求的入参、渠道返回的原始报文、回调收到的参数和验签结果、以及状态变更的前后值。用logback的MDC机制把订单号塞进线程上下文就能在日志系统里按订单号拉出一次完整支付的全过程。每次查凌晨的对账差异这招帮我至少省掉两小时。回想起来我从第一次接触Jeepay到敢把它用到生产环境花了大概两周其中一半时间消耗在踩回调、换配置这些细坑上。如果你正走在同样的路上耐心一点。支付系统的每一条边界都是用真金白银验证出来的把每一笔测试单当成真实交易对待跑通那天的踏实感就值得了。希望帮到你。本文还有配套的精品资源点击获取
返回列表