NEW

免费试用已开放

立即开始

API 网关 · 长连接

企业微信数据协议与消息协议

更新于 2026-08-167 分钟

「数据协议」和「消息协议」常常并排出现在检索里,但它们既不是企业微信官方的规范名,也不指两份不同的文档 —— 说的是同一套接入方式的两个切面:实体长什么样,事件长什么样。这篇不抄字段表,讲的是报文拿到手之后怎么存、怎么解,以及三个月后对方多出一种消息类型时你会不会挂;示意按 wecomapi 的接入形态写。

两个切面:实体可覆盖,事件只能追加

数据这一侧描述「存在的东西」:账号实例、客户、外部群、群成员、会话。特征是有稳定标识、可以被查询、状态会随时间变。消息这一侧描述「发生的事」:一条消息、一次入群、一次退群、一次撤回。特征是有发生时刻、内容不可变、一旦发生就是历史的一部分。

这个区分不是文字游戏,它直接决定存储形态:实体表可以更新覆盖,事件表必须 append-only。把事件也做成「按最新状态覆盖写」,是这类系统里最常见的一处结构性错误 —— 覆盖掉的那一刻历史就再也拼不回来,而你通常要等到第一次需要复盘某个客户的完整轨迹时才发现。

还有个方向性的理由:事件表 append-only 之后,实体状态错乱了可以靠重放事件重建;实体表被覆盖之后,事件推不回来。这个方向是单向的,所以两者冲突时优先保事件。

判断一份数据该进哪一侧,问一句就够:它会不会被改?会被改的是实体,不会被改的是事件。同一个客户的「当前标签」是实体,「某时刻被打上了某标签」是事件,两者都要存,但存法完全不同。

一条消息拆三层存,别整包塞一列

无论从哪一侧拿到,一条消息报文大致都能切成三段:信封(哪个账号、哪个会话、谁发的、什么时候)、类型判别(这是文本、图片还是卡片)、载荷(这个类型自己的形状)。三段的稳定性完全不同,所以存法也该不同。

  • 信封拆成列。它要参与索引、分区、时间范围查询和去重,做成 JSON 里的嵌套字段,第一次做「查这个会话最近三天的消息」就会全表扫。
  • 载荷整包存。它的形状随类型和版本变,做成列意味着每支持一种新消息类型就改一次表结构,而这种变更几乎总是发生在你最忙的那一周。
  • 原始报文再单独存一份,带上收到时刻和一个自己算的哈希。它不参与查询,用途只有一个:三个月后重放。

原始报文确实是冗余,但这是整条链路上最便宜的保险 —— 对象存储的成本远低于「历史数据解析错了、且没法重来」。真正要注意的是留存期限和访问权限,这两件事要在合规评审里说清楚,而不是等审计时临时找答案。

一个具体的落法:用 wecomapi 的事件回调收到消息后,把信封写进消息表、把整包载荷写进同一行的 JSON 列、把原文丢进对象存储并回填引用,这三件事在同一次消费里完成。拆成两次做,迟早会出现有信封没原文、或者有原文没索引的记录,而这类记录在对账时最难处理。

媒体消息给的是引用,不是内容

图片、语音、视频、文件这几类,事件里带的是一个可以换取内容的引用,不是内容本身。这一条带来三个工程后果,每一个都会在上线后咬人。

  1. 1换取内容是一次额外的网络请求,而且会失败。它绝对不能挂在回调的响应路径上 —— 一个大文件能把响应拖到秒级,接着就是重投。
  2. 2引用通常有时效。这意味着「要不要存副本」必须在收到的当下决定,不能推迟到用户第一次点开的时候。等到那天再去换,可能已经换不到了。
  3. 3存副本本身有代价:存储成本,以及更重要的合规责任 —— 保存客户发来的文件,意味着你成了这份数据的保管方,留存期限、访问控制和删除流程都得有人负责。

落地做法是收到后立刻把引用写进一条下载任务,下载放在消费者里跑,完成后回写本地地址,消息记录同时保留原引用和本地地址两个值。用 wecomapi 的事件回调时媒体类事件同样只带引用,所以下载失败要能重试,且重试要有次数上限 —— 引用过期之后再怎么重试都不会成功,这类任务应该尽早进死信,而不是永远占着消费能力。

如果业务上确实不需要看内容,比如只做消息量统计和关键词命中,就明确决定不下载,并在数据模型里写清楚这个字段永远为空。留一个「以后再说」的空字段,半年后一定会有人以为它有值。

四个反复出问题的编码点

四字节字符

emoji 和一部分生僻字是四字节的。整条链路上任何一层不是 utf8mb4 —— 数据库、连接串、表、列、客户端驱动 —— 都会导致截断或写入失败。典型症状是「消息存进去少了后半截」或者偶发的写库报错,而报错信息通常指向一个看起来毫不相关的位置。这类问题在测试数据里几乎不出现,因为没人会在测试用例里打表情。

换行与不可见字符

回车换行在框架、代理、序列化各层都可能被规范化,零宽字符也会被某些清洗逻辑吃掉。只要你在任何地方用消息内容算过哈希 —— 去重键、签名、内容指纹 —— 规范化就会让两次计算的结果对不上。规矩很简单:算哈希用原始字节,不用反序列化之后再拼回来的字符串。

二次转义

带尖括号和引号的内容,在「入库 → 取出 → 拼进另一个结构 → 再发出去」的过程里很容易被转义两遍,客户最后看到的是一串实体编码。判断标准是把转义收在最终输出的那一层,中间所有环节一律存原文。这条规矩人人都同意,但只要入库和发送分别由两个人负责,就一定会各转义一次。

超长字段进日志

把媒体内容直接编码进 JSON 会让报文膨胀三分之一,更麻烦的是它会进日志:一条日志几百 KB,几万条之后磁盘和检索都完了。规矩是日志里对超长字段截断,只留长度和哈希,需要原文时按引用去对象存储取。

这四个点的共同特征是:联调阶段一个都不会暴露,真实流量里一个都跑不掉。验收时专门造四条脏数据 —— 一条带表情、一条带多行和首尾空白、一条带尖括号和引号、一条带大附件 —— 跑完整链路再去看落库结果和日志,性价比高于把文档从头读一遍。

版本兼容:四条规矩

对方新增一种消息类型、多返回一个字段,这类事一定会发生,而且不会提前通知到每一个使用方。下面四条按性价比排序。

  1. 1未知类型必须有降级分支。收到没见过的类型,正确行为是按「不可识别消息」入库、打一条告警,绝不是抛异常。一条谁也没见过的消息让整个消费者反复崩溃重启,是这类系统最常见的全线故障成因。
  2. 2解析要宽松。多出来的字段忽略掉,不要用严格 schema 在入口处直接拒绝。严格校验放在你自己的内部模型上,不放在外部报文的入口 —— 入口的职责是收下来,不是评判。
  3. 3外部标识不做数据库列名,也不做业务枚举值。中间隔一层映射,映射改起来是一行配置,列名和枚举改起来是一次迁移。
  4. 4保留原始报文,并且真的演练过重放。兼容性的兜底从来不是代码写得多周全,而是出问题之后能把那段时间的报文重新跑一遍。没演练过的重放能力等于没有。
示意:信封、类型判别与未知类型降级javascript
// 示意逻辑,外部类型枚举与字段名以 wecomapi 文档为准,这里只表达解析分层
function parse(raw) {
  const env  = readEnvelope(raw);        // 信封:账号 / 会话 / 发送者 / 时刻
  const kind = KIND_MAP[readKind(raw)];  // 外部类型 -> 自己的枚举,隔一层

  switch (kind) {
    case "text":
      return { ...env, type: "text",  body: readText(raw) };
    case "media":
      return { ...env, type: "media", ref: readMediaRef(raw) }; // 引用,不是内容
    default:
      return { ...env, type: "unknown", rawRef: store(raw) };   // 降级,不抛异常
  }
}

四条里第一条最便宜、收益最大:一个 default 分支,就能把「未知类型」从全线故障降级成一条告警。它在代码里通常只占三行。重放要守同一条规矩:从回调进来的报文和从对象存储重新喂进去的报文,必须经过同一个解析器、同一套幂等。另写一套重放代码是最省事也最危险的做法,它一定会和主链路的行为分叉。

本文讲的是结构分层、编码陷阱与兼容策略。精确的消息类型枚举、字段定义与媒体获取方式以 wecomapi 线上接口文档为准,示意代码只表达解析位置,不要照抄上生产。

常见问题

「企业微信数据协议」是官方术语吗?
不是。企业微信官方没有发布过叫「数据协议」或「消息协议」的规范,这是开发者社区用来指代「实体与事件长什么样」的习惯说法。看到这类词时,真正该确认的是具体接入方式提供哪些实体、哪些事件,以及报文形态和版本策略。
消息内容要不要全量落库?
分层决定:信封建议全量落且拆成列,它是所有查询的入口;载荷按业务需要落,整包存;媒体默认只落引用,需要内容时再决定是否下载副本。全量落之前先确认留存期限和访问控制 —— 保存客户内容意味着你要为这份数据负责。
对方新增字段会不会把我这边搞挂?
取决于你的解析严不严格。宽松解析加未知类型降级,新增字段和新增类型都只会让你少收到一些信息,不会导致崩溃;用严格 schema 在入口卡校验的实现,会在对方发版当天全线失败。字段与类型的现状以 wecomapi 线上接口文档为准,但代码要按「文档会变」来写。

准备好动手了?

精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。

相关文章