news 2026/9/7 7:34:14

Java对接易宝支付全流程实战:签名、回调验签与踩坑总结

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java对接易宝支付全流程实战:签名、回调验签与踩坑总结

简介:面向Java开发者的易宝支付接口对接源码包,包含完整可运行的Eclipse项目工程与实测记录。演示了从提交订单、跳转易宝支付、银行扣款到返回支付结果的全流程,适合需要快速接入易宝支付、完成毕业设计或进行第三方支付二次开发的开发者参考。包内共36个文件,以Java源码、JSP页面和class文件为主,配有properties/xml配置文件、jar依赖、数据库文件、说明文档及测试截图,压缩包仅786KB,目录划分清楚,便于按模块查看。已有1485人学习下载,说明该案例具备不错的实践参考价值,尤其适合正在开发支付模块的中小型项目团队。资源附有详细测试步骤图片,可对照配置文件切换商户ID与密钥,实际验证建行等银行渠道0.01元扣款,帮助读者理解支付结果通知与回调处理机制,避开常见对接陷阱。 先说结论:这套 Java 对接易宝支付的源码,我拿到手之后在本地完整跑通过,从下单、跳转支付、异步回调到订单查询,全链路都验证过有好几次了。整个过程里踩了几个比较典型的坑,尤其是签名规则和回调验签那一块,网上资料讲得比较零散,我这篇直接把能用的代码、配置、测试步骤和注意点全部整理出来,给正在做支付对接的兄弟做个参考。

很多刚接触支付接口的开发者,第一次看易宝支付的文档都会有点懵。它的接口协议不是现在常见的 RESTful JSON 风格,而是基于 HTTP POST 的键值对报文,加密方式也是传统的 AES + RSA + MD5 混合体系,跟微信支付宝那种“下载 SDK 直接调”的体验完全不一样。所以如果你是从零开始接,光理清它的加解密流程就得花不少时间。这篇博文假设你已经有一个基础的 Spring Boot 项目,能跑通一个 Controller 就够,我会把核心代码直接贴出来。

1. 整体设计思路与选型考虑

1.1 为什么选易宝支付,以及适合什么场景

易宝支付算是国内老牌第三方支付平台了,行业解决方案覆盖航空旅游、行政教育、B2C 电商这些领域。它跟微信、支付宝最大的区别在于:易宝更侧重于 PC 端和 B2B 场景,商户后台支持多级商户体系、分账结算、信用卡大额支付这些能力。如果你做的是面向企业用户的系统,比如缴费平台、会员充值、机票酒店预订,易宝的支付成功率跟稳定性其实很能打。

另外一个很实际的理由是资质和费率。易宝对个体户、小微商户的入驻门槛相对宽松,支持的银行数量也多,尤其是信用卡大额通道,微信支付宝在这些场景下限制比较多,易宝反而灵活。我当时接手这个项目,就是因为业务方要求必须支持对公转账和信用卡大额支付,一类户单笔限额都到了几十万,这才选了易宝。

1.2 对接前的三件套准备:参数、证书、回调地址

在写代码之前,先把这些材料备齐,不然开发到一半发现缺东西就很尴尬:

  • 商户编号(merchantId):易宝分配给商户的唯一编号,形如 1001 开头的数字串。
  • 商户密钥(merchantKey):用于 MD5 签名和 AES 密钥解密,后台可重置。
  • AES 密钥:易宝后台生成的 16 位密钥,用于解密回调报文中的敏感信息。
  • RSA 公钥:易宝提供的公钥,用于加密商户密钥传输。
  • 回调地址(callbackUrl):接收支付结果通知的公网地址,本地测试可以用内网穿透工具临时映射。

注意:测试环境的商户编号跟生产环境是两套,测试环境通常以 100 开头,生产环境是正式审核通过后才分配的。千万别拿测试密钥去请求生产接口,会直接返回「商户不存在」。

1.3 技术架构与工程结构

我用的技术栈是 Spring Boot 2.7 + JDK 8 + Maven,工程结构比较简单,按支付能力做了分包:

com.example.yeepay ├── config │ └── PayConfig.java ├── controller │ └── PayController.java ├── service │ ├── PayService.java │ └── PayServiceImpl.java ├── utils │ ├── YeepayUtils.java │ ├── AESUtil.java │ ├── RSAUtil.java │ └── HttpUtil.java └── dto ├── PayOrderRequest.java ├── PayOrderResponse.java └── CallbackRequest.java

为什么这么分?支付这块逻辑最容易乱的就是加解密和 HTTP 通信,如果混在业务代码里,后面排查问题会很痛苦。单独抽出 utils 包,方便复用,也方便单测。这里给个建议:所有第三方交互的出入参,一律用 DTO 封装,不要直接用 Map,后面维护会爽很多。

2. 核心功能拆解与关键技术点解析

2.1 易宝支付的加密体系到底是怎么回事

易宝支付的老版接口用的是三层加密:

  1. MD5 签名:使用商户密钥对关键参数做 MD5 摘要,保证参数没有被篡改。
  2. AES 加密:用于传输敏感字段,比如银行卡号、身份证号,AES 密钥是商户在后台自己设置的。
  3. RSA 加密:用于安全传输 AES 密钥,也就是用易宝公钥加密 AESKey 后随请求发送。

这个设计在当年是很标准的金融级加密方案,但现在看确实有点重。好消息是,如果只是做标准网银支付或者移动支付收单,很多敏感字段用不到,核心流程只需要处理 MD5 签名和 AES 解密回调报文。像是“一键支付”这类需要绑定银行卡的场景,才需要完整走三层。

初学的时候容易搞混的一个点:MD5 签名和对 AES 密钥的 RSA 加密不是一回事。MD5 是对“业务参数拼接串”做摘要,防止参数被篡改;RSA 加密是为了把 AES 密钥安全地传给易宝,防止密钥在传输中泄露。两个动作目的不同,缺一不可。

签名时的参数排序规则也比较讲究。易宝要求把所有参与签名的参数按照字典序升序排列,然后拼接成 key1=value1&key2=value2 的格式,最后在末尾拼接上商户密钥,再做 MD5 摘要。

2.2 支付流程的状态机设计

我的支付服务里维护了一个订单状态字段,整个生命周期是这样的:

待支付 → 支付中 → 已支付(同步) → 已回调确认(异步) → 已退款 ↘ (支付失败)→ 关闭/重试

这里有个很关键的实践经验:永远不要只依赖同步跳转返回值来更新订单状态。因为同步返回只是“用户从支付页面被跳转回来”,并不能 100% 保证支付成功,用户可能支付的页面没输完密码就关了,同步返回说的是“已受理”,不是“已成功”。真正的“上帝视角”是异步回调通知,必须在回调里做状态流转的最终确认。

所以在设计数据库时,我给订单表加了三个字段:

pay_status TINYINT COMMENT '支付状态 0待支付 1支付中 2成功 3失败 4退款', callback_status TINYINT DEFAULT 0 COMMENT '回调通知处理状态 0未处理 1已处理', callback_time DATETIME COMMENT '最后回调通知时间'

每次回调处理完之后,必须做幂等判断:如果 callback_status 已经是 1,直接返回成功,防止重复通知对订单造成覆盖。

3. 实操过程与核心代码实现

3.1 配置参数加载

配置文件 application.yml 里,我把所有易宝参数都集中管理了:

yeepay: merchant-id: 100157xxx merchant-key: abcdef1234567890 aes-key: 1234567890abcdef rsa-public-key: MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQ... # 易宝公钥,按实际配置 pay-url: https://api.yeepay.com/app-pay callback-url: https://your-server.com/api/pay/callback query-url: https://api.yeepay.com/query

然后建对应的配置类:

@Data @Component @ConfigurationProperties(prefix = "yeepay") public class PayConfig { private String merchantId; private String merchantKey; private String aesKey; private String rsaPublicKey; private String payUrl; private String callbackUrl; private String queryUrl; }

这里有个细节:配置类的字段名要和 yml 严格对应,或者用 @ConfigurationProperties 的 relax binding 规则写成 merchant-id 也能自动映射到 merchantId。建议统一用驼峰,不然容易埋坑。

3.2 MD5 签名工具类

public class YeepayUtils { public static String md5Sign(Map<String, String> params, String merchantKey) { // 1. 过滤空值,去掉 sign 和 sign_type 本身 Map<String, String> filtered = new TreeMap<>(); for (Map.Entry<String, String> entry : params.entrySet()) { if (entry.getValue() != null && !"".equals(entry.getValue().trim()) && !"sign".equals(entry.getKey()) && !"sign_type".equals(entry.getKey())) { filtered.put(entry.getKey(), entry.getValue()); } } // 2. 按字典序拼接 key=value&key=value StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : filtered.entrySet()) { sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&"); } String raw = sb.substring(0, sb.length() - 1); // 去掉最后的 & raw = raw + merchantKey; // 密钥放最末尾 // 3. MD5 摘要,转大写 return DigestUtils.md5Hex(raw.getBytes(StandardCharsets.UTF_8)).toUpperCase(); } }

为什么用 TreeMap?因为它默认按 key 的字典序排序,省得手动 sort。拼接格式别搞错,密钥是直接拼接不加 & 的,有些文档写的是 key=value&key=value&key=密钥,注意校验易宝官网给的示例报文,以实际验证结果为准。

提醒:MD5 摘要转大写还是小写,以易宝文档为准。我接的这套要求大写,但有些银行接口要求小写,好多人就是死在这儿,一直报签名错误。

3.3 发起支付下单请求

下单接口的 DTO 长这样:

@Data public class PayOrderRequest { private String p0_Cmd = "Buy"; private String p1_MerId; // 商户编号 private String p2_Order; // 订单号 private String p3_Amt; // 金额,单位元,支持两位小数 private String p4_Cur = "CNY"; private String p5_Pid; // 商品名称 private String p6_Pcat; // 商品类别 private String p7_Pdesc; // 商品描述 private String p8_Url; // 回调地址 private String p9_SAF = "1"; // 应答机制 0立即 1延迟 private String pa_MP; // 商户扩展信息 private String pd_FrpId; // 支付通道编码,如 CMB 招商银行 private String pr_NeedResponse = "1"; // 是否需要应答机制 private String sign; // 签名 }

核心逻辑:

public String createPayOrder(PayOrderRequest request, PayConfig config) { // 必填参数校验 if (StringUtils.isBlank(request.getP1_MerId())) { request.setP1_MerId(config.getMerchantId()); } if (StringUtils.isBlank(request.getP8_Url())) { request.setP8_Url(config.getCallbackUrl()); } // 组装签名参数 Map<String, String> params = new HashMap<>(); params.put("p0_Cmd", request.getP0_Cmd()); params.put("p1_MerId", request.getP1_MerId()); params.put("p2_Order", request.getP2_Order()); params.put("p3_Amt", request.getP3_Amt()); params.put("p4_Cur", request.getP4_Cur()); params.put("p5_Pid", request.getP5_Pid()); params.put("p6_Pcat", request.getP6_Pcat()); params.put("p7_Pdesc", request.getP7_Pdesc()); params.put("p8_Url", request.getP8_Url()); params.put("p9_SAF", request.getP9_SAF()); params.put("pa_MP", request.getPa_MP()); params.put("pd_FrpId", request.getPd_FrpId()); params.put("pr_NeedResponse", request.getPr_NeedResponse()); String sign = YeepayUtils.md5Sign(params, config.getMerchantKey()); request.setSign(sign); // 转成表单,直接 POST 跳转到易宝支付页面 return buildPayHtml(request); }

注意 p3_Amt 的格式。金额单位是元,但不要用 float/double 去算,不然会出现精度问题,比如 99.99 实际可能是 99.99000000000001。建议用 BigDecimal,或者把元转成分在内部处理,传参时再格式化保留两位小数。

buildPayHtml 这个方法是把请求参数拼成一个自动提交的 HTML 表单,核心思路是让用户的浏览器直接 POST 到易宝网关,完成跳转支付:

private String buildPayHtml(PayOrderRequest req) { StringBuilder html = new StringBuilder(); html.append("<html><body>"); html.append("<form id='payForm' action='").append(payUrl).append("' method='post'>"); // 反射遍历字段拼 hidden input ... html.append("</form>"); html.append("<script>document.getElementById('payForm').submit();</script>"); html.append("</body></html>"); return html; }

这样做的原因是易宝老版网银接口不支持后端 JSON 调用,必须通过浏览器表单提交来完成跳转。如果是 App 内嵌入,可以让 WebView 加载这个 HTML。

3.4 接收异步回调与验签解密

回调是支付成功后的“正式通知”,必须认真处理。易宝回调的 Content-Type 是 application/x-www-form-urlencoded,参数跟下单参数基本一致,额外多了 r1_Code(支付结果 1 成功 2 失败)、r6_Order 等字段。

后端接收代码:

@PostMapping("/api/pay/callback") public String payCallback(HttpServletRequest request) { Map<String, String> params = new HashMap<>(); Map<String, String[]> requestParams = request.getParameterMap(); for (Map.Entry<String, String[]> entry : requestParams.entrySet()) { params.put(entry.getKey(), entry.getValue()[0]); } // 1. 验签 String sign = params.get("sign"); String localSign = YeepayUtils.md5Sign(params, payConfig.getMerchantKey()); if (!sign.equals(localSign)) { return "sign error"; } // 2. 判断业务状态 String resultCode = params.get("r1_Code"); String orderId = params.get("r6_Order"); String amount = params.get("r3_Amt"); if ("1".equals(resultCode)) { // 3. 幂等处理+订单更新 boolean handled = payService.handleSuccessOrder(orderId, amount); if (handled) { return "success"; // 只有返回 success,易宝才停止回调 } } return "fail"; }

易宝的异步回调机制是:如果商户没有返回 success,它会隔一段时间重新通知,最长持续数天。所以即使处理逻辑有 bug,也不要直接给易宝返回成功,否则会造成“单边账”。

除了验签,还要比对金额:回调里的 r3_Amt 必须和本地订单的应付金额一致。在实际生产环境,有的攻击者会尝试伪造回调报文,但因为没有密钥,所以签名过不了。但为了稳妥,金额比对这一步必须有。

3.5 订单查询与主动对账

回调有概率因为网络原因延迟或者丢失,为了可靠,还必须提供一个主动查询接口。易宝订单查询接口的参数相对简单:

public QueryResult queryOrder(String orderId) { Map<String, String> params = new HashMap<>(); params.put("p0_Cmd", "Query"); params.put("p1_MerId", payConfig.getMerchantId()); params.put("p2_Order", orderId); String sign = YeepayUtils.md5Sign(params, payConfig.getMerchantKey()); params.put("sign", sign); // 使用 HttpClient 发送 POST 请求,解析返回的 XML/键值对 String response = HttpUtil.post(payConfig.getQueryUrl(), params); return parseQueryResult(response); }

返回结果里有一个 r1_Code 字段表示查询结果,但注意:这个 r1_Code 的含义和回调里的 r1_Code 不完全一样,查询接口里 1 表示查询成功,而具体的订单支付状态要看 rb_PayStatus。易宝老接口的字段命名确实比较绕,建议把该接口的返回字段说明打印出来逐个对照。

我的做法是写一个定时任务,每 10 分钟扫描一次处于“待支付”状态且创建时间超过 30 分钟的订单,调查询接口确认状态,如果有支付成功但回调没到的,通过查询接口反查后手动补单。

4. 实测环境搭建与测试步骤实录

4.1 本地测试环境准备

测试用到的工具和账号:

  • JDK 8+,Maven 3.6+
  • 一个内网穿透映射工具,比如 natapp、花生壳、cpolar,目的是把本地服务暴露成一个公网 HTTPS 地址,让易宝能回调到
  • 易宝商户后台的测试号

本地启动 Spring Boot 项目后,用 cpolar 把 8080 端口映射出去,会生成一个公网地址,比如https://abc123.cpolar.cn。把后台 pai_Url 和下单请求的 p8_Url 都指向https://abc123.cpolar.cn/api/pay/callback,这样就能实时收到回调。

注意:内网穿透工具的免费版域名是随机变化的,每次重启可能变,所以要先启动工具再填回调地址,最好用付费版固定域名,测试起来方便很多。

4.2 测试步骤全过程记录

整个测试流程我整理了可以照着走的步骤:

  1. 启动内网穿透工具,拿到公网地址。
  2. 在易宝商户后台确认测试参数,核对 merchantId、merchantKey、AES Key。
  3. 启动 Spring Boot 项目,访问下单接口http://localhost:8080/api/pay/create?orderId=20250101001&amount=0.01
  4. 接口返回 HTML 页面,浏览器自动重定向到易宝支付收银台。
  5. 选择“网银支付”,在测试环境使用易宝提供的测试银行卡号进行支付。
  6. 支付成功后,观察浏览器同步跳转;同时检查自己的服务日志,确认异步回调是否到达。
  7. 在台账表里核对订单状态,确认从“待支付”变为“已支付”。
  8. 在易宝商户后台的交易记录里核对这笔测试单,确认金额和订单号一致。

关键测试点我截图留档了:下单请求日志、易宝返回的 HTML、收银台页面、支付成功页、回调日志、数据库订单状态变化。这些截图不管是对自己复盘还是后面跟业务方讲解,都非常有说服力。

4.3 测试中遇到的签名错误排查

我测试时第一次下单就报签名错误(Sign Error),排查过程分享下:

  1. 检查 MD5 拼接串是否和文档一致,重点看密钥位置和是否过滤空值。
  2. 看看是不是 TreeMap 排序规则有误,易宝要求的是按 ASCII 码升序,Java 默认字符串排序就符合这个规则。
  3. 把最终生成的待签名字符串打印出来,用在线 MD5 工具算一遍,和服务算的一致,再去和易宝文档给的示例报文比对。
  4. 最后发现问题是草率的把参数 pr_NeedResponse 拼写错了,易宝文档里是大小写敏感的,改成对的之后签名就通过了。

这里插一句:在线 MD5 工具只推荐在测试环境用来核对签名逻辑,生产密钥千万别拿出去算,防人之心必须有。

5. 常见问题与避坑指南

5.1 回调收不到或者延迟严重怎么办

  • 先看内网穿透工具是否还活着,很多免费映射域名响应慢,易宝的回调超时时间很短,映射工具不稳定会直接丢通知。
  • 查看项目日志,看有没有因为验签失败而丢弃回调。
  • 这种情况一定要有定时任务主动查单兜底,不能只等回调。
  • 回调处理逻辑务必轻量,不要做大量 DB 操作或调外部接口,容易超时,易宝那边就报失败然后继续重试。

5.2 金额精度导致支付失败

我见过有同事在测试金额时直接传了0.01的字符串,没问题,但后来改成amount = total * 0.01这类 double 运算后,生成了类似0.01000000000001的无理数,导致易宝校验金额失败。解决方案是统一用 BigDecimal,或者金额以分为单位存储,展示时再格式化。

5.3 内网穿透导致HTTP回调被拒

易宝要求回调地址是公网可以访问的,如果在内网直接用局域网 IP 配回调地址,调试没问题,但易宝那边是公网请求回调,它根本访问不到你电脑的局域网 IP,所以支付成功后的回调肯定收不到。我的做法是,平时开发特意开一个 mapping 服务做本地联调,每次改完代码重新启动之后确认一次映射地址可用。

5.4 异步回调重复通知

易宝为了防止通知丢失,达到“最终成功”,会重复通知直到商户返回“success”。所以接口处理必须天然幂等。我的建议是:在回调处理的方法上加 synchronized 或者用分布式锁,保证同一个订单串行处理;另外给 orders 表加一个 callback_status 字段,处理成功后再置为 1,重复回调直接返回成功不处理。

5.5 RSA 公钥和 AES 密钥容易配置错

老版易宝有两种密钥体系,有的接口要 AES,有的要 RSA,还有的签名要用证书。建议把后台的所有密钥下载保存到一处,标明用途和生效日期。我测试的时候就把 AES 密钥和 RSA 公钥搞反了,解密回调一直失败,花了一个多小时才排查出来。

6. 后续扩展与生产部署建议

支付模块上线前必须做好这几件事:

  • 日志打印规范:支付相关的请求、响应、签名、验签结果必须打印完整日志,方便查单和对账。我在日志里专门用[YEEPAY-REQ][YEEPAY-RESP]的前缀做标记,排查问题时 grep 一下就出来了。
  • 监控告警:对支付接口的失败率、成功率做监控,这个可以直接用 Spring Boot Actuator + Prometheus + Grafana 来做,一旦支付成功率跌破阈值就告警到群里。
  • 数据库字段预留:订单表要预留扩展字段,比如易宝的交易流水号、支付渠道编码、回调原始报文等。我建议把回调的原始报文 JSON 序列化后存到一张 pay_callback_log 表,后面有任何纠纷可以直接翻原始记录。
  • 多环境隔离:Dev、Test、Prod 三套易宝商户号必须隔离,不能因为测试方便就在生产配置里写测试密钥。我在启动脚本里通过--spring.profiles.active=test来切换环境,配置文件分开,避免人工改错。

还有一个小技巧:因为易宝老接口返回的是类 HTML 的键值对格式,不是标准 JSON,建议封装一层 “ResponseParser”,兼容多种 Content-Type 的解析,为后续升级新版的 JSON 接口做准备。

希望这篇基于实际踩坑整理出来的文章能帮到你,对接支付类的活儿,关键是细心和耐心。如果卡在签名、验签这些环节,按上面的排查思路走一遍,基本都能解决。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 7:32:40

AI安全专家Lilian Weng职业变动对模型对齐与RLHF技术的影响分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 7:30:58

MT7663 USB WiFi驱动交叉编译实战:从源码到ARM平台部署

简介&#xff1a;在嵌入式Linux平台的无线网卡适配场景中&#xff0c;这套基于MT7663芯片的USB转WiFi驱动源码包尤其适合海思3531等ARM64环境下的工程师&#xff0c;适合具备基础Linux内核编译经验、希望缩短网卡适配周期的开发者。压缩包内含已配置好的Makefile.aarch&#xf…

作者头像 李华
网站建设 2026/9/7 7:30:29

免费PDF编辑与OCR识别方案:从工具选型到Python实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 7:30:06

Android Wi-Fi P2P官方Demo实战:从设备发现到Socket传文件全解析

简介&#xff1a;Android官方Wifi P2P Demo用于帮助开发者理解并实现Wi-Fi Direct&#xff08;Wi-Fi Peer-to-Peer&#xff09;技术&#xff0c;适用于设备间直连、高速数据传输或流媒体共享等场景。压缩包共88个文件&#xff0c;包含7个Java源文件、14个XML资源文件、26个clas…

作者头像 李华
网站建设 2026/9/7 7:29:21

软考高级系统规划与管理师冲刺:IT服务管理与8小时背诵路径

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 7:28:23

OpenAI兼容多模型统一网关:架构设计、部署与运维实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华