先说结论

某天开始,我的飞书机器人消息照收不误,但一条回复都发不出去。

根因不在我的代码、不在网络、不在凭证,而是飞书侧的一条未公开的访问控制策略:

用「豆包账号」身份登录的飞书账号,其名下自建应用只要配置了任何 OpenAPI 权限,在获取访问凭证(tenant_access_token)时就会被策略拒绝,返回未收录于官方文档的错误码 21001;而未配置任何权限的应用反而一切正常。换用手机号/邮箱注册的飞书账号建同权限的应用后,立即恢复。

这不是推测,是可以 100% 复现的实测结果(文末给了可复制的验证步骤)。


一、故障现象:一个反直觉的「半瘫」

通道 状态
入站(飞书 → 我的机器人,WebSocket 长连接) ✅ 完全正常,消息收得到,甚至能正常打「正在输入」表情
出站(我的机器人 → 飞书,HTTP OpenAPI) ❌ 全线失败:发消息、加表情、写云文档,一条不落

出站返回的是:

1
2
HTTP 200
{"code":21001,"msg":"Access denied by access control policy. Contact the administrator for assistance."}

第一个关键信息就藏在这里:HTTP 状态码是 200。

不是超时、不是 5xx、不是连不上——请求稳稳到达了飞书并被正常处理,是飞书在业务层明确拒绝。这一条就把「网络问题」「防火墙」「DNS」全部排除掉了。

第二个关键信息:入站正常、出站失败。这个不对称非常有价值——它说明不是整个连接死了,而是某个只在 HTTP API 侧生效的检查没通过。


二、排查路径

第 1 步:先分清入站和出站

看网关日志:接收侧的长连接一直是稳的,发送侧每一条都在报同一个错误。故障定位于「出站 / 鉴权层」。

第 2 步:凭证对照实验(一分钟把「凭证错」排掉)

拿正确凭证和故意写错的凭证各打一次取 token 的接口:

使用的凭证 飞书返回 含义
正确的 app_secret 21001 访问控制策略拒绝 凭证校验通过了,被后面的策略拦下
故意写错的 app_secret 10014 app secret invalid 凭证无效

结论很干净:飞书是先校验凭证、再走策略的。既然正确凭证没报「凭证无效」而是报「策略拒绝」,说明凭证是对的,卡点在策略层。

(这也说明:任何「重置 App Secret」的操作对本故障都是无效的——我确实也试过,白费一轮。)

第 3 步:21001 到底是什么?

这是整个排查最难受的部分——官方查不到:

  • 飞书官方的《通用错误码》列表里没有 21001;
  • 官方站内搜索 21001,国内站和国际站都是 0 结果;
  • 客服渠道问了一圈,没有有效回复。

唯一的官方线索来自飞书官方 CLI 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
const (
LarkErrBlockByPolicy = 21001 // access denied by access control policy
LarkErrBlockByPolicyTryAuth = 21000 // ……challenge is required to be completed by user
)

// SecurityPolicyError is returned when a request is blocked by access control policies.
type SecurityPolicyError struct {
Code int
Message string
ChallengeURL string
CLIHint string
Err error
}

也就是说:这个码属于「安全策略类」,不是凭证错误、不是权限不足、不是频率限制。而且官方还在这条错误上预留了一个「验证/申诉入口」(ChallengeURL)字段——只是从没写过它什么时候会被触发。

第 4 步:★ 我在这里踩了一个大坑(把结论带偏了一天)

我当时的设计是:在同一个账号里新建一个「什么都没有」的裸应用,和出故障的原应用做对照。

结果非常好,好到让我错了:

应用 配置 结果
原应用 配了消息相关权限 ❌ 21001
新建的裸应用 什么都没有:无权限、无机器人、未发布版本 ✅ code 0,正常签发凭证

据此我得出了结论:「这个账号没问题,是我这个应用被单独限制了」——然后沿着「应用被限制」的方向查了一整天,毫无进展。

第二天我才意识到问题出在哪:那个「什么都没有」的裸应用,恰好绕过了这条策略。

教训:对照组选错,得到的结论比没有结论更危险。
这里的变量其实有两个——「账号身份」和「应用是否持有权限」。用空权限的应用去做对照,等于同时把这两个变量都改了,看起来找到了差异,实际是把真正的变量藏了起来。

第 5 步:二维对照实验,定案

重新设计对照:账号 × 应用权限,两个维度都取到:

带权限的应用 空权限的应用
账号 A(豆包账号身份登录) ❌ 21001 ✅ code 0
账号 B(手机号/邮箱注册) ✅ code 0 ✅ code 0

只有「账号 A + 带权限」这一格是红的。

把它拆成可验证的断言:

  • 同一个账号下,空权限应用通过、带权限应用被拒 → 不是「账号被封」,是权限维度的策略评估;
  • 同一个权限配置下,账号 A 被拒、账号 B 通过 → 不是「应用有问题」,是账号身份维度;
  • 两者在同一时刻、同一台机器、同一出口 IP、同一客户端上测的 → 源 IP、网络、客户端全部无关。

我随后又独立跑了两轮复核:8 次调用 8 次一致,零抖动。


三、可复制的验证步骤(约 2 分钟)

如果你怀疑自己遇到了同一条策略,可以这样验证。准备两个应用:一个随便加两个权限,一个什么都不加,然后用同一个账号的凭证分别请求:

1
2
3
curl -s -X POST 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' \
-H 'Content-Type: application/json' \
-d '{"app_id":"{你的_app_id}","app_secret":"{你的_app_secret}"}'

预期结果(如果你也在被拦):

应用 预期返回
带权限的应用 {"code":21001,"msg":"Access denied by access control policy..."}
空权限的应用 {"code":0,"msg":"ok","tenant_access_token":"..."}
带权限的应用 + 故意写错的 secret {"code":10014,"msg":"app secret invalid"}(证明应用被平台正常识别)

第三行是关键的对照组:如果错误凭证能拿到「凭证无效」而正确凭证拿到「策略拒绝」,那就说明你的凭证没问题,问题在策略层。

排查时注意把飞书返回的 x-tt-logid(请求追踪 ID)留存下来——它是唯一能向官方定位这次调用的凭据。


四、能查到的和查不到的

官方侧存在、但不点名的信号:

  • 飞书开放平台 changelog,2026-07 下旬:「Agent 访问官方接口时增加账号保护:保护策略默认开启,只对飞书或 Lark 官方接口生效」
  • 飞书开放平台 changelog,2026-10 上旬:「登录、获取凭证和身份验证遇到安全策略拦截时,保留服务端返回的提示、验证入口和规则信息」

查不到的:

  • 21001 的官方定义、触发条件、解除方式
  • 开发者后台里没有任何提示:没有站内信、没有邮件、没有横幅、没有违规通知
  • 「豆包账号登录飞书后……此类账号没有企业管理后台」——这意味着这类身份连「去哪改设置、去哪申诉」的入口都不存在

同期社区里有其他人中招: 2026-10-13 前后,飞书官方仓库的两个 issue(larksuite/cli#2815、larksuite/openclaw-lark#639)以及若干技术论坛帖,记录的是同一个现象:入站长连接正常、出站全部 21001、无效凭证返回的却是另一个错误码。其中一条 issue 的标题直接点名了账号类型,评论里也有多位「豆包账号」用户报告同样的失败。

需要说清楚的一点: 以上是我能实测到的行为,不是官方说法。飞书至今没有就这条策略发布任何公告,本文不对其动机做任何推断——我只陈述可复现的事实。


五、如果你也遇到了:判别手册

第一步:把入站和出站切开。
长连接正常 + HTTP 全挂 → 问题在出站/鉴权层,不是网络。

第二步:看错误码定性。

错误码 含义 方向
21001 / 21000 访问控制策略拒绝(官方文档未收录) 策略层
10014 / 10015 / 10003 凭证无效 / secret 错误 / 参数非法 凭证层
99991401 IP 白名单拒绝(且官方明确它不作用于 token 类接口) 网络策略
99991403 / 429 用量配额或频率限制 用量
99991672 缺少某个权限点 权限申请

第三步:做凭证对照实验。(见上文,一分钟出结论)

第四步:做二维对照实验——这是最关键的一步。

1
2
3
             带权限的应用   空权限的应用
账号 A ? ?
账号 B ? ?
  • 只有换账号才好 → 账号身份维度
  • 只有空权限应用能通 → 权限维度
  • 千万别只用空权限应用做对照,它会给你一个看起来完美、实则错误的答案。

第五步:留存证据找官方。 记下 app_id、tenant_key、完整响应体、x-tt-logid。21001 未文档化,一线客服大概率无解,需要真正到 oncall 才能定位。


六、三条教训

  1. HTTP 200 + 业务错误码 ≠ 你的代码有问题。 当状态码成功而业务码异常时,要立刻把怀疑方向从「我的实现」转向「中间的策略层」。
  2. 对照实验的对照组选错,比没有对照更危险。 我因为一个「空权限的裸应用」,得出「只有我的应用被限制」的结论,白查了一整天。控制变量必须同时锁住你怀疑的每一个维度,哪怕这意味着多建两个应用。
  3. 平台静默变更 + 未文档化错误码,意味着你只能靠可复现实验自救。 这类故障里,社区里别人的复现记录(issue、论坛帖)往往比官方文档更有用——因为文档里根本没有这个东西。

顺便说一句:这次故障从发生到被发现静默了 17 小时——期间机器人一切看起来都在正常「工作」,只是所有回复都发不出去。如果这类链路对你重要,值得加一条主动健康检查,而不是等用户告诉你「你怎么不回我」。


后记

我已经把应用迁到手机号/邮箱注册的飞书账号上,收发恢复正常,并把整套判别流程记进了自己的运维笔记。

写这篇的目的很简单:这条策略是静默生效的,官方既没有公告也没有文档,遇到的人大概率会先怀疑自己的代码、网络或凭证,然后在「重置密钥」这种无效操作上耗掉大半天。如果这篇能让你在 10 分钟内定位到「是策略层」,那就值了。