钉钉 Stream + 飞书长连接:不暴露公网端口,让 AI 主动连进企业 IM

上一篇讲了 BizBuddy 渠道网关的整体架构,这篇说实现。

先说结论有多重要:客户内网没有公网 IP 的时候,"能不能用长连接"这一条直接决定了项目能不能落地。Webhook 再优雅也救不了这个场景,因为方向不对------它要平台来回调你,而你没有能被人回调的地址。

长连接为什么能免公网 IP

区别就一点:连接是谁发起的。

Webhook 是平台发起、你接。你的服务必须在公网上有块地盘。

长连接反过来,你的应用主动去连平台网关,之后消息复用这条已经建好的 TCP / WebSocket 通道推给你。没有入站连接,自然就不需要公网 IP、域名、备案、内网穿透这一整套东西。顺带的,回调地址的 URL 校验握手也不需要了;建连时已经完成鉴权,之后的报文是明文的,Encrypt Key 和 Verification Token 都不用参与。

代价在后面:连接得自己维护------重连、退避、单副本部署,这些是长连接形态躲不开的功课。

钉钉 Stream:JSON 帧,但有个坑

钉钉 Stream 的帧是 JSON,理论上零依赖手写一个 WebSocket 客户端也能跑通。我们直接用了框架封好的长连接客户端,装配时传一个"收到报文怎么办"的回调就行:

java 复制代码
case AiChannel.PLATFORM_DINGTALK -> {
    // 框架的 apiBase 只管 OpenAPI(accessToken / 出站),
    // 长连接注册地址是另一个独立字段,默认硬编码在默认基址上,不跟着 apiBase 走。
    String streamRegisterUrl = StringUtils.isBlank(apiBase)
        ? null
        : apiBase + "/v1.0/gateway/connections/open";

    DingTalkChannelProperties props = new DingTalkChannelProperties(
        appId, appSecret, robotCode,
        apiBaseOrNull,  /* ... */
        streamRegisterUrl);

    dingTalkStreamLocal = new DingTalkStreamClient(props, this::onDingTalkPayload);
}

注意那段注释。这是实打实踩过的坑:把 API 基址改成内网代理之后,OpenAPI 调用都正常了,长连接却一直起不来,日志里还看不出原因。翻框架源码才发现,"OpenAPI 基址"和"长连接注册地址"是两个独立字段,后者默认指向公网地址。上面那段显式派生就是修法。

内网机房、出口受限的环境,这个坑几乎必踩。

飞书长连接:私有协议,只能上官方 SDK

飞书长连接的帧是私有 protobuf。手写的意思是要自己维护帧格式、心跳、ACK、重连------投入不小,还容易踩协议的坑。所以这里我们做了一个权衡,也是整个项目里唯一一次破例新增第三方依赖:

xml 复制代码
<dependency>
    <groupId>com.larksuite.oapi</groupId>
    <artifactId>oapi-sdk</artifactId>   <!-- 2.8.7 -->
</dependency>

理由很直接:飞书长连接的建连能力只有官方 SDK 有,自己实现属于重复造轮子。好在 SDK 自带 shaded 的 protobuf 和 okhttp,跟项目里的 protobuf 版本不打架。

我们没有让 SDK 的类型渗进业务代码,中间包了一层 FeishuStreamClient,全文不到 120 行:

java 复制代码
public FeishuStreamClient(String appId, String appSecret, String domain, Consumer<JsonNode> onEvent) {
    this.onEvent = onEvent;
    // 长连接建连即鉴权、报文明文,dispatcher 的两个参数一律留空
    EventDispatcher dispatcher = EventDispatcher.newBuilder("", "")
        .onCustomizedEvent("im.message.receive_v1", new CustomEventHandler() {
            @Override
            public void handle(EventReq event) {
                dispatch(event);
            }
        })
        .build();

    Client.Builder builder = new Client.Builder(appId, appSecret).eventHandler(dispatcher);
    if (StringUtils.isNotBlank(domain)) {
        // SDK 内部按 domain + 固定路径拼接,尾斜杠要去掉
        builder.domain(domain.endsWith("/") ? domain.substring(0, domain.length() - 1) : domain.trim());
    }
    builder.onReconnecting(() -> log.info("飞书长连接重连中:appId={}", appId));
    builder.onReconnected(() -> log.info("飞书长连接已重连:appId={}", appId));
    this.client = builder.build();
}

public void start() throws Exception {
    client.start();                 // 异步,建连后立刻返回
    client.awaitReady(10_000L);     // 等握手,失败抛 ServerUnreachableException
}

public void stop() {
    client.close();                 // close 之后 SDK 不再自动重连
}

private void dispatch(EventReq event) {
    try {
        JsonNode payload = OBJECT_MAPPER.readTree(event.getBody());
        onEvent.accept(payload);    // 只入队,不阻塞
    } catch (Exception e) {
        log.warn("飞书长连接事件报文解析失败,已丢弃:原因={}", e.getMessage());
    }
}

这段代码里有三个容易写错的地方,都在上面了:

newBuilder("", "") 的两个参数是 Verification Token 和 Encrypt Key,长连接形态用不上,但必须传空串,传 null 会出问题。

domain 要带 scheme (比如 https://open.feishu.cn),而且不能有尾斜杠,SDK 内部会拿它拼固定路径。

event.getBody() 拿到的是 schema 2.0 信封的原始 JSON,明文。不用解密、不用验签,可以直接丢给入站解析器。

三个官方约束,不守一定出问题

第一,3 秒内必须 ACK。 飞书要求收到事件后 3 秒内响应,所以 SDK 回调里绝不能在原地干活------更不能等 AI 把回答生成完:

java 复制代码
private void dispatch(EventReq event) {
    JsonNode payload = OBJECT_MAPPER.readTree(event.getBody());
    onEvent.accept(payload);   // 这里只是转交,立刻返回
}

真正的处理(解析、幂等、身份校验、调智能体、出站回复)全部异步丢到业务线程池。这一点反直觉,但它是长连接形态能不能稳的关键。

第二,集群模式,必须单副本接收。 飞书长连接是集群模式:同一个应用起了多个客户端副本时,一条消息只会随机投给其中一个。配上我们幂等走 Redis(跨副本共享)的设计,结论是:渠道长连接按单副本接收部署,幂等和限流用 Redis 兜。

这不是缺陷,是官方口径下的正常用法。但一定要主动讲清楚,否则上线后一定会被问"为什么另一个副本收不到消息"。

第三,配额和重连参数别自己发挥。 每应用最多 50 个连接,我们每个渠道只建一个 client;重连间隔、退避策略由服务端下发,不要自己拍脑袋调。

另外,长连接形态只支持企业自建应用,应用商店那种第三方应用用不了。选型阶段就要确认,别做到一半才发现。

谁来建连,谁来断连

长连接是应用主动连出去的,就必须有人负责拉起和关掉。这部分放在运行期装配的注册表里:

时机 做什么
应用就绪(ApplicationReadyEvent) 按启用中的长连接渠道批量建连
渠道保存 / 删除 / 停用 先 stop 旧连接,再按新配置重建(靠指纹 id + updateTime 判断要不要重建)
应用关闭(@PreDestroy) 全部停连

装配层对外只暴露一个判定,把"钉钉恒长连接"和"飞书看 mode"收在同一个方法里:

java 复制代码
public boolean isLongConnection() {
    return dingTalkStream != null || feishuStream != null;
}

public void start() {
    if (feishuStream != null) {
        feishuStream.start();          // 建连 + 等握手
    } else if (dingTalkStream != null) {
        dingTalkStream.start();
    } else {
        return;                        // Webhook 形态,什么也不用做,等平台回调
    }
    log.info("渠道长连接已启动:channelCode={}", channelCode);
}

收报文之后的处理,两个平台长得一样

长连接收到报文之后怎么处理,跟 Webhook 形态是同一套骨架:

java 复制代码
private void onFeishuPayload(JsonNode payload) {
    Optional<String> eventId = FeishuInboundMapper.extractEventId(payload);
    if (eventId.isPresent() && !idempotency.firstSeen(channelCode + "|" + eventId.get())) {
        return;                                   // 幂等:重复事件丢掉
    }
    Optional<InboundMessage> inbound = feishuMapper.map(payload);
    if (inbound.isEmpty()) {
        return;                                   // 非文本或结构异常,静默丢弃
    }
    InboundMessage message = inbound.get();
    if (!rateLimiter.allow(channelCode, message.peer().key())) {
        log.warn("飞书长连接触发防自环限流:channelCode={}, peer={}", channelCode, message.peer().key());
        return;
    }
    inboundHandler.accept(message);               // 交给上层:身份 → 会话 → 无人值守执行
}

钉钉那边是同一个骨架,换的只有两处:幂等键从 event_id 换成 msgId,解析器换成钉钉的 inbound mapper。

还有一点值得提:飞书长连接的报文交给的是和 Webhook 形态同一份解析器 。因为 getBody() 本来就是 schema 2.0 信封,两种形态在解析层天然合流。这也印证了那句"形态是接入层的事,不该是业务层的事"。

两边对比

钉钉 Stream 飞书长连接
帧协议 JSON 私有 protobuf
要不要第三方 SDK 不要,框架已封装 要,oapi-sdk(本项目唯一破例依赖)
怎么建连 start() start() + awaitReady(ms)
报文形态 JSON JSON(schema 2.0 信封,明文)
要验签/解密件吗 不要,建连鉴权 不要,建连鉴权
重连 自动 自动,autoReconnect 默认开
主要坑 注册地址不跟 apiBase 走,得自己派生 三个参数细节:空串、domain 带 scheme、getBody 是明文
硬约束 集群模式单副本 3 秒 ACK、集群模式单副本、≤50 连接、仅自建应用

最后

长连接的价值可以用一句话概括:它把"要求你有公网可达"变成了"只要你能出网"。 对私有化交付来说,这一条往往就是生死线。

但代价也是实打实的:省下的是网络配置,多出来的是连接生命周期管理、3 秒 ACK 逼出来的异步化,以及单副本的部署纪律。这三件事做扎实,长连接就是内网场景下最合适的 IM 接入形态。

下一篇收个尾,讲落地:《企业级 AI 平台的 IM 接入:从选型到上线,一篇讲完》------建表、后台配置、身份映射、上线检查表,还有一张真实报错对照表,可以直接当交付手册用。


关于 BizBuddy

BizBuddy 是一个全量开源的企业级智能体平台(MIT License):RuoYi-Vue-Plus(Spring Boot 4.1.1 / JDK 21)做底座,AgentScope Java Harness 做智能体内核,纯 Java 全栈、不含 Python,权限、审计、配额、多渠道接入等能力全部随仓库开源。

欢迎 Star、Issue 与共建。

相关推荐
IT智慧客07312 小时前
研究数字孪生的这半年:从 Demo 到新想法
人工智能
youcans_2 小时前
【嵌入式软件AI编程】21. 整理通用提示词模板
stm32·单片机·嵌入式·ai编程
F&C嘉准传感器2 小时前
国产接近FSCU1808全密封接近开关:CNC油污工况稳定检测,撞机事故减少
人工智能·安全·目标检测·自动化
人工智能AI技术2 小时前
给AI记忆上户口!从零打造AMP开源协议
人工智能
俊哥V2 小时前
每日 AI 研究简报 · 2026-10-20
人工智能·ai
明月_清风2 小时前
拆解 DeepSeek 最新开源推理框架 DeepInfer:万亿级大模型的高效落地实战
人工智能·后端·deepseek
浅安的邂逅2 小时前
261020-DeepSeek 双线出击:V4.1-Flash 登顶 Agent Arena,推理框架 DeepInfer 全面开源
人工智能·开源·deepseek·ai日报·产品发布/更新
龙亘川2 小时前
电子印章系统对接管理业务建模:从需求申请、接口开发到监控运维的全流程闭环
大数据·运维·人工智能·智慧城市·开源软件