news 2026/9/16 19:26:01

Dify+飞书多维表格构建自动化简历筛选流水线

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Dify+飞书多维表格构建自动化简历筛选流水线

最近有HR朋友问我,简历筛选能不能真正用AI跑起来。我的答案是能,而且不只是“能筛”,是能从下载附件、解析PDF、按JD打分到回写表格全程自动化。这套东西我给自己团队搭完跑了快一个季度,正好赶上Dify版本迭代和飞书多维表格能力补全,操作路径已经很顺。这篇文章就把“Dify+飞书AI表格打造自动化简历处理流水线”的完整思路、核心代码、踩坑记录全部摊开,适合正在做HR数字化、想给招聘提效的技术同学直接抄作业。

先说结论:一条简历处理流水线,本质上就是把“收简历—读简历—做判断—记结果”这件事拆成几个标准动作,让系统代替人工去跑。飞书AI表格负责承载简历数据和协同视图,Dify工作流负责调用大模型完成内容解析与JD匹配评分,中间通过开放API把两边接起来。最终效果是:HR把简历附件往多维表格里一扔,几分钟后就能看到结构化字段(姓名、工龄、技能、匹配度分)自动填好,按分数排序筛选即可。

我后面会讲到整体架构、飞书应用配置、Dify工作流节点设计、完整可运行的Python调度脚本,以及我在落地过程中真实遇到的高频问题。建议准备一小时,边看边动手试。

1. 先聊聊背景:简历筛选为什么值得交给自动化流水线

1.1 HR筛简历的真实成本与痛点

基础数据是这样的:一个中等规模公司发布一个技术岗,三天内可能收到150到300份简历,其中真正匹配的通常不到10%。HR要把这些简历逐个下载、打开、翻看、判断,每份保守估计3到5分钟,一轮初筛就要花掉大半天。而且这活儿特别反人性——前50份还能认真看,到第150份的时候,注意力早就垮了,漏掉优质候选人的概率非常高。

更麻烦的是,简历格式五花八门:有的是PDF,有的是Word,有的直接在邮件正文里贴了一段带排版的文本。同一个人,用不同模板写出来的简历,HR读取效率完全不一样。我做自动化之前统计过,团队HR平均每天花在“纯体力筛简历”上的时间接近4小时,真正给候选人打电话沟通的时间反而被压缩到1小时以内。这个比例放在招聘旺季,直接导致优秀候选人被拖到失去耐心。

1.2 为什么不直接用飞书自带AI字段,非要搭一套Dify工作流

很多人第一反应是:飞书多维表格现在自带AI字段,不是能提取信息吗?对,基础提取确实能跑,但把它放到生产环境就发现三个问题。

一是自定义能力不够。飞书AI字段适合做固定模板的抽取,但简历结构千变万化,项目经历、技能关键词、薪资期望这些字段经常能占满token上限,提示词又不能灵活调整。二是无法做复杂的流程编排。筛选不只是“提取字段”,还要和岗位JD做匹配评估、输出推荐语、按规则分流到对应招聘负责人,这是一个多步骤、多条件的判断过程。三是隐私和模型无法自主可控。飞书内置AI能力默认走的是平台侧模型,公司如果要求简历数据留在本地或者指定大模型,就基本无法满足。

Dify工作流正好补上这些。Dify是开源的大模型应用开发平台,可视化编排节点,能接自己的模型API,也能部署在内部环境。多维表格负责“数据进出”,Dify负责“AI推理”,两边用API联通,这是目前我试下来最清晰的组合方式。

1.3 这套流水线的整体数据和逻辑架构

先画个简单的逻辑链路(不涉及具体代码,只是流程):

  • 第一步,HR把收到的简历文件传到飞书多维表格附件字段,并手动选择这岗位对应的JD,或者提前把JD维护在表里。
  • 第二步,外部Python脚本定时扫描表格,找出所有“处理状态=待处理”的记录,下载附件并把PDF/Word解析成纯文本。
  • 第三步,脚本把简历文本、JD要求、表格元信息作为入参,调用Dify工作流API。
  • 第四步,Dify内部按“提示词解析—字段提取—JD匹配评分—结果结构化—回写飞书”的链路跑完。
  • 第五步,飞书表格对应记录被自动填上结构化信息和推荐分数,状态变为“已处理”。

这里最核心的设计思路是:不要把所有复杂的文件解析逻辑都塞进Dify工作流,而是让外部脚本承担“脏活累活”(文件下载、格式兼容、文本提取),Dify专注于“大脑工作”(理解简历、判断匹配度)。这样Dify工作流很容易调试,外部脚本出问题也不会影响AI链路。

2. 开工前做好三件事:账号权限、模型接入、表格设计

2.1 Dify环境准备与大模型接入(支持本地化方案)

如果你已经部署过Dify,直接跳过这部分。还没有部署的话,推荐用Docker Compose方式,官方仓库拉下来,在docker目录下执行cp .env.example .env,再docker compose up -d,Dify本身和Postgres、Redis、向量库等依赖会一起起来。Dify最近几个版本(包括社区版的1.17系列)对工作流编排体验一直在优化,建议直接用最新稳定版。

部署完成后,进入Dify后台的“设置—模型供应商”,把你要用的大模型API Key填进去。我这边主要用两种:

  • 调用云厂商API(如通义千问、智谱GLM、DeepSeek等),速度快、效果稳定,适合不想维护GPU的团队。
  • 本地部署Ollama再接开源模型(如Qwen系列),数据不出内网,适合对简历数据保密要求高的场景。

我的建议是:初期先用云API跑通流程,确认效果后再切本地模型。因为本地小尺寸模型在“从长文档中准确抽取结构化字段”这个任务上,效果和云端大模型差距还是比较明显的,至少要到14B以上才勉强可用。

2.2 飞书开放平台准备:自建应用、权限开通、token获取

要让脚本能读写多维表格,需要在飞书开放平台创建一个企业自建应用。具体路径:飞书开放平台 → 创建企业自建应用 → 补充应用名称和描述。

创建完成后,进入“权限管理”,把以下权限范围加进去:

  • bitable:app(查看多维表格元数据)
  • bitable:record:read(读取记录)
  • bitable:record:write(写入/更新记录)
  • drive:drive(读取云空间文件,用于下载简历附件)

权限配置好之后,最关键的一步:必须要点“创建版本”并发布,让权限真正生效。很多新手在这卡住,脚本一调接口就报401或权限错误,基本都是因为只加了权限没发布版本。

然后在“凭证与基础信息”页面拿到App ID和App Secret,这两个值就是脚本的“身份证”。获取tenant_access_token的接口我后面会写在代码里,有效期是2小时,建议在脚本里做缓存,避免高频调用被限流。

2.3 多维表格字段设计与状态机约定

表格设计看似简单,实际上直接影响Dify回写数据的成功率。我的建议是建一张名为“简历初筛”的多维表格,字段如下:

字段名类型说明
候选人姓名文本由Dify自动回填
联系电话文本由Dify自动回填
邮箱文本由Dify自动回填
工作年限文本由Dify自动回填,格式如“5年”
最高学历文本由Dify自动回填
技能标签文本由Dify自动回填,用顿号分隔
最近公司文本由Dify自动回填
最近职位文本由Dify自动回填
综合画像文本由Dify自动回填,一句话候选人总结
匹配度评分数字0-100分,由Dify自动回填,便于排序
匹配理由文本由Dify自动回填
处理状态单选待处理 / 已处理 / 异常,外部脚本和工作流共同维护
目标JD文本招聘需求或岗位描述,HR手动填入
简历文件附件HR手动上传简历PDF/Word

字段类型一定要对着类型来,比如“匹配度评分”必须是数字字段,Dify回写的时候传数字,否则飞书接口会报参数不合法。状态字段用单选,默认值设为“待处理”,这样脚本扫描非常方便。

3. 核心实现:Dify工作流节点拆解与关键代码

3.1 整体工作流节点编排思路

进入Dify“工作流”页签,新建一个空白工作流,命名为“简历分析Agent”。我的编排思路是“尽量精简节点,把复杂计算拆到外部脚本”,最终节点结构如下:

  • 开始节点:定义输入变量(resume_text、job_requirement、app_token、table_id、record_id、tenant_access_token)
  • LLM节点:简历结构化解析与JD匹配评估,输出JSON文本
  • 代码节点①:清洗LLM输出,提取合法JSON对象
  • 代码节点②:组装飞书更新记录的URL和请求体
  • HTTP请求节点:调用飞书API,把结构化结果写回多维表格
  • 结束节点:返回最终解析结果

这里要注意一个设计细节:我没有在Dify内部做简历文件下载和PDF解析。原因是代码执行器里处理PDF很容易踩依赖坑,而且Dify不是为“文件处理”设计的,外部Python脚本用pdfplumber、python-docx处理这些更稳定。Dify只接收“已经转好的纯文本”,整个链路职责清晰,排查问题也简单。

3.2 简历解析:提示词模板与JSON结构化提取

LLM节点是流水线的大脑。提示词我反复调了很多版,核心要点有三个:明确输出字段、要求只输出JSON、把JD放在简历后边作为评分参考。

下面是我现在稳定使用的提示词模板(LLM节点变量里引用开始节点传入的resume_textjob_requirement):

你是资深HR招聘助手。请根据候选人简历内容,提取结构化信息,并评估其与目标JD的匹配度。 要求: 1. 只输出一个合法JSON对象,不要输出多余解释。 2. JSON字段必须严格包含: candidate_name(候选人姓名)、phone(联系电话)、email(邮箱)、 work_years(工作年限,如“5年”)、education(最高学历)、 skills(技能列表,数组形式)、recent_company(最近公司)、 recent_position(最近职位)、summary(不超过50字的候选人画像)、 match_score(匹配度评分,0到100的整数)、match_reason(匹配与不匹配的核心理由)。 简历内容: {{resume_text}} 目标JD: {{job_requirement}}

LLM参数建议:temperature设置为0.1到0.3之间,不要太高,否则JSON格式经常漂。max_tokens设置到1500以上,简历内容比较长时可以有效避免输出被截断。

3.3 两个关键代码节点:清洗LLM输出与组装回写参数

代码节点①的作用是清洗LLM返回的文本。虽然提示词要求只输出JSON,但大模型经常不听话,喜欢甩个代码块标记,或者加一段“好的,根据简历内容,我提取的JSON如下”。我的处理逻辑是:去掉代码块标记,截取第一个{到最后一个}之间的内容,再json.loads解析。

import json def main(raw_output: str) -> dict: text = raw_output.strip() # 去掉markdown代码块标记 if text.startswith("```"): lines = text.splitlines() lines = [line for line in lines if not line.strip().startswith("```")] text = "\n".join(lines) start = text.find("{") end = text.rfind("}") if start == -1 or end == -1: return {"error": "no json found", "raw": raw_output} content = text[start:end + 1] try: data = json.loads(content) except json.JSONDecodeError as e: return {"error": f"json decode failed: {e}", "raw": raw_output} return data

Dify代码节点的入口统一是main函数,参数名要和上游节点输出变量名保持一致,返回值必须是dict,这里返回的就是清洗后的JSON内容。

代码节点②负责把解析结果转换成飞书API需要的请求体。这里最容易踩坑的是字段名必须和表格字段名完全一致,另外“技能标签”我做了个处理:如果模型返回的是数组,就拼接成顿号分隔的字符串再写入文本字段。

import json def main(tenant_access_token: str, app_token: str, table_id: str, record_id: str, parsed: dict) -> dict: if "error" in parsed: return { "success": False, "message": parsed.get("error", "unknown error") } skills = parsed.get("skills", []) if isinstance(skills, list): skills_str = "、".join([str(item) for item in skills]) else: skills_str = str(skills if skills else "") fields = { "候选人姓名": parsed.get("candidate_name", ""), "联系电话": parsed.get("phone", ""), "邮箱": parsed.get("email", ""), "工作年限": parsed.get("work_years", ""), "最高学历": parsed.get("education", ""), "技能标签": skills_str, "最近公司": parsed.get("recent_company", ""), "最近职位": parsed.get("recent_position", ""), "综合画像": parsed.get("summary", ""), "匹配度评分": int(parsed.get("match_score", 0)), "匹配理由": parsed.get("match_reason", ""), "处理状态": "已处理" } url = ( "https://open.feishu.cn/open-apis/bitable/v1/apps/" + app_token + "/tables/" + table_id + "/records/" + record_id ) headers = { "Authorization": "Bearer " + tenant_access_token, "Content-Type": "application/json; charset=utf-8" } payload = {"fields": fields} return { "success": True, "url": url, "headers": headers, "body": json.dumps(payload, ensure_ascii=False) }

3.4 回写飞书表格的HTTP请求配置

代码节点②输出urlheadersbody之后,我用Dify的HTTP请求节点发起更新。HTTP节点配置如下:

  • 请求方法:PUT
  • URL:直接引用代码节点输出的url变量,或者手动拼接https://open.feishu.cn/open-apis/bitable/v1/apps/{{app_token}}/tables/{{table_id}}/records/{{record_id}}
  • Headers:Authorization: Bearer {{tenant_access_token}}
  • Body类型:JSON,内容引用代码节点输出的body字段

HTTP节点的时间建议设置到30秒以上,因为偶尔飞书接口响应会比较慢。如果返回的状态码是200,记录更新成功,结束节点把success: truematch_score一起返回;如果是4xx,结束节点把错误信息原样返回,方便后续排查。

4. 把整条链路串起来:外部调度脚本与运行效果

4.1 定时调度脚本的职责边界

外部调度脚本是整个流水线的“总控”。它的职责只有四块:扫描待处理记录、下载附件并解析文本、调用Dify工作流、处理异常和日志。注意,它不需要负责把结果写回表格,因为Dify工作流内部已经通过HTTP节点直接更新了多维表格记录。这样分工,脚本代码量能控制得很小,即使以后Dify工作流调整了内部逻辑,脚本也基本不用动。

运行方式我建议用cron或系统计划任务,比如Linux下每10分钟跑一次:

*/10 * * * * cd /data/hr-pipeline && python run_pipeline.py >> pipeline.log 2>&1

如果你是Windows服务器,就用“任务计划程序”定时执行python run_pipeline.py

4.2 完整Python调度脚本(可复制)

下面是我精简过、但可以实际运行的脚本主体。你只需要替换顶部配置里的APP_ID、APP_SECRET、表格参数、Dify地址和API Key即可。

import os import time import requests import pdfplumber import docx # ========== 配置区 ========== APP_ID = "cli_xxxxxxxx" APP_SECRET = "xxxxxxxxxxxxxxxx" APP_TOKEN = "bascnxxxxxxxxxxxxxxxx" # 多维表格 app_token TABLE_ID = "tblxxxxxxxxxxxxxxxx" # 多维表格 table_id DIFY_URL = "http://localhost/v1/workflows/run" DIFY_API_KEY = "app-xxxxxxxxxxxxxxxx" FIELD_RESUME = "简历文件" FIELD_JD = "目标JD" FIELD_STATUS = "处理状态" # =========================== def get_tenant_access_token(): resp = requests.post( "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal", json={"app_id": APP_ID, "app_secret": APP_SECRET}, timeout=10 ) data = resp.json() if data.get("code") != 0: raise Exception(f"获取tenant_access_token失败: {data}") return data["tenant_access_token"] def list_all_records(token): records = [] page_token = "" while True: params = {"page_size": 100} if page_token: params["page_token"] = page_token resp = requests.get( f"https://open.feishu.cn/open-apis/bitable/v1/apps/{APP_TOKEN}/tables/{TABLE_ID}/records", headers={"Authorization": f"Bearer {token}"}, params=params, timeout=15 ).json() if resp.get("code") != 0: raise Exception(f"读取表格失败: {resp}") records.extend(resp["data"]["items"]) if resp["data"].get("has_more"): page_token = resp["data"]["page_token"] else: break return records def get_resume_download_url(field_value): if not isinstance(field_value, list) or len(field_value) == 0: return None one = field_value[0] if one.get("tmp_url"): return one["tmp_url"] if one.get("url"): return one["url"] if one.get("file_token"): return download_file_by_token(one["file_token"]) return None def download_file_by_url(url): ext = ".pdf" tmp_path = f"/tmp/resume_{int(time.time() * 1000)}{ext}" r = requests.get(url, timeout=30) with open(tmp_path, "wb") as f: f.write(r.content) return tmp_path def extract_text(path): text = "" if path.endswith(".pdf"): with pdfplumber.open(path) as pdf: for page in pdf.pages: text += (page.extract_text() or "") + "\n" else: doc = docx.Document(path) text = "\n".join([p.text for p in doc.paragraphs]) return text.strip() def call_dify_workflow(resume_text, job_require, record_id, token): headers = { "Authorization": f"Bearer {DIFY_API_KEY}", "Content-Type": "application/json" } payload = { "inputs": { "resume_text": resume_text, "job_requirement": job_require, "app_token": APP_TOKEN, "table_id": TABLE_ID, "record_id": record_id, "tenant_access_token": token }, "response_mode": "blocking", "user": "hr-pipeline-bot" } resp = requests.post(DIFY_URL, json=payload, headers=headers, timeout=60) data = resp.json() if data.get("data") and data["data"].get("outputs"): return data["data"]["outputs"] raise Exception(f"Dify工作流调用失败: {data}") def main(): token = get_tenant_access_token() records = list_all_records(token) for record in records: fields = record.get("fields", {}) if fields.get(FIELD_STATUS) != "待处理": continue record_id = record["record_id"] print(f"处理记录: {record_id}") try: resume_field = fields.get(FIELD_RESUME, []) download_url = get_resume_download_url(resume_field) if not download_url: print(" 未找到简历附件,跳过") continue file_path = download_file_by_url(download_url) resume_text = extract_text(file_path) if len(resume_text) < 50: print(" 简历文本过短,疑似扫描件,标记异常") continue job_require = fields.get(FIELD_JD, "") outputs = call_dify_workflow(resume_text, job_require, record_id, token) print(f" 完成,Dify返回: {outputs}") except Exception as e: print(f" 处理失败: {e}") time.sleep(1) if __name__ == "__main__": main()

这个脚本我故意简化了一些细节,比如没有做token缓存,但每轮运行获取一次token完全够用。真实使用的时候你还要注意几点:

  • 附件字段的tmp_url有时效性,尽量拿到后立刻下载,不要存起来隔天再跑。
  • Python环境需要安装requestspdfplumberpython-docx三个包,安装命令是pip install requests pdfplumber python-docx
  • 遇到扫描版PDF(比如照片拍的简历),pdfplumber提取出来是空文本,这种情况我建议在表格里加一个“简历类型”字段,让HR标注是否为扫描件,脚本遇到空文本就标成“异常”,后续用OCR方案单独处理。

4.3 我实测的一组效果数据与观察

上线这套流水线后,我记录了连续三周的数据,可以给大家一个参考(数据来自我自己的测试环境,效果因人而异):

指标纯人工处理Dify流水线处理
每份简历平均耗时4-6分钟12-25秒
批量处理200份简历约5小时约1.5小时
结构化字段完整率依赖人工填写90%-95%
匹配度判断一致性因人而异同一JD下相对稳定

这里要特别说明:字段识别准确率不是100%。“邮箱”和“电话”这种固定格式字段基本稳定,但“技能标签”这种开放字段偶尔会丢或者多抓。我的做法是让LLM输出原始技能数组,脚本只做拼接,不替模型做判断,这样即使有个别错误,HR一眼就能看出来并修正。

5. 实战问题排查速查表与几条实在建议

5.1 高频问题与排查方法(表格)

现象可能原因排查方法
Dify返回调用失败,日志显示401飞书tenant_access_token过期或权限未发布检查开放平台权限配置,确认已创建版本并发布;token建议每次运行重新获取
PDF解析出来是空字符串扫描件或图片型PDF先人工确认文件类型;文本型PDF用pdfplumber,扫描件接入OCR服务
LLM输出的JSON经常解析失败temperature设置太高或max_tokens不够把temperature降到0.1-0.3,max_tokens提到1500以上;代码节点做markdown代码块清理
飞书更新记录报1001参数错误字段类型不匹配(比如评分字段传了文本)检查表格字段类型,数字字段必须传int,不要传引号包裹的数字
脚本提示找不到模块本地缺少依赖包执行pip install requests pdfplumber python-docx
Dify HTTP节点响应超时飞书接口偶发慢HTTP节点超时时间调到30秒以上;脚本侧加time.sleep控制频率
工作流开发调试时看不到中间变量节点输出被折叠在Dify调试面板直接点节点查看outputs,或者临时加一个打印节点输出到日志

5.2 几个大家容易忽略的细节

第一,多维表格的“目标JD”字段建议做成规范化下拉或者文本,不要每次用不同的说法。比如你一会写“3年以上Java”,一会写“熟练Java语言优先”,模型给出的match_score会有波动。JD文本越接近真实招聘描述,评分越稳定。

第二,Dify工作流的开始节点参数必须和脚本里inputs的key完全一致,大小写都不能错。我遇到过几次因为record_id写成recordId导致飞书更新失败的案例,这种问题日志里不仔细看很不好找。

第三,回写状态时,不要只写“已处理”。我保留了“异常”状态,用于区分扫描件、附件缺失、解析失败这些情况。这样每天早上的checklist,HR只需要筛选“处理状态=异常”的记录单独处理,效率高很多。

第四,关于模型选择,我用同一批真实简历对比过不同供应商的模型。综合来看,参数更大的模型在“summary”和“匹配理由”上写得明显更合理,小模型的优势是快、便宜,但偶尔会把不存在的公司名编出来。所以如果公司对准确性要求高,建议至少用中档以上云端模型,不要为了省几分钱在初筛环节埋雷。

最后再分享一个我踩过几次坑之后总结的小技巧:开发阶段不要直接跑全量数据,可以先把表格里数十条记录的状态改成“待处理”,然后手动执行一遍脚本,看Dify调试面板里每一轮的输出。确认无误之后再设置定时任务。调提示词的时候,拿同一条简历反复跑,你会发现模型对同一条简历的理解会有细微波动,这是正常的,只要结构化字段和评分没有大偏差,就可以接受。

这套流水线跑顺之后,HR的活法肯定是变了。我团队里的招聘同事现在每天到岗的第一件事不再是闷头翻PDF,而是打开多维表格按匹配度评分排个序,把前20%约来聊一聊。技术说到底就是帮人把注意力放回真正重要的地方。接下来你还可以在这个基础上接着扩展——比如给离职风险高的记录加自动提醒,或者把面试反馈也接入表格形成闭环。方向很多,先把流水线跑起来,后面的事都好说。

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

51单片机与74HC595级联实现64位流水灯(附Proteus仿真)

简介&#xff1a;基于51单片机的64位花样流水灯完整资料包&#xff0c;适合单片机初学者、课程设计及电子竞赛备赛人群。设计采用74HC595驱动扩展64个LED灯&#xff0c;低电平驱动点亮&#xff0c;并通过5个独立按键切换5种不同流水花样&#xff0c;覆盖了串行扩展芯片、IO控制…

作者头像 李华
网站建设 2026/9/16 19:24:12

BRepNet加工特征识别实战:从图网络原理到工程部署

一开始接触BRepNet&#xff0c;是在一个老客户的CAM自动编程项目里。对方要求把过去需要工艺工程师手工标注的孔、槽、台阶全部自动识别出来&#xff0c;接进后处理流程。当时我第一反应还是走传统几何推理的老路&#xff0c;结果折腾了一个多月&#xff0c;面对千奇百怪的倒角…

作者头像 李华
网站建设 2026/9/16 19:23:57

MATLAB带电粒子混合电磁场轨迹仿真与ODE求解器实战

简介&#xff1a;一份基于MATLAB的带电粒子在混合场运动仿真模拟实验源码&#xff0c;针对均匀电场、均匀磁场及其叠加等不同混合场情景&#xff0c;能够准确计算并绘制带电粒子的运动轨迹&#xff0c;帮助学习者直观理解电磁场对粒子运动的影响。压缩包内共7个文件&#xff0c…

作者头像 李华
网站建设 2026/9/16 19:23:42

CubeSandbox使用12个常见陷阱:老手总结的血泪经验清单

CubeSandbox使用12个常见陷阱&#xff1a;老手总结的血泪经验清单 【免费下载链接】CubeSandbox Instant, Concurrent, Secure & Lightweight Sandbox for AI Agents. 项目地址: https://gitcode.com/GitHub_Trending/cu/CubeSandbox CubeSandbox 是面向 AI Agent 的…

作者头像 李华