跳到正文

04 - 渠道对接:企业 IM 统一聚合 ​

一句话:全企业共用一个 IM 应用,聚合器承担验签、event_id 去重、sender 到 user_id 的绑定查找与 chat 到 session 的映射,把三种平台协议压成一次 console 入站;凭证与审计因此集中在一处,代价是绑定表成了路由正确性的唯一依据,同步滞后就会把消息交给别人的分身,而同一会话必须串行投递才保得住上下文。

一、核心设计决策 ​

问题:全企业员工在飞书/企微/钉钉里 @ "我的数字分身",消息如何统一收口?

结论:渠道聚合在平台层——一个企业 IM 应用(机器人)接收全部员工消息,正确区分"这是发给谁的",路由到对应员工实例。实例只保留 console 入站 API,不直接对接任何 IM。

二、为什么实例不留原生 IM 通道 ​

QwenPaw 原生支持 18 种 channel(feishu/wecom/dingtalk/...),每种 channel 走长连接 + 应用级凭证。如果每个实例都配置独立的 IM 凭证:

问题影响
凭证爆炸1000 员工 × 3 IM 平台 = 3000 套凭证管理
回调地址不统一每个实例需要独立公网回调 URL,NAT 穿透复杂
消息无处聚合各实例独立收消息,无法统一审计和管控
资源浪费每个实例维护 3 个长连接,空闲时占用连接池

收敛方案:实例设置 QWENPAW_ENABLED_CHANNELS=console,仅保留 console 入站。所有 IM 消息由平台级渠道聚合器统一处理后,转为 POST /api/console/chat 调用。

三、身份映射:IM sender → 企业 user_id ​

3.1 绑定表结构 ​

sql
CREATE TABLE im_user_binding (
    id              BIGSERIAL PRIMARY KEY,
    platform        VARCHAR(20) NOT NULL,     -- feishu / wecom / dingtalk
    platform_user_id VARCHAR(128) NOT NULL,   -- 飞书 open_id / 企微 userid / 钉钉 userId
    user_id         VARCHAR(64) NOT NULL,     -- 企业员工号(数字分身 owner)
    bind_status     VARCHAR(20) DEFAULT 'bound', -- unbound/binding/bound/expired
    source          VARCHAR(20) DEFAULT 'sync',  -- sync(组织同步) / manual(引导绑定)
    bound_at        TIMESTAMPTZ,
    unbound_at      TIMESTAMPTZ,
    metadata        JSONB DEFAULT '{}',
    
    UNIQUE(platform, platform_user_id)
);

CREATE INDEX idx_binding_user ON im_user_binding(user_id);
CREATE INDEX idx_binding_status ON im_user_binding(bind_status);

一个员工可在多个 IM 平台绑定(同一 user_id 对应多个 platform_user_id),同一平台内 platform_user_id 唯一。

3.2 两条绑定路径 ​

主路径:组织架构同步

兜底路径:引导绑定

3.3 绑定缓存 ​

四、消息协议归一化 ​

4.1 三平台消息格式 ​

飞书(事件订阅 v2):

json
{
  "schema": "2.0",
  "header": {
    "event_id": "evt_xxx",
    "event_type": "im.message.receive_v1",
    "token": "verify-token"
  },
  "event": {
    "sender": {
      "sender_id": {"open_id": "ou_xxx"},
      "sender_type": "user"
    },
    "message": {
      "chat_id": "oc_xxx",
      "msg_type": "text",
      "content": "{\"text\":\"你好\"}"
    }
  }
}

企微(回调 URL,AES-CBC 加密):

json
{
  "ToUserName": "corpid",
  "FromUserName": "ZhangSan",
  "MsgType": "text",
  "Content": "你好",
  "MsgId": "msg_xxx",
  "CreateTime": 1698000000
}

钉钉(Stream 模式,长连接):

json
{
  "senderStaffId": "staff_xxx",
  "conversationId": "cid_xxx",
  "msgtype": "text",
  "text": {"content": "你好"}
}

4.2 统一 ChannelMessage 格式 ​

python
@dataclass
class ChannelMessage:
    platform: str              # "feishu" | "wecom" | "dingtalk"
    event_id: str              # 去重幂等键(MsgId/event_id)
    sender: str                # 平台用户标识(open_id/userid/userId)
    chat_id: str               # 单聊/群会话标识
    msg_type: str              # "text" | "image" | "file" | "interactive"
    content: str               # 解析后的消息内容
    raw_payload: dict          # 原始消息体(用于特殊处理)
    verified: bool             # 是否已通过平台验签
    received_at: datetime      # 接收时间
    
    # 归一化后填充
    user_id: str = ""          # 绑定表查到的企业员工号
    session_id: str = ""       # 会话映射结果

4.3 适配器接口 ​

python
class IMAdapter(ABC):
    """IM 平台适配器基类,每个平台实现一个"""
    
    @abstractmethod
    def verify(self, request: Request) -> bool:
        """验证请求来源(平台签名校验)"""
    
    @abstractmethod
    def parse_message(self, request: Request) -> ChannelMessage:
        """解析平台消息为统一格式"""
    
    @abstractmethod
    async def send_text(self, chat_id: str, text: str):
        """发送文本消息"""
    
    @abstractmethod
    async def send_image(self, chat_id: str, image_url: str):
        """发送图片消息"""
    
    @abstractmethod
    async def send_file(self, chat_id: str, file_url: str, filename: str):
        """发送文件消息"""
    
    @abstractmethod
    async def send_card(self, chat_id: str, card_template: dict):
        """发送交互卡片(审批/确认)"""
    
    @abstractmethod
    def handle_callback(self, request: Request) -> dict:
        """处理卡片回调(审批点击等)"""

新增 IM 平台 = 实现一个适配器。一次实现,全企业员工可用。

五、会话映射 ​

5.1 映射策略 ​

模式session_key说明默认
统一模式user_id 名下默认会话IM 单聊与 Web 同会话,上下文连续✅
群聊模式(platform, chat_id)群内独立会话按需
端隔离模式(platform, user_id, device)IM/Web 各一,互不串扰可选

5.2 统一模式(默认) ​

5.3 群聊模式 ​

六、消息路由与回发时序 ​

七、IM 交互卡片(AI-UI) ​

7.1 卡片类型 ​

交互形态场景载体
按钮卡片(批准/拒绝/重试)HITL 审批、委托授权飞书交互卡片 / 企微模板卡片 / 钉钉卡片
多选/填空卡片工具调用参数确认、技能启用确认同上(表单回调)
富文本 + 图片/文件消息产物交付、报告预览IM 文件/图片消息
对话式确认"是否继续?/ 换方案?"多轮收敛机器人提问,员工自然语言回复

7.2 审批卡片协议 ​

json
{
  "card_type": "approval",
  "request_id": "req_xyz789",
  "title": "需要你的授权",
  "content": {
    "action": "删除文件",
    "target": "/data/old_reports/",
    "risk_level": "high",
    "instance_id": "inst_abc123"
  },
  "actions": [
    {"id": "approve", "label": "批准", "style": "primary"},
    {"id": "reject", "label": "拒绝", "style": "danger"},
    {"id": "comment", "label": "附备注", "type": "input"}
  ],
  "callback_url": "https://agent.example.com/api/channel/callback",
  "timeout_seconds": 3600
}

7.3 卡片回调闭环 ​

八、产物经 IM 通道回传 ​

8.1 回传流程 ​

8.2 产物类型与平台适配 ​

产物类型IM 回传形态平台 API
图片/截图image 消息(可预览)飞书 im/v1/images、企微 media、钉钉 uploadMedia
文档/报告file 消息(点击下载)飞书 im/v1/files、企微 media、钉钉 uploadMedia
压缩包/大文件对象存储链接 + 卡片MinIO presigned URL + IM 文本/卡片消息
审批/确认交互卡片飞书交互卡片、企微模板卡片、钉钉卡片

8.3 失败处理 ​

九、可靠性保障 ​

项方案
幂等去重event_id + 5 分钟时间窗去重(IM 回调可能重发/乱序)
并发串行同一 session_key 串行投递(Redis 分布式锁),不同会话并行
保活重连长连接/Stream 断线重连 + 指数退避(1s → 2s → 4s → 最大 60s)
限流平台侧限流(IM 平台 QPS 限制)+ 聚合器侧限流(防风暴)
可观测消息链路 trace(IM 事件 → 实例 → 回发),审计全量留痕
失败处理实例暂存 → 唤醒(返回"助手唤醒中");投递失败 → 重试 → 失败回执

十、多平台接入配置 ​

yaml
# 渠道聚合器配置
channel_adapters:
  feishu:
    enabled: true
    app_id: "${FEISHU_APP_ID}"
    app_secret: "${FEISHU_APP_SECRET}"  # 从 Vault 注入
    verify_token: "${FEISHU_VERIFY_TOKEN}"
    encrypt_key: "${FEISHU_ENCRYPT_KEY}"
    event_url: "https://agent.example.com/channel/feishu/events"
    callback_url: "https://agent.example.com/channel/feishu/callback"
    
  wecom:
    enabled: true
    corp_id: "${WECOM_CORP_ID}"
    agent_id: "${WECOM_AGENT_ID}"
    secret: "${WECOM_SECRET}"
    encoding_aes_key: "${WECOM_AES_KEY}"
    callback_url: "https://agent.example.com/channel/wecom/callback"
    
  dingtalk:
    enabled: true
    app_key: "${DINGTALK_APP_KEY}"
    app_secret: "${DINGTALK_APP_SECRET}"
    # Stream 模式,无需回调 URL

所有凭证从 Vault 注入,不落配置文件、不进日志、不进备份。

把 AI 工程讲到既懂也会用