qiwei-webhook-migration-guide.md 18 KB


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 <secret> 比对,兼容 HMAC-SHA256;支持 WEBHOOK_ALLOW_UNSECURED 开发开关
事件解析 lib/webhook-types.ts v1/v2 envelope 识别,cmd + msgTypeParsedWebhookEvent;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,含 WebhookEventExternalGroupGroupMessageCustomerBrokerWeComDevice 等表

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 需要增强

二、迁移总体策略

推荐分阶段迁移,先让「接收 + 落盘 + 签名验证 + 事件解析」跑通,再逐步接入业务处理

阶段 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 解析、normalizeItemsParsedWebhookEvent
lib/webhook-verify.ts mcp/src/core/webhook-verify.js 签名验证逻辑直接平移;从 webhook-config.jsonsecret
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 autoCreateGroupcheckFriendConfirmed 等;调用 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.tsparseWebhookEnvelope(body) 已经同时支持:

  • v1: { code: 0, data: [EventItem], msg: "成功" }
  • v2: { event: "msg.group", version: "2.0", data: {...}, meta: {...} }

迁移时保留该函数签名,输出统一为 NormalizedWebhookEvent[]

4.2 关键事件类型

// 来自源项目 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/<YYYY-MM-DD>/<HHmmss>-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: <secret>Authorization: Bearer <secret>
  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/<YYYY-MM-DD>/<HHmmss>-<eventId>.json 每条事件一个文件;保留 statusparsedTyperawBody
Customer outputs/customers/<externalUserId or phone>.json 客户档案文件
Broker outputs/brokers/<brokerUserId>.json 顾问/经纪人档案
ExternalGroup outputs/groups/confirmed-mapping.json + outputs/groups/imported-mapping.json 复用目标项目已有映射文件
GroupMessage outputs/messages/<roomId>/<seq>-<msgUniqueId>.json 复用目标项目已有的消息目录
CustomerPortrait outputs/portraits/<externalUserId>.json 复用目标项目已有画像文件

新增 outputs/ 类别前,先在 mcp/src/core/output-paths.jsOUTPUT_CATEGORIES 注册,并更新 docs/OUTPUT-STANDARD.md

6.2 WebhookEvent 落盘规范

参考源项目 logWebhookEvent(),目标项目每条事件文件至少包含:

{
  "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,并写入 processedAtresult

七、业务处理迁移要点

7.1 好友通过自动建群(2131 / 2357)

源项目逻辑在 api/module/webhook/routes.tstriggerAutoCreateGroup()

  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. 通过 senderIdBroker.wecomUserId 识别经纪人;失败则通过 guid 回退。
  3. changedMemberList 解码成员,排除经纪人后匹配 Customer.externalUserId
  4. 写入 ExternalGroup(ACTIVE / IMPORTED / orphan)。

迁移到目标项目:

  • 复用 outputs/groups/confirmed-mapping.jsonimported-mapping.json
  • decodeChangedMemberList() 函数从 lib/webhook-types.ts 迁移到 mcp/src/core/webhook-types.js
  • 经纪人识别可通过 outputs/brokers/<brokerUserId>.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/<roomId>/ 目录。
  • 复用 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 读取 relayBaseUrltenantApiKeytenantApiSecretprivateKey
  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 状态 增加 lastReceivedAtlastProcessedTypependingCountrelayRunning
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.jsonsecret 不要放 .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.jsensureQiweiUid() 管理,迁移时复用。
  • QIWEI_API_BASE:默认 https://server.fmode.cn/api/qiwei,复用。

十一、代码规范

  1. 不要直接复制 TypeScript 文件。目标项目是 CommonJS + JavaScript,迁移时需改语法:去掉类型注解、接口改为 JSDoc、默认导出改为 module.exports
  2. 敏感信息不落盘到 outputs 明文文件secrettokenprivateKey 必须脱敏;参考 fmode-wecom-gateway.jsredactSecret()
  3. 遵循 docs/OUTPUT-STANDARD.md。所有运行时数据进 outputs/,新增类别先注册 OUTPUT_CATEGORIES
  4. 不要阻塞 MCP stdio 主进程。HTTP server 可运行在主进程(127.0.0.1),但 Relay 长轮询建议拆到子进程/dashboard。
  5. 错误处理用 safeResult 包装。新增工具函数都要通过 shared-gateway.jssafeResult() 或类似方式捕获异常。
  6. 优先复用已有工具qiwei_auto_create_groupqiwei_sync_group_messagesqiwei_update_customer_portraitqiwei_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/<roomId>/ 出现对应文件。
  8. 检查 Relay:配置 relay 后启动独立客户端,确认能取回并处理事件。

十三、常见坑

  1. 签名验证失败最常见原因:目标项目 readBodyJSON.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 事件统计(每小时/每天接收量、成功率)。