# 企微 Webhook 接收服务 — 数据库设计文档 ## 一、数据库概述 - **数据库类型**:PostgreSQL(通过 Parse Server 管理) - **Parse App ID**:`lami-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 成员列表解析 群事件中 `changedMemberList` 和 `base64RawData` 字段是 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`,收到再次入群事件时,状态恢复为 `active`,`leftAt` 清空,`joinedAt` 更新为新时间。 --- ### 3.3 Message(消息表) 存储群内的文本消息。当前仅存储 `msgType=0` 和 `msgType=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=0` 和 `msgType=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 逻辑伪代码 ```typescript async function upsertGroupChat(roomId: string, guid: string, extra: Record) { // 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.ts` 的 `schemas` 数组中新增条目,启动时自动建表。无需手动操作数据库。