--- title: 企微 Webhook 能力迁移指南 updated: 2026-07-27 source_project: d:\caidawork\Qiwei target_project: d:\caidawork\openclaw-voc-skill\claude-code\claude-code-qiwe-assistant --- # 企微 Webhook 能力迁移指南 > 本指导用于把 `d:\caidawork\Qiwei`(原「企微客户群运营 Agent Skill」后端服务)中**成熟运行的 Webhook 接收、解析、业务处理、Relay 长轮询**能力,迁移到 `claude-code-qiwe-assistant`(MCP 技能包)中。目标项目当前已有本地 webhook server 壳子,但只落盘事件,缺少签名验证、事件解析、自动建群、消息入库、画像触发等核心业务逻辑。 ## 一、迁移前的能力现状 ### 1.1 源项目(Qiwei)已具备的完整能力 | 能力 | 关键文件 | 说明 | |------|---------|------| | 回调接收路由 | `api/module/webhook/routes.ts` | `POST /api/webhook/callback` 入口,立即返回 200,异步处理事件 | | 签名验证 | `lib/webhook-verify.ts` | `Authorization` / `Authorization: Bearer ` 比对,兼容 HMAC-SHA256;支持 `WEBHOOK_ALLOW_UNSECURED` 开发开关 | | 事件解析 | `lib/webhook-types.ts` | v1/v2 envelope 识别,`cmd` + `msgType` → `ParsedWebhookEvent`;2131/2357/1006/15000 等事件分类 | | 自动配置回调 | `lib/webhook-setup.ts` | 旧项目启动时直调 `/client/setCallback`;迁移后改为 Fmode 专用 `/relay/connect` | | Relay 客户端 | `lib/relay-client.ts` | 长轮询 `/api/relay/poll`,RSA 私钥解密 payload,ACK 已处理事件 | | 自动建群 | `api/module/webhook/routes.ts` 调用 `lib/group-service.ts` | 好友通过(2131/2357)→ 匹配 `Customer` → 二次确认 → `autoCreateGroup` | | 群新增识别 | `api/module/webhook/routes.ts` | msgType=1006 时识别 `Broker`/`Customer`,写入 `ExternalGroup` | | 群消息入库 | `api/module/webhook/routes.ts` | `NEW_MESSAGE` 写入 `GroupMessage`,更新 `ExternalGroup.lastMsgAt`,触发画像更新 | | 配置中心 | `lib/config.ts` | `getWebhookConfig()` / `discoverPublicBaseUrl()` / `getQiweApiToken()` 等 | | 数据库 | `lib/schema.ts` + `lib/db.ts` | SQLite,含 `WebhookEvent`、`ExternalGroup`、`GroupMessage`、`Customer`、`Broker`、`WeComDevice` 等表 | ### 1.2 目标项目当前已有的 webhook 壳子 | 文件 | 现状 | 缺失 | |------|------|------| | `mcp/src/core/webhook-server.js` | 启动本地 HTTP server,把事件 JSON 写入 `outputs/webhook/` | 无签名验证、无事件解析、无业务处理 | | `mcp/src/tools/qiwei-webhook-relay-run.js` | 提供 `qiwei_webhook_server_start/auto_setup/setup/status/relay_*` 等工具 | 中央 Relay 模式通过 `/relay/connect` 接入,直连模式默认关闭 | | `mcp/src/server.js` | 已注册 9 个 webhook/relay 工具 | 工具 handler 需要增强 | ## 二、迁移总体策略 推荐分阶段迁移,**先让「接收 + 落盘 + 签名验证 + 事件解析」跑通,再逐步接入业务处理**。 ```text 阶段 1:签名验证 + 事件解析 + 结构化落盘 阶段 2:好友通过自动建群(2131/2357) 阶段 3:群新增识别与 ExternalGroup 落盘(1006) 阶段 4:群消息实时入库与画像触发(NEW_MESSAGE) 阶段 5:Relay 长轮询客户端接入 阶段 6:删掉/归档源项目重复代码,目标项目成为主入口 ``` > 目标项目没有 SQLite,业务数据以文件形式存在 `outputs/`。迁移时需要把源项目的数据库操作改为文件读写,并遵循 `docs/OUTPUT-STANDARD.md`。 ## 三、核心文件迁移清单 ### 3.1 必须迁移/重写的文件 | 源文件 | 目标路径建议 | 迁移要点 | |--------|-------------|---------| | `lib/webhook-types.ts` | `mcp/src/core/webhook-types.js` | 类型改为普通 JS 对象/枚举;保留 v1/v2 envelope 解析、`normalizeItems`、`ParsedWebhookEvent` | | `lib/webhook-verify.ts` | `mcp/src/core/webhook-verify.js` | 签名验证逻辑直接平移;从 `webhook-config.json` 读 `secret` | | `lib/webhook-setup.ts` | 合并进 `mcp/src/tools/qiwei-webhook-relay-run.js` | 中央模式注册设备后调用 Fmode `/relay/connect`;只有隔离部署可显式开启直连 | | `lib/relay-client.ts` | `mcp/src/core/relay-client.js` | TypeScript → JavaScript;长轮询、RSA 解密、ACK | | `api/module/webhook/routes.ts` 中的处理逻辑 | 拆分为 `mcp/src/core/webhook-processor.js` | 好友通过检查、自动建群、群新增识别、消息入库 | | `lib/group-service.ts` | `mcp/src/core/group-service.js` | `autoCreateGroup`、`checkFriendConfirmed` 等;调用 Fmode 网关 | | `lib/room-sync.ts` | 按需迁移到 `mcp/src/core/group-store.js` | 群列表同步、成员识别 | | `lib/portrait-service.ts` | 复用/扩展 `mcp/src/tools/qiwei-portrait-tags-run.js` | 画像触发入口 | ### 3.2 不需要迁移但要参考的规范 - `lib/config.ts`:目标项目用 `credentials.js` + `.env` 管理鉴权,用 `webhook-config.json` 管理回调配置,不需要整个配置中心。 - `lib/schema.ts`:目标项目没有 SQLite,不需要建表脚本。但要把源项目的表结构映射为 `outputs/` 下的文件结构(见第 6 节)。 ## 四、事件解析迁移要点 ### 4.1 v1/v2 envelope 兼容 源项目 `lib/webhook-types.ts` 的 `parseWebhookEnvelope(body)` 已经同时支持: - v1: `{ code: 0, data: [EventItem], msg: "成功" }` - v2: `{ event: "msg.group", version: "2.0", data: {...}, meta: {...} }` 迁移时保留该函数签名,输出统一为 `NormalizedWebhookEvent[]`。 ### 4.2 关键事件类型 ```js // 来自源项目 lib/webhook-types.ts const ParsedWebhookEvent = { CONTACT_ADDED_OR_CHANGED: 'CONTACT_ADDED_OR_CHANGED', // 2131 外部联系人变动 FRIEND_REQUEST_RECEIVED: 'FRIEND_REQUEST_RECEIVED', // 2357 好友申请通知 ACCOUNT_ONLINE: 'ACCOUNT_ONLINE', ACCOUNT_OFFLINE: 'ACCOUNT_OFFLINE', GROUP_MEMBER_JOINED: 'GROUP_MEMBER_JOINED', GROUP_CREATED: 'GROUP_CREATED', // 1006 群新增 GROUP_EVENT: 'GROUP_EVENT', NEW_MESSAGE: 'NEW_MESSAGE', // 普通群消息 UNKNOWN: 'UNKNOWN' }; ``` ### 4.3 事件去重 源项目使用 `WebhookEvent.eventId`(来自 `msgUniqueIdentifier` 或生成)去重。目标项目没有数据库,去重方式可选: - **方案 A(推荐)**:在 `outputs/webhook/event-id-set.json` 中维护最近 N 条已处理 `eventId` 的集合(LRU 或按日期分片)。 - **方案 B**:按 `outputs/webhook//-callback/` 目录 + 文件名携带 `eventId` 做幂等,处理前检查文件是否存在。 ## 五、签名验证迁移要点 ### 5.1 当前目标项目的风险 `mcp/src/core/webhook-server.js` 直接解析并落盘,**没有验证签名**。生产环境任何人都可以向本地端口灌数据。 ### 5.2 必须接入的验证逻辑 把 `lib/webhook-verify.ts` 的核心策略平移到 `mcp/src/core/webhook-verify.js`: 1. 读取 `outputs/webhook/webhook-config.json` 中的 `secret`。 2. 从 `Authorization` header 提取签名;兼容 `Authorization: ` 和 `Authorization: Bearer `。 3. 未配置 `secret` 时默认拒绝(开发环境可通过 `QIWEI_WEBHOOK_ALLOW_UNSECURED=true` 放行)。 4. 兼容 HMAC-SHA256 防御性校验。 5. 使用 `crypto.timingSafeEqual` 防止时序攻击。 ### 5.3 rawBody 捕获 目标项目用原生 `http` 模块,`readBody(req)` 已经把 body 读成字符串,可直接用于签名验证。注意:**验证前不要对 body 做 JSON.stringify**,否则 key 顺序/空格变化会导致签名失败。 ## 六、数据存储改造(SQLite → 文件) ### 6.1 文件结构映射 目标项目统一用 `outputs/` 存运行时数据。建议新增/复用以下类别: | 原 SQLite 表 | 目标文件/目录 | 说明 | |-------------|--------------|------| | `WebhookEvent` | `outputs/webhook/events//-.json` | 每条事件一个文件;保留 `status`、`parsedType`、`rawBody` | | `Customer` | `outputs/customers/.json` | 客户档案文件 | | `Broker` | `outputs/brokers/.json` | 顾问/经纪人档案 | | `ExternalGroup` | `outputs/groups/confirmed-mapping.json` + `outputs/groups/imported-mapping.json` | 复用目标项目已有映射文件 | | `GroupMessage` | `outputs/messages//-.json` | 复用目标项目已有的消息目录 | | `CustomerPortrait` | `outputs/portraits/.json` | 复用目标项目已有画像文件 | > 新增 `outputs/` 类别前,先在 `mcp/src/core/output-paths.js` 的 `OUTPUT_CATEGORIES` 注册,并更新 `docs/OUTPUT-STANDARD.md`。 ### 6.2 WebhookEvent 落盘规范 参考源项目 `logWebhookEvent()`,目标项目每条事件文件至少包含: ```json { "eventId": "...", "guid": "...", "cmd": 15500, "msgType": 2131, "parsedType": "CONTACT_ADDED_OR_CHANGED", "externalUserId": "...", "status": "PENDING", "receivedAt": "2026-07-16T08:00:00.000Z", "processedAt": null, "result": null, "rawBody": { ... } } ``` 处理完成后把 `status` 更新为 `PROCESSED` / `IGNORED` / `ERROR` / `AUTO_GROUP_CREATED`,并写入 `processedAt` 和 `result`。 ## 七、业务处理迁移要点 ### 7.1 好友通过自动建群(2131 / 2357) 源项目逻辑在 `api/module/webhook/routes.ts` 的 `triggerAutoCreateGroup()`: 1. 从事件提取 `externalUserId`;2131 没有时遍历在线设备调用 `getWxContactList` 查找。 2. 匹配 `Customer`。 3. 二次确认 `checkFriendConfirmed(deviceGuid, { externalUserId, phone, name })`。 4. 调用 `updateWxContact` 设置客户备注(姓名 + 电话)。 5. 调用 `autoCreateGroup({ brokerId, customerId, supportBrokerId, guid, skipFriendCheck: true })`。 6. 写入 `InteractionTimeline`。 迁移到目标项目时: - 用 `outputs/customers/` 文件替换 `Customer` 表查询。 - 用 `outputs/brokers/` 文件替换 `Broker` 表查询。 - 用 `gatewayCall(ctx, '/contact/getExternalContactList', { guid })` 或 `/contact/searchContact` 替代 `qiweapi.getWxContactList`。 - 用 `qiweiAutoCreateGroup` 工具内部逻辑或 `mcp/src/core/group-service.js` 替代 `lib/group-service.ts`。 - 如果目标项目的 `qiwei_auto_create_group` 已可用,直接复用,不要重写。 ### 7.2 群新增识别(1006) 源项目 `handleGroupCreateWebhook()` 逻辑: 1. 检查 `ExternalGroup` 是否已存在该 `roomId`。 2. 通过 `senderId` → `Broker.wecomUserId` 识别经纪人;失败则通过 `guid` 回退。 3. 从 `changedMemberList` 解码成员,排除经纪人后匹配 `Customer.externalUserId`。 4. 写入 `ExternalGroup`(ACTIVE / IMPORTED / orphan)。 迁移到目标项目: - 复用 `outputs/groups/confirmed-mapping.json` 和 `imported-mapping.json`。 - `decodeChangedMemberList()` 函数从 `lib/webhook-types.ts` 迁移到 `mcp/src/core/webhook-types.js`。 - 经纪人识别可通过 `outputs/brokers/.json` 中的 `wecomUserId` 字段匹配。 ### 7.3 群消息实时入库(NEW_MESSAGE) 源项目 `storeGroupMessageFromWebhook()` 逻辑: 1. 只处理 `fromRoomId` 非空的群消息。 2. 只保存已记录在 `ExternalGroup` 的群。 3. 按 `msgUniqueIdentifier` 去重。 4. 提取 `content`,识别 `senderType`。 5. 语音消息调用 `processVoiceMessage()`。 6. 写入 `GroupMessage`;更新 `ExternalGroup.lastMsgAt`。 7. 触发画像更新任务。 迁移到目标项目: - 复用 `outputs/messages//` 目录。 - 复用 `qiweiTranscribeVoice` 工具处理语音。 - 复用 `qiweiPrepareCustomerPortrait` / `qiweiUpdateCustomerPortrait` 触发画像更新。 ## 八、Relay 模式迁移要点 ### 8.1 当前目标项目 Relay 状态 `qiwei-webhook-relay-run.js` 只有配置读写工具,没有真正的长轮询客户端。 ### 8.2 需要接入的完整逻辑 把 `lib/relay-client.ts` 迁移为 `mcp/src/core/relay-client.js`: 1. 从 `outputs/webhook/relay-config.json` 读取 `relayBaseUrl`、`tenantApiKey`、`tenantApiSecret`、`privateKey`。 2. 长轮询 `POST /api/relay/poll`(参考源项目 `runPollOnce`)。 3. RSA 私钥解密事件 payload(注意 `.env` 中 `\n` 需还原为真实换行,与源项目 `getRelayPrivateKey()` 一致)。 4. 调用 ACK `/api/relay/ack`。 5. 把解密后的事件喂给 `processWebhookEvents()`(复用本地 webhook 处理逻辑)。 6. 指数退避重连。 ### 8.3 启动时机 目标项目是 MCP server(stdio 长连接),**不建议在 stdio 主进程内启动长轮询**,否则可能阻塞 MCP 消息循环。可选方案: - **方案 A**:把 Relay 客户端做成独立子进程(`scripts/start-relay-client.js`),由用户显式启动。 - **方案 B**:在 `qiwei_relay_connect` 工具内部 `fork` 子进程启动轮询,主进程立即返回。 - **方案 C**:如果迁移后目标项目也提供 HTTP dashboard(`scripts/start-dashboard.js` 已有),在 dashboard 进程内启动 Relay 客户端。 推荐 **方案 A 或 C**,保持 MCP server 本身轻量。 ## 九、工具注册与参数规范 `mcp/src/server.js` 已经注册了 9 个 webhook/relay 工具。迁移后需要增强以下工具的行为: | 工具 | 当前行为 | 迁移后行为 | |------|---------|-----------| | `qiwei_webhook_server_start` | 启动 server,落盘事件 | 启动 server,**先验证签名**,再解析并结构化落盘 | | `qiwei_webhook_auto_setup` | 旧实现直调 `/client/setCallback` | 注册设备并调用 Fmode `/relay/connect`,客户端不接触全局签名密钥 | | `qiwei_webhook_setup` | 旧实现直调 `/client/setCallback` | 仅隔离部署且设置 `QIWEI_ALLOW_DIRECT_CALLBACK=true` 时允许 | | `qiwei_webhook_status` | 返回 server 状态 | 增加 `lastReceivedAt`、`lastProcessedType`、`pendingCount`、`relayRunning` 等 | | `qiwei_relay_connect` | 仅检查配置 | 实际启动 Relay 长轮询(子进程或后台 worker) | 新增工具建议: - `qiwei_webhook_replay`:重放某条 `WebhookEvent` 文件,用于调试。 - `qiwei_webhook_purge`:清理 `outputs/webhook/` 过期事件(保留最近 30 天)。 ## 十、配置项映射 ### 10.1 源项目 `.env.example` → 目标项目 | 源项目变量 | 目标项目建议 | 说明 | |-----------|-------------|------| | `WEBHOOK_ENABLED` | `QIWEI_WEBHOOK_ENABLED` | 是否启用 webhook 工具 | | `WEBHOOK_BASE_URL` | 无需环境变量 | 目标项目通过 `qiwei_webhook_auto_setup` 时传入,或自动发现 | | `WEBHOOK_AUTH_SECRET` | 写入 `outputs/webhook/webhook-config.json` 的 `secret` | 不要放 `.env`,避免泄露 | | `WEBHOOK_AUTO_TUNNEL` | 无需环境变量 | 目标项目 `auto_setup` 时由用户决定是否启动本地 server | | `WEBHOOK_ALLOW_UNSECURED` | `QIWEI_WEBHOOK_ALLOW_UNSECURED` | 仅开发环境 | | `RELAY_BASE_URL` | 写入 `outputs/webhook/relay-config.json` | 同上 | | `TENANT_API_KEY` | 写入 `outputs/webhook/relay-config.json` | 同上 | | `TENANT_API_SECRET` | 写入 `outputs/webhook/relay-config.json` | 同上 | | `RELAY_PRIVATE_KEY` | 写入 `outputs/webhook/relay-config.json` | 注意单行 `\n` 存储 | ### 10.2 目标项目已有配置 - `QIWEI_AUTH_TOKEN` / `FMODE_API_KEY` / `FMODE_API_TOKEN`:已由 `mcp/src/core/credentials.js` 统一管理,迁移时直接复用。 - `QIWEI_UID`:已由 `credentials.js` 的 `ensureQiweiUid()` 管理,迁移时复用。 - `QIWEI_API_BASE`:默认 `https://server.fmode.cn/api/qiwei`,复用。 ## 十一、代码规范 1. **不要直接复制 TypeScript 文件**。目标项目是 CommonJS + JavaScript,迁移时需改语法:去掉类型注解、接口改为 JSDoc、默认导出改为 `module.exports`。 2. **敏感信息不落盘到 outputs 明文文件**。`secret`、`token`、`privateKey` 必须脱敏;参考 `fmode-wecom-gateway.js` 的 `redactSecret()`。 3. **遵循 `docs/OUTPUT-STANDARD.md`**。所有运行时数据进 `outputs/`,新增类别先注册 `OUTPUT_CATEGORIES`。 4. **不要阻塞 MCP stdio 主进程**。HTTP server 可运行在主进程(`127.0.0.1`),但 Relay 长轮询建议拆到子进程/dashboard。 5. **错误处理用 `safeResult` 包装**。新增工具函数都要通过 `shared-gateway.js` 的 `safeResult()` 或类似方式捕获异常。 6. **优先复用已有工具**。`qiwei_auto_create_group`、`qiwei_sync_group_messages`、`qiwei_update_customer_portrait`、`qiwei_transcribe_voice` 已存在,不要重写。 7. **保持通用化**。源项目有「经纪人/客户/房产」术语,目标项目已改为「顾问/客户」,迁移时不要把业务术语改回去。 8. **事件文件命名用 kebab-case + UTC 时间戳**。例如 `event-20260716-080000-abc123.json`。 ## 十二、测试验证清单 迁移完成后,按以下顺序验证: 1. `qiwei_webhook_server_start` 启动本地 server。 2. `qiwei_webhook_auto_setup` 配置回调地址到 Fmode 网关。 3. 在 Fmode 平台手动触发一条好友通过事件,或等待真实事件。 4. 检查 `outputs/webhook/events/` 下事件文件是否生成,且 `status` 正确。 5. 检查签名验证:用错误 secret POST 一条事件,应返回 401。 6. 检查自动建群:准备一条 2357 事件 payload,事件文件最终状态应为 `AUTO_GROUP_CREATED`。 7. 检查群消息:发送一条群消息,确认 `outputs/messages//` 出现对应文件。 8. 检查 Relay:配置 relay 后启动独立客户端,确认能取回并处理事件。 ## 十三、常见坑 1. **签名验证失败最常见原因**:目标项目 `readBody` 用 `JSON.parse` 后再 `JSON.stringify` 验证。必须保存原始字符串用于 HMAC。 2. **guid 为空**:2131 事件有时没有 `guid`,需要遍历在线设备或从 Relay payload 里取 `deviceGuid`。 3. **`\n` 私钥问题**:Relay 私钥在 `.env` 或 JSON 中按单行 `\n` 存储,读取后必须 `.replace(/\\n/g, '\n')`。 4. **多设备冲突**:源项目优先用 `broker.storeId` 找在线设备,目标项目没有 `Store` 概念,可简化为优先用事件 `guid`,其次用任意在线 `guid`。 5. **v2 事件**:未来 Fmode 网关可能推送 v2 格式,必须保留 `parseWebhookEnvelope` 的 v2 分支。 6. **MCP server 退出**:stdio MCP server 退出时本地 webhook server 也会关闭。若需要持久接收回调,应使用 dashboard 进程或独立进程。 ## 十四、后续迭代建议 - 把 webhook 处理进度暴露为 dashboard 页面(`mcp/src/dashboard/`)。 - 增加 webhook 事件检索工具 `qiwei_webhook_search`(按日期、类型、状态过滤)。 - 把「好友通过自动建群」做成可开关配置,写入 `outputs/webhook/webhook-config.json`。 - 增加 webhook 事件统计(每小时/每天接收量、成功率)。