不用翻文档了,飞书云文档API怎么调、token怎么拿、表格怎么写、机器人怎么推消息、Dify这类AI平台怎么接入,这篇一次性讲完。前阵子在做一个内部知识库项目,需要把飞书云文档作为数据源接入AI应用,网上资料东一块西一块,照着官方文档调接口踩了不少坑,尤其是授权凭证获取和API报错排查那一段,费了不少时间。这篇文章就把我实际跑通的完整过程整理出来,从应用创建、鉴权取token,到云文档读写、表格导入、消息推送,最后再补上Dify接入飞书时的授权凭证获取方式和常见报错定位,希望对正在做类似对接的人有帮助。
1. 动手前的底层认知:飞书开放平台的鉴权模型与应用创建
飞书云文档API走的是飞书开放平台那套标准OAuth 2.0体系,对接之前先把鉴权模型吃透,后面能省很多事。这套模型本质上就两种身份:应用身份和用户身份。
1.1 为什么租户令牌是最常用的鉴权方式
飞书API的访问令牌主要分三类:tenant_access_token(租户访问令牌)、user_access_token(用户访问令牌)、app_access_token(应用访问令牌)。
实际做服务端对接时,90%的场景用的都是tenant_access_token。它代表的是"整个应用"的身份,只要应用具备对应权限范围,用这个token就能操作所有有权限的资源,比如读取任何员工名下的云文档,前提是权限范围配了。它不需要用户参与授权跳转,拿一个app_id和app_secret就能换,非常适合后端服务调用。
user_access_token代表"某个用户"的身份,走OAuth授权流程,需要用户在浏览器里点同意授权,然后拿授权码换token。它的用处主要是以用户身份操作文档,比如代表某个用户创建文档、@人、评论等。如果你做的是纯后端数据同步、机器人推送这类场景,用不到它。
至于app_access_token,和tenant_access_token类似,但它是被多个租户安装后都能用的"全局应用身份"。普通自建应用不需要用这个,企业自建场景下tenant_access_token就够用了。
1.2 创建企业自建应用的完整步骤与权限开通逻辑
拿到token的前提是先有一个应用。登录飞书开放平台,在开发者后台点击"创建企业自建应用",填好应用名称和描述。创建完之后,必须做两件事:开权限和发版本。
第一件事是开通API权限。打开"权限管理"页面,搜索你需要的API权限点,点击开通。这里有个关键坑:飞书的权限是双向控制的,权限管理页面开通了还不够,如果应用没有发布新版本,权限是不会生效的。
第二件事是创建版本并发布。在"版本管理与发布"页面创建版本,填版本号(比如1.0.0)、更新说明,然后提交发布。企业自建应用需要企业管理员在管理后台审核通过,通常很快,管理员在飞书管理后台"工作台→应用管理"里点一下同意就行。发布成功之后,新的权限才会真正生效。
权限申请的逻辑建议从一开始就想清楚。飞书文档相关API的权限点拆得很细:docx:document系列管云文档读写,sheets:spreadsheet管电子表格,drive:drive管云空间文件。如果你要做的是AI知识库场景——读取文档内容喂给大模型——至少需要开通以下权限:
| 权限点 | 用途 |
|---|---|
docx:document:readonly | 读取云文档内容 |
drive:drive:readonly | 读取云空间文件列表 |
sheets:spreadsheet:readonly | 读取电子表格内容 |
im:message:send_as_bot | 机器人发送消息 |
drive:file:upload | 上传文件到云空间 |
这些权限覆盖了"读文档→写表格→推消息→传文件"这条完整链路。权限开多了有安全风险,开少了后面接口报权限错误又要重新发版本,所以开始前最好列个清单。
2. 从凭证到调用:access_token获取与缓存策略
鉴权模型清楚了,下一步就是写代码拿token。这一步本身不难,就是两个参数换一个token,但里面有个缓存细节很多人会忽略,导致线上环境偶发报错。
2.1 获取tenant_access_token的代码实现
接口地址是POST https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal,请求体只需要两个字段:app_id和app_secret。这两个值在开发者后台的"凭证与基础信息"页面可以找到。
用Python的requests库写一个最基础版本:
import requests def get_tenant_access_token(app_id: str, app_secret: str) -> str: url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" payload = { "app_id": app_id, "app_secret": app_secret } resp = requests.post(url, json=payload, timeout=10) resp.raise_for_status() data = resp.json() if data.get("code") != 0: raise RuntimeError(f"获取token失败: {data.get('msg')}") return data["tenant_access_token"]返回的JSON里有个expire字段,单位是秒,默认7200秒(2小时)。注意:token过期后直接调用业务接口会返回99991663之类的错误码,所以不能每次调用都重新换token,也不能拿到手就永久缓存。
2.2 token过期与并发刷新:一个容易踩的坑
我见过不少初学者在每个请求里都调一次获取token的接口,虽然能跑通,但有两个问题:一是每次多一次网络往返,接口变慢;二是飞书开放平台对token接口有频控,短时间大量调用会触发99991664请求过于频繁的报错。
正确做法是把token缓存到内存或者Redis里,设置一个比实际过期时间略短的过期时间(比如7000秒),过期后重新获取。单机部署用内存缓存就够了,多实例部署建议用Redis加分布式锁,避免多个实例同时刷新token导致互相覆盖。
我实际用的缓存逻辑大致是这样:
import time import threading class TokenManager: def __init__(self, app_id: str, app_secret: str): self.app_id = app_id self.app_secret = app_secret self._token = None self._expire_at = 0 self._lock = threading.Lock() def get_token(self) -> str: # 提前60秒过期,留出刷新余量 if self._token and time.time() < self._expire_at - 60: return self._token with self._lock: if self._token and time.time() < self._expire_at - 60: return self._token self._token = self._refresh() self._expire_at = time.time() + 7000 return self._token def _refresh(self) -> str: url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" payload = {"app_id": self.app_id, "app_secret": self.app_secret} resp = requests.post(url, json=payload, timeout=10) data = resp.json() return data["tenant_access_token"]这个类内部用threading.Lock()保证并发安全,多线程环境下不会出现token被重复刷新的问题。如果你的服务是多实例部署,把_token和_expire_at挪到Redis里,刷新逻辑加分布式锁即可。
3. 云文档核心API实操:创建、导入与读取内容
拿到token之后,就可以操作文档了。这一节我把最常用的三个场景串起来:创建空白文档、往文档里导入Markdown、读取文档内容。这三个操作基本覆盖了AI知识库的写入和读取两端。
3.1 创建云文档并指定文件夹
创建文档的接口是POST https://open.feishu.cn/open-apis/docx/v1/documents,请求头带上Authorization: Bearer {tenant_access_token}。
def create_doc(token: str, title: str, folder_token: str = None) -> str: url = "https://open.feishu.cn/open-apis/docx/v1/documents" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } body = {"title": title} if folder_token: body["folder_token"] = folder_token resp = requests.post(url, headers=headers, json=body) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"创建文档失败: {data}") return data["data"]["document"]["document_id"]folder_token是可选的。按照飞书API的设计,不传folder_token时文档会创建在应用自己的云空间里,通常叫"我的空间"。如果你想归到某个共享文件夹下面,得先把那个文件夹的token拿过来。
怎么拿文件夹token?两种方式:一是调用GET https://open.feishu.cn/open-apis/drive/v1/files,把folder_token参数设为你要找的文件夹的token,逐级往下翻;二是在网页端打开文件夹,地址栏URL里就能看到fld开头的token,https://xxx.feishu.cn/drive/folder/fldxxxxx,这个fldxxxxx就是folder_token。第二种方式最直接,实际对接时建议优先用。
3.2 导入Markdown内容到云文档
飞书云文档原生支持Markdown导入,这个功能做知识库批量写入非常方便。接口是POST https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{block_id}/md,其中block_id传document_id表示从文档根部开始写入。
def import_markdown(token: str, document_id: str, md_content: str): url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks/{document_id}/md" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } body = {"md": md_content} resp = requests.post(url, headers=headers, json=body) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"导入markdown失败: {data}") return data这个接口对Markdown的支持比较完整:标题、列表、表格、代码块、引用、加粗、斜体、链接都能正确转换。我实测过把一份包含多层嵌套列表和表格的README.md直接导入,渲染效果和本地预览基本一致,分层结构、表格边框都很干净。
有个细节要注意:导入接口是追加写入,不是覆盖。同一个block_id下重复调用导入会把内容追加到原有内容后面。所以如果想做"重新生成文档"的效果,得先删掉文档里的所有子块再导入,或者直接创建一个新文档。我自己的方案是每次生成内容前先调一次删除子块接口,再导入,保证文档内容是最新的一份而不是反复追加。
3.3 读取文档纯文本内容的两种方式
读取文档内容,飞书提供了两个层面的接口:一个是读原始块结构的,一个是读纯文本的。做AI知识库的时候,绝大多数情况下要的是纯文本。
读取纯文本接口:GET https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/raw_content
def get_doc_raw_text(token: str, document_id: str) -> str: url = f"https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/raw_content" headers = {"Authorization": f"Bearer {token}"} resp = requests.get(url, headers=headers) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"读取文档失败: {data}") return data["data"]["content"]这个接口返回的是纯文本,所有格式信息(标题层级、加粗、颜色)都会被丢弃。如果你希望保留文档结构信息,让大模型理解文档的层级关系(比如把一级标题识别为章节),就需要用块接口GET https://open.feishu.cn/open-apis/docx/v1/documents/{document_id}/blocks逐层拉取块信息,把每个块的block_type和文本内容组装成带结构的Markdown。
我在实际项目中是这么处理的:先拉纯文本做全文索引,再拉块结构生成带标题层级的Markdown喂给大模型,两条数据链路各司其职。这样做的问题是文档大了之后块接口要分页拉取,需要处理page_token,稍微麻烦一点,但能换来更准确的结构语义,值得。
4. 表格写入与消息推送:把数据从机器人送到群聊
文档读写解决的是"内容存取",这一节解决的是"结果触达"。我把频率最高的两个组合场景展开讲:往电子表格批量写入数据,以及把表格内容通过机器人发到群聊里。
4.1 电子表格的sheet操作与单元格写入
飞书的电子表格API层级有点绕,我第一次对接时花了点时间理清:spreadsheet_token(表格token)→sheet_id(工作表ID)→ 单元格区域。拿到一个表格文件的token之后,要找到你要写入的那个工作表,这个sheet_id在表格网页端URL上可以看到,形如https://xxx.feishu.cn/sheets/{spreadsheet_token}?sheet={sheet_id},URL参数里那个就是。
写入单元格数据用PUT https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values:
def write_sheet_cells(token: str, spreadsheet_token: str, sheet_id: str, cells: list): url = f"https://open.feishu.cn/open-apis/sheets/v2/spreadsheets/{spreadsheet_token}/values" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } body = { "valueRange": { "range": f"{sheet_id}!A1:C3", "values": cells } } resp = requests.put(url, headers=headers, json=body) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"写入表格失败: {data}") return datarange的格式是{sheet_id}!A1:C3,这里V2版本的接口要求sheet_id前面不加单引号,V3版本加单引号,用V2接口别加引号。values是一个二维数组,每一行对应表格里的一行,写入时会整体覆盖目标区域。如果你只想改某几个单元格,range可以精确到{sheet_id}!B2这种,只写入指定格子。
批量写入大量数据时,飞书单个接口请求体有限制,一次写入最好不要超过500行或者几百KB。数据量再大就需要分批写入,没写成功的行要记录重试。我做过一个清洗数据的工具,把一万行数据写进表格,分20批每次500行,稳定跑完没有报错。
4.2 机器人发送富文本消息到群聊
表格数据整理完之后,经常要推一个摘要到群里,让团队直接看到结果。这就用到消息API:POST https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id,请求体里receive_id传群聊的chat_id,msg_type传interactive(卡片消息)或者text。
def send_group_message(token: str, chat_id: str, content: str, msg_type: str = "text"): url = "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id" headers = { "Authorization": f"Bearer {token}", "Content-Type": "application/json" } body = { "receive_id": chat_id, "msg_type": msg_type, "content": content } resp = requests.post(url, headers=headers, json=body) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"发送消息失败: {data}") return datacontent是一个JSON字符串,不是JSON对象,这里容易和json=body搞混。text消息的content格式是{"text":"你好"},interactive卡片消息的content格式是{"config":{"wide_screen_mode":true},"elements":[...]}。
如果想直接把表格以富文本形式发到群里,有一个更轻量的方案:用msg_type=text发文本摘要,然后在文本里带上表格链接,引导用户点开。如果一定要在消息里展示表格内容,可以用interactive卡片的column_set组件拼一个表格布局,但这个对数据量有要求,超过几行就不适合了,卡片会变得很挤。我的习惯是数据量大时发表格文件本身,数据量小时直接发卡片摘要,这其实是产品体验问题,技术上两条路都通。
4.3 AI知识库场景:如何把飞书文档喂给大模型
最近很多人在做AI大模型全栈知识库,把飞书云文档的存量内容结构化后作为RAG(检索增强生成)的数据源。这个场景下,飞书云文档API充当的角色是"数据管道入口",核心链路是:定时任务通过API拉取文档内容→清洗分块→向量化→写入向量数据库→大模型检索回答。
这个流程里API这块最关键的优化点是增量同步。飞书文档API的事件订阅机制可以监听文档变更事件,文档被编辑后推送事件消息,服务端收到事件后只重新拉取变更过的文档,代价是事件订阅需要配置回调地址,还要处理事件签名验证和消息去重。如果先跑通存量同步,再补增量,分阶段落地比较稳妥。
关于调用量规划,飞书文档相关API的频控限制通常在每秒几十次量级(具体以官方文档为准),做全量同步时要控制并发,单线程跑太慢,并发太高会被限流。我用的是线程池,并发数设在5左右配合重试机制,基本能跑满限流上限又不触发报错。
5. Dify等AI平台对接飞书的授权流程与常见报错排查
热搜词里反复出现"dify首次使用飞书云文档的授权凭证如何取得"和"api error: 400 invalid schema"这类问题,正好是我前面项目里实际处理过的,单独开一节写清楚。
5.1 授权凭证的获取路径
Dify接入飞书时,本质上走的是飞书开放平台的标准鉴权,需要的凭证就是app_id和app_secret。首次使用Dify的飞书工具(比如飞书文档读取节点),需要在飞书开放平台创建一个应用,拿到这两个值后填到Dify的配置界面里。之后Dify会用它来换取tenant_access_token,后续所有API调用都走这个token。
有一个容易混淆的点:Dify里有些工具会要求填access_token,有些要求填app_id和app_secret,这两个不是一回事。前者是飞书开放平台的访问令牌(先换token再填进来),后者是应用凭证(Dify自己会去换token)。用Dify内置飞书节点时,填app_id和app_secret就行,Dify底层帮你处理token获取和缓存。
如果Dify节点要求先获取一个授权链接做OAuth(获取user_access_token),那流程是:在飞书开放平台配置回调地址,用户访问授权链接并同意,飞书跳转回来时带一个code,用这个code换token。注意回调地址必须和开放平台配置的完全一致,端口、路径一个字符都不能差,否则会报"invalid code"之类的错误。
5.2 "Invalid schema for function artifact"类报错的根因
Dify对接飞书时报api error: 400 invalid schema for function 'artifact',这个错我在GitHub issue里也看到过多次,根因通常是Dify工作流里飞书节点的输入参数类型定义和实际传入的数据结构不一致。
以Dify的"飞书云文档-获取文档内容"节点为例,节点定义的工具函数期望入参是一个符合JSON Schema的对象,比如{"document_id": "doxcnxxx"}。如果你在前置节点里输出的结构不对——比如document_id被包在了另一个字段下面,或者是一个数组而不是字符串——Dify调用工具时就会报schema校验失败。
排查链路是这样的:
- 先点开工作流里飞书节点的输入预览,看实际传给工具的JSON长什么样;
- 对比工具函数的期望Schema,逐个字段检查类型;
- 如果前置节点输出的是一个复杂对象,用"代码节点"或"模板转换节点"把数据整理成工具期望的扁平结构;
- 修改后重新运行工作流,错误应该消失。
如果报错的函数名是artifact而不是具体的get_document之类,说明问题出在Dify内部对工具返回值的包装层,通常和知识库检索结果的格式有关。这个场景下检查一下知识库的检索设置,确认返回的片段(chunk)数据不是空数组或缺失content字段。空内容进不去工具函数,就会在artifact封装层炸掉。
5.3 权限范围不足的排查链路
权限范围不足是另一个高频问题,报错信息通常是99991672(无权限访问该资源)或者Permission denied。这个错的排查链路我建议按顺序走:
- 确认应用是否发布了新版本。改权限后没发版本是最常见的原因。
- 确认权限点是否覆盖了所调用的接口。飞书文档API有的接口要求多个权限点同时具备,少了任何一个都会报权限错误。比如读取云文档纯文本,既要有
docx:document:readonly,在有的一线场景下还要求drive:drive:readonly。 - 确认资源本身是否对应用可见。应用token访问用户文档,要求该用户所在租户安装了你的应用,且用户至少对文档有阅读权限。文档如果是私密的,即使应用权限配好了也访问不了。
- 如果是user_access_token,还要确认用户在授权时是否勾选了对应权限。
按这个顺序排查,90%的权限问题都能定位。我在实际项目中遇到的最隐蔽情况是:一个文档从外部共享进来,源租户没有安装应用,导致应用token读不了。这种情况要么让文档所有者把文档复制到本租户,要么通过用户身份授权来解决。
6. 关于限流、分页和错误码的几个实操提醒
飞书API整体设计比较规整,但有几个细节在对接高峰期特别容易踩,单独列出来提醒一下。
限流方面,飞书开放平台对每个应用每个接口都有QPS限制,超过限制返回99991664,错误信息是"请求过于频繁"。我看到不少人在做数据迁移脚本时一上来就开20个并发线程拉文档,跑不了几秒就全被限流。我的经验是从单线程开始,逐步把并发数往上加,直到出现限流报错再往回退一档。不同接口的限流阈值不一样,文档写入类比读取类严格得多,写入并发建议从1开始,稳定了再往上抬。
分页方面,凡是从接口返回列表的场景,比如文档列表、块列表、消息列表,都可能涉及分页。飞书的通用分页参数有两种:page_token游标分页和page_size数量控制。游标分页的规则是:第一次请求不传page_token,返回的data里带has_more和page_token,如果has_more为true,用返回的page_token作为下一次请求的参数继续拉。代码模板如下:
def fetch_all_items(token: str, base_url: str, params: dict) -> list: items = [] page_token = None while True: if page_token: params["page_token"] = page_token resp = requests.get(base_url, headers={"Authorization": f"Bearer {token}"}, params=params) data = resp.json() if data.get("code") != 0: raise RuntimeError(f"拉取列表失败: {data}") items.extend(data["data"]["items"]) if not data["data"].get("has_more"): break page_token = data["data"]["page_token"] return items这个模板可以复用在一系列列表型接口上,改一下URL和items的字段名就能用。
错误码方面,有几个高频值值得记牢:99991663是token不存在或已过期,99991664是请求过于频繁,99991672是权限不足,99991400是参数错误。参数错误时返回信息一般会带具体字段,照着改就行。如果看到230002开头的错误码,通常是文档操作相关,比如文档不存在或者没有编辑权限。
还有个容易忽略的点:飞书接口的Content-Type设置。凡是带JSON请求体的接口,Content-Type必须是application/json; charset=utf-8,有些SDK或者HTTP客户端默认不带charset,在某些网关环境下会导致中文内容乱码或者请求被拒。请求头统一加上charset=utf-8能避免很多莫名其妙的问题。
回调事件方面,如果用了事件订阅(比如文档变更监听),飞书会往回调地址POST事件数据,请求头带X-Lark-Signature、X-Lark-Request-Timestamp、X-Lark-Request-Nonce三个字段,需要在回调里用app_secret做HMAC-SHA256验签,防止伪造请求。验签逻辑不复杂,但漏了会导致安全性问题或者事件重复处理的脏数据,对接时记得加上。
最后分享一个方法论层面的心得:飞书云文档API的对接,本质上是在"文档即数据结构"这套模型上做读写。正式写代码前,先拿API Explorer(飞书开放平台的在线调试工具)把每个接口的入参出参跑一遍,确认返回字段和预期一致,再落到代码里,能省掉一大半调试时间。特别是那些嵌套层级比较深的接口(块结构、单元格样式),直接在API Explorer里看真实返回的JSON,比看文档猜字段结构高效得多。我每次对接新接口都是这个流程:Explorer调到通→写最小可运行代码→再补异常处理和重试逻辑,踩坑率低很多。