
本文聚焦飞书三层接入(机器人Webhook/自建应用卡片/长连接事件订阅)+钉钉横向对比。
0. 开篇:为什么从飞书开始
CSDN上"飞书对接"的实战文不少,但"飞书+长连接事件订阅+MCP双向闭环"的完整跑通几乎空白。飞书开放平台对MCP最友好:个人手机号就能建团队,本文所有实测都不需要企业资质、不需要公网服务器,一条WebSocket长连接把"群里@机器人→调@Tool→回卡片消息"打通,按open_id做多租户数据隔离,全程走飞书事件订阅。
先看一段真实得不能再真实的对话:
[08:30] 天河城店店长:@MCP助手查一下我们店今天的销售
[08:30] MCP助手:🎫 门店助手 · 查询成功
**指令**:销售
天河城店 今日销售12,480元,客流431
[08:31] 天河城店店长:库存呢?SKU-1001还剩多少
[08:31] MCP助手:🎫 门店助手 · 查询成功
**指令**:库存
SKU-1001库存23(安全线50)
[08:31] 天河城店店长:补货建议?
[08:31] MCP助手:SKU-1001库存23 → 建议补货至安全线的1.2倍
这不是演示,而是零售/供应链企业里"企业系统对接"最常见的一类刚需:把企业已有的业务系统(销售、库存、设备、补货)通过MCP Server开放出来,再用企业IM(飞书)做入口,让非技术角色(店长、区域经理)用一句话就能查询和决策。
先消掉三个最常见门槛(这是读者放弃前的最大拦路虎):
- 需要企业资质吗? 不需要。个人手机号注册即可建团队,创建"企业自建应用"。
- 要实名/要钱吗? 飞书个人免费;长连接方案免内网穿透,连ngrok都省了。
- 多久能跑通? 第1层群机器人Webhook五分钟;第3层长连接半天内。
本文主线是"个人手机号+免公网即可完整跑通飞书深度接入"。场景对齐零售/供应链:店长飞书群@机器人 查本门店销售/库存/设备/补货。
1. 三层接入模型总览
一张决策树,三秒对号入座:
要告警推送?──────────────> 第1层Webhook(5分钟)
│否
要富卡片+用户身份/隔离?───> 第2层自建应用
│否
要群里@机器人双向交互?───> 第3层事件订阅
已有钉钉体系?────────────> 钉钉webhook(对比)
对应到零售场景的三种真实用法:
| 生产场景 | 用哪层 | 为什么 |
|---|---|---|
| 门店设备离线/低库存告警,推给店长群 | 第1层Webhook | 单向推送足够,最省事 |
| 总部下发行情卡、区域经理按身份看自己区域 | 第2层自建应用 | 富卡片+用户身份+数据隔离 |
店长在群里@机器人查自己门店 |
第3层事件订阅 | 双向交互,MCP工具当"后台" |

先澄清一个最容易混的点:第1/2/3层共用同一个自建应用,按发→收→双向交互逐层开启,并非三套独立凭证。但第1层的"群机器人"和第2/3层的"自建应用"是两套不同凭证体系------群机器人无App ID/Secret、纯出站;自建应用有完整凭证、可入站。本文第2/3层统一用自建应用,别把两套凭证搞混。
每层标题后的徽章:[本机跑通] / [设计+伪代码],一眼分清可照搬与仅思路
2. 第1层:飞书机器人Webhook(5分钟跑通)本机跑通
生产场景:门店空调、照明、风扇等设备离线,或某SKU库存跌破安全线,需要第一时间推给店长群。这是单向通知,不需要店长回复,所以第1层Webhook最合适。
群机器人适合门店告警/设备离线推店长群。创建群机器人拿到Webhook地址,加签后POST文本即可。飞书群机器人加签与钉钉同构(timestamp+"\n"+secret做HMAC-SHA256):
java
// 飞书群机器人加签(复用篇8思路,飞书版)
String stringToSign = timestamp + "\n" + secret;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
String sign = Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(UTF_8)));
String url = webhook + "×tamp=" + timestamp + "&sign=" + URLEncoder.encode(sign, "UTF-8");
边界很硬(原则第8条明边界):纯出站、无用户身份、无交互、无鉴权上下文。它只是"推",不是"接"。能做的只有"系统→群"单向广播,店长不能反问。如果业务需要"群@机器人查本门店",必须上第3层。
3. 第2层:飞书自建应用(卡片/权限/用户身份)本机跑通
生产场景:区域经理下发行情卡,店长按身份看自己门店的数据。这一层的关键不是"发卡片",而是自建应用才有入站能力+用户身份。
自建应用才有入站能力。先拿tenant_access_token(用App ID/Secret换),申请im:message权限;把@Tool查询结果渲染成飞书交互卡片推回去:
生产注意(多实例):
tenant_access_token要跨实例共享同一份,别各实例独立刷新------用 Redis 存 token + TTL 续期,并用 singleflight/分布式锁防并发刷新击穿(多实例同时到期会齐刷飞书 token 接口被限流)。
json
{
"config": { "wide_screen_mode": true },
"header": { "title": { "tag": "plain_text", "content": "门店助手·查询成功" }, "template": "green" },
"elements": [
{ "tag": "div", "text": { "tag": "lark_md", "content": "**指令**:查库存ST001\n\nSKU-1001库存23(安全线50)" } },
{ "tag": "action", "actions": [
{ "tag": "button", "text": { "tag": "plain_text", "content": "补货" }, "value": { "sku": "SKU-1001" } }
]}
]
}
版本说明:本文用飞书卡片1.0协议 (顶层
config+header+elements,text.tag=lark_md),兼容最广、实测跑通。新项目可升级2.0(schema:"2.0"+body.elements+text.tag=markdown),字段结构不同,迁移时注意。
用户身份与多租户数据隔离 (本文相对篇9的实质增量):篇9解决"怎么拿身份"(sender的open_id),本篇解决"身份映射到门店"。闭环是事件sender.sender_id.open_id→通讯录contact API解析user_id/部门→绑定门店→工具层按门店过滤(get_store_sales(storeCode=本门店))。
身份闭环流程:
事件.sender.sender_id.open_id
│ (飞书平台鉴权,身份可信)
▼
通讯录contact API:open_id → user_id → 部门 → 门店编码
│ (生产:真实绑定;本机demo:open_id→门店 映射表)
▼
工具层按门店过滤:get_store_sales(storeCode=本门店)
在真实工程里,我把它做进了FeishuLongConnConfig的resolveStoreCode:
java
private String resolveStoreCode(String openId, String text) {
Matcher m = STORE.matcher(text);
if (m.find()) return m.group().toUpperCase(); // 用户直接说了门店编码
String bound = OPENID_STORE.get(openId); // 否则走 open_id→门店绑定
return bound; // null → 工具层返回"需要补充门店编码"
}
这里必须讲清生产和demo的差别 :真实代码里OPENID_STORE目前是Map.of()空映射,因为生产来源应是飞书通讯录contact API(open_id→user_id→部门/门店),而个人免费版能否开通contact权限仍需在散点文03单独验证。所以本文本机实测用open_id→门店demo映射表跑通隔离机制,生产要换成通讯录API的真实绑定------这是"机制演示"和"生产级真实绑定"的区别,别混。数据只按门店维度切片,但隔离策略本身已经落地:即使店长乱敲storeCode=ST002,工具层也只认他绑定的门店。
出站生产注意(技术4.4):飞书消息有频控+失败重试语义,生产要加退避重试,别裸发:
java
// 退避重试(飞书频控 + 瞬时失败):指数退避 + 随机抖动,避免多实例同时重试把飞书打得更限流
int attempt = 0;
while (attempt < 3) {
try { apiClient.im().message().send(req); break; }
catch (RateLimitException e) {
long base = (1L << attempt) * 500; // 0.5s / 1s / 2s
long jitter = ThreadLocalRandom.current().nextLong(0, 500); // 随机抖动,错开重试
Thread.sleep(base + jitter); attempt++;
}
}
4. 第3层:事件订阅双向(@机器人触发MCP调用)★重点,已实测本机跑通
生产场景 :店长在飞书群里@MCP助手查销售/库存/设备/补货,机器人收到消息、调用MCP工具、把结果渲染成卡片回复。这是"人→系统→人"的双向闭环,也是企业系统对接真正值钱的部分------把MCP Server变成群里的"智能助理"。

选型上,长连接(WebSocket)优于公网回调 :免公网入站端口、免内网穿透;本机只需出站连wss://msg-frontier.feishu.cn(官方强调服务器需能访问公网)。长连接是飞书官方推荐路线,把接入成本降到最低。兜底"公网回调"仅在长连接被质疑/受限时作退路(清单P2原方案,已验证可达ngrok),不写成并列第二方案。
收事件im.message.receive_v1(单聊+群@),鉴权App ID/Secret→access_token→建连。SDK坐标定案 :com.larksuite.oapi:oapi-sdk:2.3.4(注意不是官方文档写的com.lark.oapi:lark-oapi-sdk,长连接客户端类路径不同)。
闭环核心代码(节选自com.ethanliang.mcp.feishu.FeishuLongConnConfig,完整见仓库11-feishu):
java
// 主API客户端:全限定名!避免与ws.Client同名冲突(踩坑1)
com.lark.oapi.Client apiClient =
com.lark.oapi.Client.newBuilder(appId, appSecret).build();
EventDispatcher dispatcher = EventDispatcher.newBuilder(appId, appSecret)
.onP2MessageReceiveV1(new ImService.P2MessageReceiveV1Handler() {
public void handle(P2MessageReceiveV1 event) {
String openId = event.getEvent().getSender().getSenderId().getOpenId();
String text = parseText(event.getEvent().getMessage().getContent()); // 剥@提及
String storeCode = resolveStoreCode(openId, text); // 多租户:open_id→门店
StoreResult r = dispatch(text, storeCode); // 路由到门店@Tool
String card = buildCard(text, r); // 渲染交互卡片
apiClient.im().message().reply(ReplyMessageReq.newBuilder()
.messageId(event.getEvent().getMessage().getMessageId())
.replyMessageReqBody(ReplyMessageReqBody.newBuilder()
.msgType("interactive").content(card).build()).build());
}
}).build();
Client wsClient = new Client.Builder(appId, appSecret)
.eventHandler(dispatcher).autoReconnect(true).build();
new Thread(wsClient::start).start(); // 守护线程起长连接
这段代码还原了生产里的完整链路:收事件→剥@→解析意图→多租户定门店→调MCP工具→渲染卡片回复 。dispatch按关键词路由到门店工具:
java
private StoreResult dispatch(String text, String storeCode) {
String lower = text.toLowerCase();
if (lower.contains("库存")) return storeService.getInventory(storeCode);
if (lower.contains("设备")) return storeService.getDeviceStatus(storeCode);
if (lower.contains("补货")) return storeService.suggestRestock(storeCode);
return storeService.getStoreSales(storeCode); // 默认销售
}

真实启动日志(2026-10-07本机):
Started FeishuMcpApplication in 3.035 seconds (Spring Boot 4.0.0 / Java 21 / Spring AI 2.0.1)
Registered Tool: get_store_sales / save_context / get_inventory /
suggest_restock / load_context / get_device_status → Registered tools: 6
connected to wss://msg-frontier.feishu.cn/ws/v2?...&conn_id=7693857954043481288
群内@MCP篇11测试 查库存ST001→本机客户端实时打印事件、调工具、回卡片(应用内机器人显示名即"MCP篇11测试";开篇对话为阅读顺畅称"MCP助手")。六工具零重写挂载篇8的StoreService业务域(@Tool引用,P5通过)。
踩坑(必看,5条+1补):
com.lark.oapi.Client(API)与com.lark.oapi.ws.Client(WS)同名Client,两个single-type import会冲突,API客户端须全限定名。- 发布版本须携
im:message.receive_v1,0.0.1未携收不到事件,发0.0.2即可达。 - 补:发布版本后事件生效约1分钟延迟(飞书FAQ),常误以为立即生效。
- 飞书只有@下拉框真正选中机器人才生成mention实体,直接输入
@名称文本不触发。 - 群机器人被误移出后需API重建验证群(已用
oc_a1c5...群完成)。
生产化小节(本机/待生产两栏):
- 事件幂等:
event_id去重。飞书投递不保证不重复,生产第一天就要做------区别于本自主题下的篇04的"外部攻击重放",那是另一回事(攻击重放需签名校验+时间窗,平台重投是正常重复)。 - 多实例分发:一实例一条WS、事件只进一个实例;多实例需跨实例共享去重(Redis分发/发布订阅)。事件落到哪个实例:各实例各开一条 WS 接住全量事件,按
chat_id一致性哈希把同一会话固定到同一实例(会话状态亲和),其余实例靠 Redis 全局去重防重复触发;或简单轮询分发(代价是InMemoryContextStore改为 Redis 共享)。注意这与InMemoryContextStore(上下文共享)是两个不同问题,别混------前者是事件去重,后者是会话状态共享。

java
// 跨实例幂等(Redis SETNX 带TTL)
if (!redis.setnx("evt:" + eventId, "1", Duration.ofMinutes(10))) return; // 已处理,丢弃

5. 横向对比:钉钉
钉钉拆两层说,别笼统说"钉钉双向受限":
- 群自定义机器人Outgoing :截至2026-10实测 显示"功能维护中",本次仅单向推送可达(本篇独家差异)。加签POST返回
{"errcode":0,"errmsg":"ok"}验证链路通,但收@消息回调不可用。 - 钉钉Stream模式:官方推荐双向方案,能力对等飞书长连接(TCP持久连接、免公网),但需企业内部应用+组织开发者权限,个人版门槛更高(本篇未实测,已标注)。
结论:受限仅指"群自定义机器人Outgoing线",不是泛指钉钉平台。若已有钉钉体系且是企业应用,Stream是等价路线。
6. 选型决策表
| 场景 | 方案 |
|---|---|
| 门店告警/设备离线 | 第1层 群机器人Webhook |
| 富卡片+用户身份+隔离 | 第2层 自建应用 |
| 群@交互查门店 | 第3层 事件订阅 |
| 已有钉钉体系 | 钉钉webhook(Outgoing维护中,单向)/ Stream(企业应用) |
关于"统一接口封装":三平台事件模型差异大,强统一事件层是过度设计,原第6节已砍,思路并入本表------飞书/钉钉各用原生客户端,对外只暴露
WebhookSender最小抽象(两实现各≤20行)即可。

7. 踩坑速查表
(第4节已逐条展开,此处速查;钉钉要点单列。)
| # | 飞书踩坑 | 现象/解法 |
|---|---|---|
| 1 | Client同名冲突 |
API客户端用com.lark.oapi.Client全限定名 |
| 2 | 事件权限未携 | 发布版本须含im:message.receive_v1,发0.0.2 |
| 3 | 发布后延迟 | 约1分钟才生效,别当失败 |
| 4 | @不触发 | 必须下拉框真选机器人,文本@无效 |
| 5 | 群机器人被移出 | API重建验证群 |
| + | 钉钉Outgoing | 截至2026-10维护中,仅单向可达 |
8. 边界声明
本机已验证:长连接连飞书、收p2p/群@事件、调6工具回卡、钉钉webhook推送;环境Spring Boot 4.0.0/Java 21/Spring AI 2.0.1。
待生产验证 :真实流量限流、access_token轮换与多实例共享、消息加解密与重放防护、长连接多实例分发与event_id幂等、门店数据隔离多租户压测。
密钥声明:调试用飞书App Secret/钉钉secret已重置,本地含密钥文件已删除。
能力→岗位对照(求职资产化):
飞书三层接入(Webhook/卡片/事件)→ 飞书开放平台API系统集成开发
长连接事件订阅(WS) → 企业IM双向消息/机器人回调
用户身份+门店数据隔离 → 权限管理、组织架构对接
门店场景(销售/库存/设备) → 零售/供应链业务系统集成
完整代码 :仓库11-feishu模块+v11 tag(Gitee/GitHub),一键启动只需飞书App ID/Secret+复制两个yml。