在n8n里把飞书日历接进自动化流程,第一回跑就撞上HTTP 400,响应体给了code:99991679和一句Unauthorized.,这事我印象太深了。当时第一反应是某个参数格式不对,毕竟400嘛,可把请求体翻了个遍也没看出毛病。后来弄明白,这个错误码跟参数关系不大,它真正想说的是:你调用的这个接口,不在当前令牌的权限范围内。
这篇文章就把这套排查过程完整写下来——从错误码含义、飞书token机制,到n8n里该配什么凭证、后台该怎么开通权限,再到用curl做最小化复现,一步步定位。不管你是刚开始接触n8n和飞书API,还是在已有流水线里撞上这个报错,按这个顺序查基本都能解决。
1. 先看报错本身:HTTP 400是表象,99991679才是关键
先还原一下报错现场。在n8n的HTTP Request节点里请求飞书日历接口,执行结果直接进了ERROR,点开错误详情看到状态码400,响应体大致长这样:
{ "code": 99991679, "msg": "Unauthorized.", "data": null, "request_id": "73f0a8b1-xxxx-xxxx-xxxx" }很多人看到400的第一反应就是请求参数、JSON格式、content-type不对,这个方向不能说是错的,但在飞书的错误码体系里,HTTP状态码只是一个粗糙的分组,真正要读的是业务code。99991679在飞书的错误码表里对应的就是Unauthorized,触发条件基本集中在权限层:当前访问令牌所拥有的scope不包含你要调用的这个URI。
所以碰到这个响应,第一步绝对不是去改参数,而是把三样东西抄下来:
- 请求的完整接口URL(尤其注意是哪个calendar_id)
- Authorization头里的token字符串长什么样(是t-开头的tenant token还是user token)
- 响应里的request_id(后面真要找平台支持排查时,这个最有用)
1.1 为什么HTTP状态码是400而不是401
正常REST接口里,token没带或者token无效通常返回401,Unauthorized这个文案也经常跟401绑定。但飞书在这一点上做得很"统一":大量业务型错误都用400承载,再靠code细分。我观察下来,这种设计对调用方的好处是错误码体系很清晰,坏处就是像我当初那样,被400误导去排查参数。
记住一个判断原则:如果响应体里出现code字段,就不要再盯着HTTP状态码猜原因,code才是业务层的答案。99991679这种以9999开头的大段位错误码,基本都指向权限体系,跟参数格式化没太大关系。继续在时间戳格式、日期字符串上折腾,只会浪费时间。
1.2 参数型400和权限型99991679怎么区分
为了后面排查不跑偏,这里直接列一个区分表:
| 特征 | 普通参数错误 | 99991679 权限错误 |
|---|---|---|
| msg内容 | invalid param / 参数错误 | Unauthorized. |
| 触发位置 | 请求体字段、时间格式等 | 令牌授权范围 |
| 修改请求参数能否解决 | 通常能 | 不能 |
| 正确的动作 | 修JSON结构 | 换token或加scope |
拿日程创建来举例:如果把start_time写成了"2025-01-01"而不是时间戳格式,返回的msg会直接指出字段问题,不会轻飘飘来一句Unauthorized.。所以看到Unauthorized.,排查方向就该往"授权"两个字上走。
2. 飞书日历接口的令牌体系:token类型不对是第一个雷区
飞书API里的token分两大类,很多人第一次接触时会混在一起,我先把区别放出来。
2.1 tenant_access_token和user_access_token的区别
| 对比项 | tenant_access_token | user_access_token |
|---|---|---|
| 代表身份 | 应用本身 | 具体用户 |
| 获取方式 | app_id + app_secret 直接换 | 走OAuth授权码流程 |
| 有效期 | 约2小时 | 约2小时,可配合refresh_token刷新 |
| 典型用途 | 应用维度数据 | 访问个人维度数据 |
| 日历接口适用性 | 多数不适用 | 日历相关接口主要认这个 |
日历是典型的用户维度资源:你的日程、你的主日历、别人共享给你的日历,都挂在具体用户身份下面。飞书日历接口在权限要求上,标注的往往也是"需要用户授权"类型的权限。换句话说,你在n8n里如果图省事,直接用app_secret换来的tenant_access_token填进Authorization头,跑去查某个用户的日历,撞上99991679是大概率事件。
2.2 scope是什么,它和token是什么关系
scope可以理解成"令牌随身携带的权限标签"。用户点授权时,应用申请了哪些权限,授权页就会展示哪些;用户同意后,飞书签发的user_access_token里就只包含这些scope。之后你拿这个token调接口,飞书会先检查token里的scope是否覆盖当前URI。
打个比方:token是门禁卡,scope就是这张卡上开通的门禁区域。99991679这句话的真正含义是——门禁系统发现你手里这张卡没有这个门的权限,于是把你拦在门外。
所以排查一定要记住:token类型对不对,和scope全不全,是两件事,但都会导致同一个错误码。这也是为什么很多人在n8n里来回试token、刷新、重新认证,折腾半天还报一样的错——因为问题可能根本不在token本身,而在应用后台的权限配置。
2.3 scope和token在OAuth里是怎么组装在一起的
OAuth授权URL里会带一个scope参数,n8n的OAuth2凭证里也有一个Scope输入框。浏览器打开授权页后,飞书根据这个scope参数展示权限列表,用户同意后飞书返回的code去换token,换出来的token就带上了这些scope。
这里有一个很阴的细节:如果你先把应用接好了、用户也授权过了,之后才去开发者后台追加日历权限,那么原来已经签发的token不会自动带上新scope。也就是说,权限改了,旧的user_access_token仍然缺日历权限,调用还是99991679。要解决只能重新走一遍OAuth授权,让飞书签发一颗带新scope的token。
3. 从n8n配置出发的排查链路:三个最隐蔽的坑
这一章专门讲n8n侧。我的经验是,n8n本身很少有问题,出问题的大多在凭证类型、授权顺序和token续期方式上。
3.1 用OAuth2凭证,还是手填Bearer
如果你用的是现成的飞书节点,直接配置它自带的凭证类型即可;如果和我一样用HTTP Request节点自己封装,强烈建议选OAuth2方式的凭证,不要图省事在Header里写死一个Bearer。
n8n上OAuth2凭证的关键配置项可以对应到飞书的参数:
- Authorization URL填写飞书的授权端点,类似 https://open.feishu.cn/open-apis/authen/v1/authorize
- Access Token URL填写换token的端点,类似 https://open.feishu.cn/open-apis/authen/v1/access_token
- Scope填入日历相关权限标识(以开发者后台权限列表里的编号为准,常见的是带冒号的字符串)
- Client ID和Client Secret分别填应用的app_id和app_secret
这里最容易忽略的是重定向URL。n8n会生成一个回调地址,你需要把这个地址完整配置到飞书开发者后台的"重定向URL"里,配置不一致的话授权流程根本走不完,那又是另一个报错了。配置完成后,在n8n里点一下连接授权,浏览器会跳到飞书授权页,这次授权同意之后,n8n才能拿到合法的user_access_token。
3.2 授权顺序比想象中更关键
我踩过的一个典型顺序坑:先在n8n里连接了飞书OAuth,把工作流跑通了,后来发现日历权限没加全,跑到开发者后台把scope补上、发布版本,满以为万事大吉,结果n8n再跑还是99991679。
原因就是上一章说的,旧token不自动继承新scope。正确顺序是先确认开发者后台的权限全部开通并发布生效,然后再去n8n里做OAuth连接。如果已经连过了,就断开重连,强制执行一次新的授权。别嫌麻烦,这一步能省下后面大量的排错时间。
3.3 token过期和"写死Bearer"的隐患
飞书的访问令牌有效期基本是2小时左右。如果你在n8n里手动把token填进Header,那这个工作流就是个定时炸弹:刚配置好能用,两小时后就因为token过期报错。而且过期token调用接口同样可能返回99991679(也有些场景返回其他token失效类错误码),很容易跟scope问题混淆。
推荐的做法是让token始终动态获取。一种办法是直接用n8n的OAuth2凭证,它内部会处理刷新逻辑;另一种办法是把工作流拆成两步:第一步发请求换取或刷新token,第二步把返回的token用表达式拼到后面的飞书日历请求里。这样起码token不会过期到不可用。
4. 飞书开放平台后台检查:权限列表、版本发布与用户授权状态
如果n8n凭证配置看起来没问题,那就该去飞书开发者后台核对应用侧的权限状态了。很多99991679最终都定位在这一层。
4.1 在权限管理里把日历相关权限补全
打开开发者后台 → 应用 → 权限管理,搜索"日历""日程""calendar"等关键字,把看到的只读和读写类日历/日程权限都勾上。注意飞书的权限标识经常升级换代,老的在个别场景下可能保留、新的另行命名,所以不要凭记忆勾,直接在权限中心搜一遍最稳妥。
勾选完成后,需要确认权限状态从"可用"变成"已开通"。如果只是能勾选但没进入开通状态,说明还没走完发布流程,后面第4.2节说的就是这个。
4.2 权限变更后必须重新发布版本
这是重灾区中的重灾区。飞书的应用权限配置不是改完立即生效的,它需要走一次"创建版本 → 提交发布 → 管理员审核"的流程。企业自建应用通常由管理员直接审核,审核通过后,新配置的权限才会被真正打进应用的可信配置里。
我见过不少情况:开发者后台的开关全都亮了,但版本还是旧版本,于是线上调用一直99991679。检查方法很简单——在后台的"版本管理与发布"页面看最新版本是否已经"已发布",如果不是,创建一个新版本重新发布。有些时候你以为自己发布过了,其实只保存了草稿,这种现象很常见。
4.3 用户的授权状态和可用范围
如果应用已发布、权限也开了,还是报99991679,再看两个东西:
- 应用的"可用范围"是否包含你要操作的用户。如果用户不在可用范围里,授权流程可能能走通,但签发的token根本无法访问日历数据。
- 这个用户本人是否完成了OAuth授权。日历是用户维度数据,每个人都需要单独授权。有些人是在权限配置完成之前授权的,需要重新走一遍授权。
我一直建议在n8n的OAuth凭证上做一次"重新连接/重新认证"操作,让用户的授权状态强制刷新,这一步能解决掉不少权限变更后遗留的问题。
5. 用curl做最小化复现:把问题从n8n里剥离出来
当n8n和后台检查都做完还报错,下一步就是绕过n8n,直接在命令行用curl复现一次。这个环节价值在于:它能把"n8n配置问题"和"飞书应用问题"彻底分离开。curl通了,问题肯定在n8n侧;curl也报错,那问题在飞书侧,n8n只是背锅。
5.1 先验证app_id和app_secret本身没问题
用下面的请求换取一个tenant_access_token,注意这里用的是internal端点,自建应用走这个:
curl -X POST 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' \ -H 'Content-Type: application/json' \ -d '{"app_id":"cli_你的应用ID","app_secret":"你的应用密钥"}'正常响应里会有tenant_access_token和过期时间expire:
{ "code": 0, "msg": "ok", "tenant_access_token": "t-xxxxxxxx", "expire": 7200 }如果这一步就报错,说明app_id/app_secret本身有问题,跟日历无关,先去后台换密钥再排查。
5.2 用tenant token直接调日历接口看看反应
curl -X GET 'https://open.feishu.cn/open-apis/calendar/v4/calendars?page_size=20' \ -H 'Authorization: Bearer t-xxxxxxxx'这里返回99991679一点都不奇怪,因为tenant token默认碰不了用户日历。这一步的意义是确认错误码的复现路径:如果连"获取日历列表"都报99991679,说明当前token的scope里完全没有日历权限,或者token类型本身不对路。
5.3 走一遍用户授权,用user_access_token再测
如果确认接口需要用户身份,就不要再拿tenant token硬试了。构造一个授权URL,让用户在浏览器里点一次:
https://open.feishu.cn/open-apis/authen/v1/authorize?app_id=cli_你的应用ID&redirect_uri=你的回调地址&scope=calendar:calendar.event&state=任意字符串用户同意后,浏览器会跳转到redirect_uri,地址栏里会出现code参数。拿到code再换user_access_token:
curl -X POST 'https://open.feishu.cn/open-apis/authen/v1/access_token' \ -H 'Content-Type: application/json' \ -d '{"app_id":"cli_你的应用ID","app_secret":"你的应用密钥","grant_type":"authorization_code","code":"回调地址里的code参数"}'然后用换回来的user_access_token重新调日历列表:
curl -X GET 'https://open.feishu.cn/open-apis/calendar/v4/calendars?page_size=20' \ -H 'Authorization: Bearer u-xxxxxxxx'5.4 基于curl结果做判定
| 测试动作 | 返回情况 | 结论 |
|---|---|---|
| tenant token 调日历列表 | 99991679 | 意料之中,别继续;需要user token |
| user token 调日历列表 | code为0 | 飞书侧没问题,回n8n查凭证和授权 |
| user token 调日历列表 | 还是99991679 | 问题在scope或版本发布,回后台检查 |
| 任意token请求 | 其他code | 按对应错误码另查,与本文场景不同 |
这一套测完,问题基本能定位到具体层级。我自己做的时候习惯把每一步的响应体完整存下来,尤其是request_id,因为如果真的得找平台支持排查,request_id是必不可少的线索。
6. 解决之后:验证完整流程与后续工作流设计建议
错误码最终消失只是第一步。我建议顺便把下面这几件事也做了,免得同一个坑换个姿势再踩一遍。
6.1 正常返回时的响应长什么样
日历日程接口正常返回时,code字段是0,msg是success。比如获取日程列表时能看到日程的summary、start_time等字段:
{ "code": 0, "msg": "success", "data": { "has_more": false, "items": [ { "event_id": "event_xxxx", "summary": "项目周会", "start_time": { "timestamp": "1710000000", "timezone": "Asia/Shanghai" } } ] } }具体字段以飞书当前版本文档为准,但code=0这个形态是一致的。如果你的响应里不是这个形态,大概率还没走到正轨。
6.2 calendar_id不要手猜,用列表接口取
再提醒一个和权限耦合的坑:URL里的calendar_id。有些人在浏览器里看到日历ID字符串就直接抄过来用,结果因为那个日历根本不是当前token可访问的,出现权限类报错。正确做法是先调"获取日历列表"接口,从返回的items里拿到当前用户真正可见的calendar_id,再拿它去查日程。如果业务上要操作别人共享的日历,还得确认对方确实给了权限。
6.3 工作流里给99991679做个分支处理
n8n里建议在飞书请求节点后面加一个IF或者Switch节点,判断响应里的code。如果code等于99991679,不要假装重试,直接发一条提醒——大概率是权限变更或token需要重新授权,人工介入处理比自动重试几百次更快。
我自己的习惯是把授权、刷新token、取日历列表、取日程做成独立的几个节点链,任何一个环节挂了,错误信息里都能明确看到是哪一步。把"获取token"和"实际业务请求"耦合在同一个节点里,排查时非常痛苦。
6.4 一个关于curl验证的补充技巧
如果你在n8n里看到其他9999段的错误码,不要照搬本文的排查流程——先查飞书官方错误码表,把code的含义翻译出来再动手。盲目冲去配置里翻权限,有时候会浪费一个下午。99991679这个码的特征足够鲜明,只要你确认了它,那权限方向就是你唯一要去认真盘查的。
按我踩坑的经验来说,99991679这个错,十次里有七八次是scope没配上或者版本没发布,剩下两三次是token类型不对,真跟"参数格式"关系不大。排查顺序理顺以后,五分钟就能定位:先确认token类型,再核对scope,最后用curl绕过n8n复现一次。至于n8n那边,还是那句老话:能自动刷新的OAuth2凭证,就别自己手写Bearer。
最后再分享一个小技巧:每次在n8n里跑飞书相关节点时,我会在错误信息里把request_id摘出来记到日志里。等哪天连续报错需要对照分析时,这些request_id能帮你快速判断是应用整体权限问题,还是某个用户token偶发问题。省下的时间,都够多配两条工作日历了。