lami-aiparse/node),使用 masterKey 绕过 ACL 权限roomId + guid 联合唯一每条记录都携带 guid(设备/账号标识),与业务主键(如 roomId)组成联合去重键。
为什么需要 guid?
企业微信平台中,一个公司可能有多个员工的企微账号接入。每个账号登录后对应一个独立的设备节点(guid)。回调数据中同时包含:
guid — 来自哪个设备/账号fromRoomId — 发生在哪个群同一个 roomId 可能被多个设备看到(比如同一个群里有多个公司的员工)。如果只按 roomId 去重,不同设备的数据会互相覆盖。
隔离策略:所有表的查询/写入都以 (roomId, guid) 或 (userId, guid) 作为唯一性约束。查询时按 guid 过滤,确保只返回属于该设备的数据。
群表和成员表不直接 insert,而是先查询是否存在,存在则更新,不存在则创建。原因:
群事件中 changedMemberList 和 base64RawData 字段是 base64 编码的字符串。解码后为分号分隔的 userId 列表。
例如:
原始 base64:MTY4ODg1MDAwMDAxOzE2ODg4NTAwMDAwMjsxNjg4ODUwMDAwMDM=
解码后: "168885000001;168885000002;168885000003"
解析结果: ["168885000001", "168885000002", "168885000003"]
存储企业微信群聊的基本信息。
| 字段 | 类型 | 说明 |
|---|---|---|
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(群名变更):更新 roomNamemsgType=1023(群解散):更新 status=dismissed去重逻辑:按 (roomId, guid) 联合查询,存在则更新已有字段,不存在则创建。
记录每个群内成员的加入/退出状态。
| 字段 | 类型 | 说明 |
|---|---|---|
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 解码后的所有成员,状态 activemsgType=1002(新增成员):解码后的成员,状态 activemsgType=1003(移除成员):解码后的成员,状态 leftmsgType=1005(退群):事件 senderId,状态 left重入群处理:如果成员之前标记为 left,收到再次入群事件时,状态恢复为 active,leftAt 清空,joinedAt 更新为新时间。
存储群内的文本消息。当前仅存储 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
└─ 日志记录(暂不入库)
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.ts 的 schemas 数组中新增条目,启动时自动建表。无需手动操作数据库。