database-design.md 8.4 KB

企微 Webhook 接收服务 — 数据库设计文档

一、数据库概述

  • 数据库类型:PostgreSQL(通过 Parse Server 管理)
  • Parse App IDlami-ai
  • Schema 管理:启动时通过 Parse Schema API 自动建表,幂等执行
  • 数据访问:全部通过 Parse SDK(parse/node),使用 masterKey 绕过 ACL 权限

二、核心设计原则

2.1 数据隔离:roomId + guid 联合唯一

每条记录都携带 guid(设备/账号标识),与业务主键(如 roomId)组成联合去重键。

为什么需要 guid?

企业微信平台中,一个公司可能有多个员工的企微账号接入。每个账号登录后对应一个独立的设备节点(guid)。回调数据中同时包含:

  • guid — 来自哪个设备/账号
  • fromRoomId — 发生在哪个群

同一个 roomId 可能被多个设备看到(比如同一个群里有多个公司的员工)。如果只按 roomId 去重,不同设备的数据会互相覆盖。

隔离策略:所有表的查询/写入都以 (roomId, guid)(userId, guid) 作为唯一性约束。查询时按 guid 过滤,确保只返回属于该设备的数据。

2.2 Upsert 模式

群表和成员表不直接 insert,而是先查询是否存在,存在则更新,不存在则创建。原因:

  • 回调事件可能重复推送
  • 群信息可能发生变更(改名、人数增减)
  • 成员可能退出后重新加入

2.3 Base64 成员列表解析

群事件中 changedMemberListbase64RawData 字段是 base64 编码的字符串。解码后为分号分隔的 userId 列表。

例如:

原始 base64:MTY4ODg1MDAwMDAxOzE2ODg4NTAwMDAwMjsxNjg4ODUwMDAwMDM=
解码后:   "168885000001;168885000002;168885000003"
解析结果: ["168885000001", "168885000002", "168885000003"]

三、数据表设计

3.1 GroupChat(群聊表)

存储企业微信群聊的基本信息。

字段 类型 说明
objectId String Parse 自动生成的主键
roomId String 企微群 ID(来自回调 fromRoomId
roomName String 群名称(来自群名变更事件 base64 解码)
ownerId String 群主企微 userId
memberCount Number 当前成员数
status String active(活跃)| dismissed(已解散)
guid String 所属设备/账号标识
createdAt Date Parse 自动,记录创建时间
updatedAt Date Parse 自动,记录最后更新时间

索引

  • roomId_guid:联合索引 (roomId, guid) — 用于 upsert 查询

数据来源

  • msgType=1006(群创建):写入新记录,含群主和初始成员
  • msgType=1001(群名变更):更新 roomName
  • msgType=1023(群解散):更新 status=dismissed

去重逻辑:按 (roomId, guid) 联合查询,存在则更新已有字段,不存在则创建。


3.2 GroupMember(群成员表)

记录每个群内成员的加入/退出状态。

字段 类型 说明
objectId String Parse 自动生成的主键
roomId String 所属群 ID
userId String 成员企微 userId
nickname String 群内昵称
status String active(在群)| left(已退出)
joinedAt Date 入群时间
leftAt Date 退群时间(status=left 时记录)
guid String 所属设备/账号标识
createdAt Date Parse 自动
updatedAt Date Parse 自动

索引

  • roomId_userId_guid:联合索引 (roomId, userId, guid) — 用于 upsert 查询

数据来源

  • msgType=1006(群创建):changedMemberList 解码后的所有成员,状态 active
  • msgType=1002(新增成员):解码后的成员,状态 active
  • msgType=1003(移除成员):解码后的成员,状态 left
  • msgType=1005(退群):事件 senderId,状态 left

重入群处理:如果成员之前标记为 left,收到再次入群事件时,状态恢复为 activeleftAt 清空,joinedAt 更新为新时间。


3.3 Message(消息表)

存储群内的文本消息。当前仅存储 msgType=0msgType=2(纯文本)。

字段 类型 说明
objectId String Parse 自动生成的主键
msgUniqueIdentifier String 消息唯一标识(来自回调,去重键)
roomId String 所属群 ID
senderId String 发送者企微 userId
content String 文本内容(来自 msgData.content
atList Array @提及的用户列表(来自 msgData.atList
msgType Number 消息类型(0 或 2)
timestamp Date 消息时间戳(Unix timestamp 转换)
guid String 来源设备
createdAt Date Parse 自动
updatedAt Date Parse 自动

索引

  • msgUniqueIdentifier:唯一索引 — 用于消息去重

存储策略

  • 仅存文本msgType=0msgType=2 写入,其他类型(图片/视频/文件/链接等)忽略
  • 去重:按 msgUniqueIdentifier 查重,已存在则跳过
  • 完整存储content 存文本原文,atList 保留 @提及信息

为什么暂不存非文本消息?

图片/视频/文件消息的 msgData 结构各异(图片有 fileId+fileAeskey,视频有 coverImageId+duration),且媒体文件需要通过额外 API 下载。当前阶段业务优先需要文本数据,非文本消息后续按需扩展。


四、数据流向图

企微平台 POST
  │
  ├─ cmd=15000, msgType=1006(群创建)
  │   ├─ GroupChat.upsert(roomId, guid) → 写入/更新群信息
  │   └─ base64 解码 changedMemberList
  │       └─ 逐个 GroupMember.upsert(roomId, userId, guid) → 写入成员
  │
  ├─ cmd=15000, msgType=1001(群名变更)
  │   ├─ base64 解码 rawData → 群名称
  │   └─ GroupChat.upsert(roomId, guid, { roomName }) → 更新群名
  │
  ├─ cmd=15000, msgType=1002(成员加入)
  │   └─ base64 解码 → 逐个 GroupMember.upsert(status=active)
  │
  ├─ cmd=15000, msgType=1003(成员移除)
  │   └─ base64 解码 → 逐个 GroupMember.upsert(status=left)
  │
  ├─ cmd=15000, msgType=1005(成员退群)
  │   └─ GroupMember.upsert(senderId, status=left)
  │
  ├─ cmd=15000, msgType=1023(群解散)
  │   └─ GroupChat.upsert(roomId, guid, { status: 'dismissed' })
  │
  ├─ cmd=15000, msgType=0/2(文本消息)
  │   ├─ Message.query(msgUniqueIdentifier) → 去重检查
  │   └─ 不存在 → Message.save()
  │
  └─ cmd=15500 / 11016 / 20000
      └─ 日志记录(暂不入库)

五、Upsert 逻辑伪代码

async function upsertGroupChat(roomId: string, guid: string, extra: Record<string, any>) {
  // 1. 查询是否存在
  const query = new Parse.Query('GroupChat');
  query.equalTo('roomId', roomId);
  query.equalTo('guid', guid);
  const existing = await query.first();

  // 2. 存在 → 更新
  if (existing) {
    for (const [key, value] of Object.entries(extra)) {
      if (value !== undefined) existing.set(key, value);
    }
    return existing.save(null, { useMasterKey: true });
  }

  // 3. 不存在 → 创建
  const obj = new Parse.Object('GroupChat');
  obj.set('roomId', roomId);
  obj.set('guid', guid);
  obj.set('status', 'active');
  for (const [key, value] of Object.entries(extra)) {
    if (value !== undefined) obj.set(key, value);
  }
  return obj.save(null, { useMasterKey: true });
}

GroupMember 的 upsert 逻辑相同,只是在更新时会额外处理"重入群"场景:如果 existing.status === 'left' 且新数据 status === 'active',表示成员重新入群,需要恢复状态和清空退群时间。


六、扩展指南

后续需要新增消息类型时

webhook.service.ts 中新增 handler(如 handleImageMessage),并在 processWebhookEvent()msgType 分支中添加调用。图片/视频/文件消息需要额外处理媒体文件下载(通过企微 API)。

后续需要新增系统消息入库时

cmd=15500 的系统消息当前仅记录日志。如需入库(如联系人变动、标签操作),在 15500 分支下按 msgType 分发写入对应的新增表。

后续需要新增数据表时

schema-setup.tsschemas 数组中新增条目,启动时自动建表。无需手动操作数据库。