ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java易宝支付源码实战:签名回调与避坑指南

Java易宝支付源码实战:签名回调与避坑指南

简介:一套基于Java的易宝支付对接源码,适合需要快速接入第三方支付或学习支付网关接口的Java Web开发者。资源已实测成功,附带完整测试步骤截图,清晰展示从登录选择银行、提交订单到建行网银扣款0.01元并收到回调通知的完整过程。压缩包共36个文件,以Java源码、JSP页面、class文件、properties及xml配置为主,另含jar依赖、测试结果图片和说明文档,整体约786KB,目录紧凑便于本地部署与调试。目前已有1491人学习下载,代码中保留了商户ID、回调地址等关键配置位置,可直接参考其参数组装、hmac签名校验及支付结果处理逻辑,对理解易宝支付网关交互机制很有帮助。

1. 一套 Java 易宝支付源码:先看它解决了什么,再看值不值得下载

做支付对接的人都有种体会:接口文档读三遍,不如一份能跑的代码。这套易宝支付 Java 源码卖点在标题里就说透了——已测试成功,附测试步骤图片。它不是那种“代码齐全但一跑就崩”的陈列品,而是把网关支付请求、MD5 签名、异步回调验签、订单更新这一条完整链路走通了的模板。适合马上要在 Java 项目里接入易宝支付,又不想从零研究签名规则的开发者,也适合接手老项目、需要一份对照实现来排查问题的同学。下文按“原理、配置、踩坑、测试”的顺序拆开讲,每个环节都能直接对到源码里的文件。

2. 易宝支付网关链路和源码结构:先分清五个参与方

支付对接最容易犯的错,是把流程想象成“客户端请求后端,后端请求网关,网关同步返回结果”。真实情况是网关支付和登录验码不一样,支付成功后,易宝的支付结果不是跟着用户浏览器回来的,而是由易宝后台单独发起一条异步通知,打到你在支付请求里配置的回调地址上。所以一套完整的易宝对接源码,至少要有两个入口:一个是发起支付的请求入口,一个是接收支付结果的通知入口。两个入口共用同一套签名和验签逻辑,这就决定了“签名工具类”在整个项目里的核心地位。

这条链路里真正干活的参与方有五个:用户浏览器、发起支付的后端服务、易宝支付网关、易宝后台通知服务、承载订单状态的数据库。用户付完款之后,浏览器跳回你的同步返回地址,那是给用户看的页面,不代表订单就成功了;真正可信的是易宝后台向你的异步通知地址发的那条 POST 请求。这套源码能跑通,本质上就是把同步跳转和异步通知这两个入口分别处理对了。

2.1 一次支付里,代码要处理的四个关键节点

一次完整交易在源码里会经过四个节点,按顺序看:用户点“去支付”,后端查出订单,按易宝文档给定的顺序组装参数;后端生成签名后输出一个自动提交的 HTML 表单,由浏览器跳到易宝网关;用户支付完成后,浏览器同步跳回,同时易宝后台向 p8_Url 发异步通知;后端收到异步通知,验签、核对金额、更新订单,并返回固定的 SUCCESS 字符串。

四个节点里,最容易误解的是第三和第四之间的边界。同步跳转告诉用户“你付完了”,但它不带可靠的交易回执,也不保证一定到达,所以不能作为更新订单的依据;异步通知才是支付平台和后端服务器之间的正式握手。源码里 PayStartServlet 承担节点二,PayCallbackServlet 承担节点四,两端共用同一个 SignUtil,这是源码里最值得先读的三文件组合。

二次开发时常见的问题是,有人把签名工具里的参数顺序动了一下,发起支付没问题,回调验签却立刻失败。原因很简单:回调方用的是下单时那套参数规则,你对任何一端改了顺序、漏了字段,两边的签名串就对不上,所有验证全部失败。

2.2 接口参数表:先认识易宝网关交易的每个字段

支付请求的主体参数在源码里被封装在 PayRequest 类中。下面这张表是这套源码实际拼参数时用到的核心字段,也是测试排错时的对照基准:

参数含义必填/样例
p0_Cmd业务类型Buy
p1_MerId商户编号测试商户号,数字串
p2_Order订单号长度不超过 20 位,保证唯一
p3_Amt支付金额字符串格式两位小数,如 199.00
p4_Cur币种CNY
p5_Pid商品名称展示在收银台
p6_Pcat商品分类可选
p7_Pdesc商品描述可选
p8_Url异步通知地址必须公网可达,不能填 localhost
p9_Saf_Flg跳转策略1 跳转易宝收银台,0 使用 pd_FrpId
pa_MP商户扩展字段最长 200 字,回调时原样返回
pd_FrpId支付通道编码填银行编码则直达;留空则进收银台自选
pr_NeedResponse响应机制1,表示必须异步通知
hmac签名串MD5 计算,32 位小写

三个容易看走眼的点说在前面。第一,p3_Amt 要从 BigDecimal 转成两位小数字符串,直接用 double 拼“10.0”进来,签名串和支付金额就都偏了。第二,p8_Url 在测试环境必须配到你的公网测试机,否则易宝后台通知打不进内网,订单就永远停在未支付。第三,pd_FrpId 留空反而更适合验证流程,用户进收银台可以选不同支付方式,测试覆盖面大一些。

源码包里对应的文件是 PayRequest.java 和 PayResponse.java。PayRequest 把接口参数投影成下单请求,PayResponse 把回调参数投影成验签结果。两个类都保留了易宝原始字段名,不做过度的驼峰转换,目的就是方便拿源码参数和易宝文档对照定位。很多同学习惯把 p1_MerId 改成 merchantId,改完之后文档、测试步骤、日志全对不上,这是二次开发里最不划算的重构。

2.3 签名算法原理:为什么所有参数值要按顺序进签名串

这套易宝接口用的是 MD5 签名,但签名规则不是把所有参数拼成 JSON,而是按接口文档里参数排列的顺序,把所有非空参数值拼接成一个长字符串,最后再在尾部拼接商户密钥,对整个字符串做 MD5 加密。服务端对原始串做同样的拼接,比较结果。

这意味着有两件事实打不动。第一,参数顺序不能乱。顺序变了,拼出来的原串就变,MD5 结果就变,验签就失败。这也是为什么源码里故意用 LinkedHashMap 而不是 HashMap,HashMap 不保证迭代顺序,里面的值多一个或少一个都会导致签名串不稳定。第二,空值可以不拼。拼接规则是“参数有值才进串,没有值跳过”,所以 p6_Pcat、p7_Pdesc、pd_FrpId 这几个可选字段,没赋值就跳过,不影响签名结果。

举个例子,假设商户号是 1000000000,订单号是 T20250101001,金额是 10.00,那么签名原串的大致形态是:

Buy1000000000T2025010100110.00CNY测试商品...商户密钥

注意这个字符串中间没有等号、没有 & 符号,和微信支付、支付宝的签名规则明显不同。调试时不要把习惯带过来,否则拼出来的 raw 串永远和易宝对不上。这两个点搞清楚后,后面调试验签失败基本三分钟就能定位,很多人验签失败后反复去查密钥,其实密钥没错,是拼接规则错了。

3. 把源码跑起来:配置商户号、密钥和回调地址三件事

拿到源码包后,第一步不要急着看支付页面长什么样,而是先改配置。这套源码把商户相关参数都收在了 resources 下的 pay.properties 一个文件里,从项目结构上看是清爽的,也方便做测试环境和生产环境切换。源码包里依赖的 jar 放在 lib 目录,web.xml 放在 WebContent/WEB-INF 下,src 下按 controller、service、util、entity 分了包。整套是传统 Servlet 结构,打进 war 包丢进 Tomcat 就能跑,没有引入重量级框架,对老项目很友好。

源码包内部结构大概是:src/controller 放两个支付入口 Servlet,src/service 放订单查询与更新服务,src/util 放 SignUtil 和 Md5Util,resources 放 pay.properties,WebContent 放 JSP 页面和测试入口。测试步骤图片集中在文档目录里,每个页面和配置项的对应关系在图上标得很清楚。这种结构的好处是搬进自己项目时不用拆框架,直接把 controller 和 util 拷过去改包名即可。

3.1 先改配置:商户号、商户密钥、回调地址三件套

pay.properties 里需要改的核心是三组配置:

# 商户编号,测试环境用易宝商户后台里的测试商户号 merchant.id=1000000000 # 商户密钥,测试环境的密钥和生产环境的密钥是两套 merchant.key=2f6b8c4e9a1d7f3c50e2b8a94d16c7f3 # 异步通知地址,公网能访问到的完整路径 callback.url=http://your-server-ip:8080/pay-demo/pay/callback # 同步跳转地址,用户支付完成后浏览器跳回来 return.url=http://your-server-ip:8080/pay-demo/pay/result

这段配置里的 callback.url 是整条链路里最容易犯错的点。注意两端要一致:一端是发起支付时 p8_Url 要传 callback.url 的值;另一端是你在易宝商户后台签约时填的支付通知地址,两者要指向同一个地址。很多项目测试不通过,是因为下单的 p8_Url 填了 A 地址,商户后台却配置了 B 地址,易宝看到下单信息里的地址和后台登记的不一致,通知直接打给后台配置的地址,两边对不上,订单就停在半路。

测试环境里第一次跑通,也不要急着把密钥换成生产的。先确认商户号、密钥、回调地址三个值的来源都是同一个环境。测试环境和生产环境的密钥是两套,混用之后发起支付可能看着正常,回调验签却过不了,这种问题最消耗时间。

3.2 发起支付:参数封装、签名和自动跳转表单

在源码的 PayStartServlet 里,发起支付的核心逻辑是:查订单、组参数、算 hmac、输出一段自动提交表单。下面这段代码是源码里的核心逻辑,去掉了一些业务判断,保留主链路:

@WebServlet("/pay/start") public class PayStartServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String orderId = req.getParameter("orderId"); Order order = orderService.getByOrderId(orderId); if (order == null) { resp.sendRedirect("/pay-demo/error.jsp"); return; } if (!"UNPAID".equals(order.getStatus())) { resp.sendRedirect("/pay-demo/result.jsp?orderId=" + orderId); return; } LinkedHashMap<String, String> params = new LinkedHashMap<>(); params.put("p0_Cmd", "Buy"); params.put("p1_MerId", payConfig.getMerchantId()); params.put("p2_Order", order.getOrderId()); params.put("p3_Amt", order.getAmount().setScale(2).toString()); params.put("p4_Cur", "CNY"); params.put("p5_Pid", order.getProductName()); params.put("p6_Pcat", ""); params.put("p7_Pdesc", ""); params.put("p8_Url", payConfig.getCallbackUrl()); params.put("p9_Saf_Flg", "1"); params.put("pa_MP", ""); params.put("pd_FrpId", ""); params.put("pr_NeedResponse", "1"); String hmac = SignUtil.createSign(params, payConfig.getMerchantKey()); params.put("hmac", hmac); StringBuilder form = new StringBuilder(); form.append("<html><body onload=\"document.forms['payForm'].submit()\">"); form.append("<form name='payForm' method='POST' action='https://www.yeepay.com/app-merchant-proxy/node'>"); for (Map.Entry<String, String> item : params.entrySet()) { form.append("<input type='hidden' name='") .append(item.getKey()) .append("' value='") .append(item.getValue()) .append("'/>"); } form.append("</form></body></html>"); resp.setContentType("text/html;charset=UTF-8"); resp.getWriter().write(form.toString()); } }

代码逻辑分成三段。第一段查订单并判断状态,防止同一笔订单重复发起支付。第二段按易宝文档给定的参数顺序组 p0_Cmd 到 pr_NeedResponse,这里最忌讳的就是有人为了少写几行把参数放进 HashMap,签名全乱。第三段把参数连同 hmac 拼成自动提交的隐藏表单,由浏览器代为完成跨域 POST 跳转。

p3_Amt 这里用的是 order.getAmount().setScale(2).toString(),没有直接 toString。BigDecimal 不显式设置保留位数,可能输出 1990 或 1990.0,签名字符串和网关解析金额都会出问题。setScale(2) 之后输出的一定是两位小数,也只有这种写法能保证签名稳定。

签名方法 createSign 的内部实现是核心中的核心,单独拆开看:

public static String createSign(Map<String, String> params, String merchantKey) { StringBuilder raw = new StringBuilder(); for (Map.Entry<String, String> item : params.entrySet()) { String value = item.getValue(); if (item.getKey().equals("hmac") || value == null || value.length() == 0) { continue; } raw.append(value); } raw.append(merchantKey); return Md5Util.md5(raw.toString()).toLowerCase(); }

这段做的事就是 2.3 说的签名规则:按参数顺序把非空 value 拼在一起,尾部附加商户密钥,再做 MD5。这里要求传入的 params 必须是有序的 LinkedHashMap,否则遍历顺序不固定,签名就不可复现。这套源码里 createSign 同时服务支付请求和回调验签,任何一边改动都要考虑对另一边的影响,不然下单成功、回调查验失败这种奇怪现象就会找上门。

3.3 回调验签:用同一套签名逻辑保护订单状态

回调接口 PayCallbackServlet 是这套源码的另一个入口,服务端异步通知和浏览器同步跳转都会到达这里。源码里把两种请求合并处理,但更新订单的逻辑只在异步通知分支执行,同步跳转只做结果展示。核心代码:

@WebServlet("/pay/callback") public class PayCallbackServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { LinkedHashMap<String, String> params = new LinkedHashMap<>(); params.put("r0_Cmd", req.getParameter("r0_Cmd")); params.put("r1_Code", req.getParameter("r1_Code")); params.put("r2_TrxId", req.getParameter("r2_TrxId")); params.put("r3_Amt", req.getParameter("r3_Amt")); params.put("r4_Cur", req.getParameter("r4_Cur")); params.put("r5_Pid", req.getParameter("r5_Pid")); params.put("r6_Order", req.getParameter("r6_Order")); params.put("r7_Uid", req.getParameter("r7_Uid")); params.put("r8_MerId", req.getParameter("r8_MerId")); params.put("r9_BType", req.getParameter("r9_BType")); params.put("rp_PayDate", req.getParameter("rp_PayDate")); String hmac = req.getParameter("hmac"); if (!SignUtil.verifySign(params, payConfig.getMerchantKey(), hmac)) { resp.getWriter().write("FAIL"); return; } if (!"1".equals(req.getParameter("r1_Code"))) { resp.getWriter().write("FAIL"); return; } // 只有异步通知才更新订单,BType=2 代表异步通知 if ("2".equals(req.getParameter("r9_BType"))) { String orderId = req.getParameter("r6_Order"); String trxId = req.getParameter("r2_TrxId"); String payAmount = req.getParameter("r3_Amt"); boolean updated = orderService.markPaidIfMatch(orderId, payAmount, trxId); if (!updated) { resp.getWriter().write("FAIL"); return; } } resp.getWriter().write("SUCCESS"); } @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 同步跳转只展示页面,订单状态由前端调查询接口刷新 resp.sendRedirect("/pay-demo/result.jsp?orderId=" + req.getParameter("r6_Order")); } }

验签通过后,r1_Code 等于 1 表示支付成功,r9_BType 等于 2 才被认为是异步通知,可以更新订单。同步跳转的请求一律不碰订单表,只刷新结果页面,让用户看到“支付确认中”的中间状态。这里有个细节:markPaidIfMatch 方法内部会比较回调金额和数据库订单金额是否一致,这是防止金额被篡改的关键防线,不能省。

回调接口返回 SUCCESS 之前,不能再写其他内容到响应里,也不能先调用 sendRedirect,否则 Servlet 容器会抛 IllegalStateException,易宝侧收到非预期的响应会认为处理失败。生产上建议在回调入口加一行业务日志:收到回调,订单号是 r6_Order,来源是 BType 的值。这一行日志在后续排查重复通知、丢失通知时非常有用。

4. 避坑:易宝支付对接里最容易翻车的五个细节

支付对接的坑不像普通业务代码,报错信息往往就一行“验签失败”或“数据不对”,排查起来很玄学。下面五条是从源码测试记录里整理出来的高频问题,全部按现象、原因、解决的方式记录,照着查能省大半天。

4.1 回调验签失败,日志里只有一句签名不对

现象:测试支付时用户在收银台付了钱,浏览器也跳回成功页了,但服务端日志里签名校验不通过,订单没更新。

原因:最常见的是两处。一是发起支付用的是 LinkedHashMap,回调验签却用了 HashMap,遍历取值顺序不一样,签名串就变了;二是回调接口漏取了某个参数,比如 rp_PayDate 没放进验签集合,导致拼接串和易宝计算时少了内容。

解决:把回调验签参数也改成 LinkedHashMap,并且严格对照易宝回调文档里的参数顺序来 put。改完后再测一次,九成验签失败是这样解决的。若还失败,把验签用的原始拼接串打出来,和易宝签名串逐位对比,差在哪个字符就改哪个字段。

4.2 用户付了钱,订单还是未支付

现象:收银台已完成支付,异步通知也收到了,但数据库里订单状态还是 UNPAID。

原因:订单更新逻辑只应在异步通知分支执行,但更新前的状态校验没写,或者用了“先更新后判断”的写法;另一个常见原因是回调返回的字符串大小写不对,易宝要求返回大写 SUCCESS,如果返回了 success 或空,易宝会判定处理失败,持续重发通知。

解决:更新订单前先查一次订单状态,存在且为 UNPAID 再更新;接口返回固定字符串 SUCCESS。

注意:易宝识别的是原样输出的 SUCCESS 四个英文大写字母,任何 JSON 包装、空格、大小写差异都会被当成处理失败。

响应内容长度保持最短,不要带业务状态码,避免网关解析异常。

4.3 金额对不上,回调里的 amt 和订单差一分钱

现象:下单金额是 9.90,回调金额却是 9.9,数据库一比较就失败。

原因:金额在发起支付时没做 setScale(2),下单的时候把 9.9 拼给了 p3_Amt,网关在回调时原样返回 9.9,而数据库字段或 Java 比较时又按 9.90 的格式校验,两个字符串不等。

解决:统一规定金额的字符串格式。下单和回调比较都用 BigDecimal 的 setScale(2, RoundingMode.HALF_UP),不要用字符串 equals 直接比。数据库金额字段建议用 decimal(10,2),从源头保证格式一致。

4.4 本地联调,异步通知始终收不到

现象:本地起了服务,也能发起支付,但支付完成后,回调接口没任何日志,订单也不更新。

原因:易宝后台通知只能访问公网,localhost 或内网地址是不行的。本地联调没有公网入口,通知自然打不进来,这是最容易被忽视的环境问题。

解决:测试阶段把回调地址配到一台有公网 IP 的测试服务器上,或者用公司已有的测试环境域名转发到开发机。等本地逻辑验证完,再把回调地址切回真实测试环境。不建议把生产回调地址拿来测试,免得污染生产订单数据。

4.5 异步通知重复到达,订单状态被覆盖

现象:支付完成后,订单被正确置为已支付,但过了几分钟,日志里又出现同一条回调,订单状态反而变成了已取消或者异常。

原因:易宝为了可靠性会重发异步通知,直到收到 SUCCESS。如果更新订单时不检查当前状态,直接无条件更新,重发的通知可能把已支付状态覆盖成其他值。

解决:订单状态更新要做幂等处理。状态只有 UNPAID 才能流转到 PAID;如果已经是 PAID,相同的回调直接返回 SUCCESS,不做任何变更。源码里的 markPaidIfMatch 在 SQL 里带上了 state='UNPAID' 条件,配合 UPDATE 影响行数为 0 时直接返回,这是最稳的做法。

5. 按源码包的测试步骤跑一遍:从测试环境到回调落地

源码包里附带测试步骤图片,这一步把截图背后的操作补全。测试环境与生产环境的关键区别在于商户号和密钥不同,支付后的资金流是虚拟的,但代码路径和生产几乎一致,所以这套步骤跑通后,切生产只需要改配置和重新部署,不用动业务代码。

5.1 测试前必改的三项配置

第一项是 pay.properties 的商户号与密钥。第二项是项目部署的上下文路径,确保 pay/start 和 pay/callback 两个地址与你实际部署路径一致。第三项是异步通知地址,测试环境建议填测试服务器的公网地址或公司测试环境域名,并确保端口在安全组里放开。

配置完成后用两个 curl 命令做快速连通检查,一个看项目是否正常启动,一个看回调地址是否能被外部访问:

# 检查项目是否启动成功 curl -I http://your-server-ip:8080/pay-demo/pay/start # 检查回调地址是否能被外部访问,POST 一个空请求 curl -X POST http://your-server-ip:8080/pay-demo/pay/callback

第一个命令期望返回 302 或 200,不报 404 或 500。第二个命令即使验签失败,也要能收到 FAIL 而不是连接超时,连接超时说明回调地址不可达,需要先解决网络问题再继续往下测。

5.2 完整跑通一笔 0.01 元订单的测试步骤

按源码包里的测试步骤图片,测试订单金额统一用 0.01,金额越小越不容易出错。测试流程分成下面几步:

  1. 在测试页面录入一个测试商品,生成订单号并保存到数据库,订单状态初始为 UNPAID。
  2. 浏览器访问支付发起接口,携带订单号参数,触发自动提交表单跳转到易宝网关。
  3. 在网关收银台选择测试支付通道,按易宝开放平台提供的测试支付指引完成支付。
  4. 支付完成后观察两个结果:浏览器是否同步跳回 return.url;服务端日志是否出现回调请求,其中一条 r9_BType 为 2。
  5. 查数据库订单状态是否变为 PAID,同时确认数据库里记录了易宝的交易流水号 r2_TrxId。

这个顺序应该和源码包的测试步骤图片一一对应。如果哪一步断了,优先看服务端日志。日志里最容易认的是“回调验签通过,订单更新成功”之类的业务日志,没有这行日志就说明卡在验签或网络链路。测试通过后,可以把支付通道切到真实的小额银行卡通道再验证一次,用真实资金走通一遍全链路,生产上更放心。

5.3 验证回调链路的三个必要检查点

回调链路不能只看订单状态,三个检查点能确认整条链路是健康的。

第一个检查点,回调日志里出现两条不同 BType 的记录,一条是 1,一条是 2。只有 BType 为 2 的异步通知触发订单更新,若只有 1,说明你把更新订单逻辑写在了同步跳转里,这在生产环境是不可靠的。

第二个检查点,数据库支付流水表里的 trx_id 与易宝侧的交易号保持一致,且金额字段是两位小数格式。

第三个检查点,回调接口返回给易宝的响应内容是纯粹的 SUCCESS。如果易宝重发了超过三次通知,说明响应体被改成了 JSON 带引号或带了业务状态码,被网关判定为失败。

6. 进阶一点:把易宝回调结果统一成自己的 PayResult,让业务层不碰易宝字段

测试通过只代表能跑通,工程上还有一件事值得做:把易宝的字段包在自己的结果对象里,业务层不要直接依赖 r6_Order、r1_Code 这些易宝命名。否则将来换支付渠道或新增一个渠道网关时,业务方法全都要改。

处理思路很直接。定义一个 PayResult 只留业务关心的字段,渠道字段在回调入口处转换:

public class PayResult { private String orderId; private String channelTradeNo; private boolean paySuccess; private String rawCallback; }

在 PayCallbackServlet 里,验签并且 r1_Code 等于 1 之后,把易宝参数塞进 PayResult,再交给 orderService 处理。这样订单服务只认识 orderId 和 paySuccess,支付渠道变成可替换的适配器。将来接入别的支付平台,只需要在新渠道的入口里做同样的转换。

验证这种改动是否安全,可以做一个简单的回归:拿第 5 章测试时留下的回调参数,写一个 Mock 请求在本地回放,不断比较重构前后的订单更新结果是否一致。回放时重点关注回调参数里缺失字段和多余字段的处理,确保 PayResult 的 rawCallback 完整保留了原始内容,排障时还能拿到第一手信息。

从那以后,我每接到一个支付对接任务,都会强制自己做一遍:先画一次链路图,确认异步通知入口,再写渠道适配接口,最后才动业务代码。支付对接的大坑从来不在代码量的多少,而在签名顺序、回调幂等和金额格式这些细节上。希望这套源码的拆解能帮到你,拿过去改改配置,跑通一遍测试链路,比一个人面对文档摸索心里有数得多。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进