title: 企微 Webhook 能力迁移指南 updated: 2026-07-27 source_project: d:\caidawork\Qiwei
本指导用于把
d:\caidawork\Qiwei(原「企微客户群运营 Agent Skill」后端服务)中成熟运行的 Webhook 接收、解析、业务处理、Relay 长轮询能力,迁移到claude-code-qiwe-assistant(MCP 技能包)中。目标项目当前已有本地 webhook server 壳子,但只落盘事件,缺少签名验证、事件解析、自动建群、消息入库、画像触发等核心业务逻辑。
| 能力 | 关键文件 | 说明 |
|---|---|---|
| 回调接收路由 | 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 + 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 等表 |
| 文件 | 现状 | 缺失 |
|---|---|---|
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。
| 源文件 | 目标路径建议 | 迁移要点 |
|---|---|---|
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 |
画像触发入口 |
lib/config.ts:目标项目用 credentials.js + .env 管理鉴权,用 webhook-config.json 管理回调配置,不需要整个配置中心。lib/schema.ts:目标项目没有 SQLite,不需要建表脚本。但要把源项目的表结构映射为 outputs/ 下的文件结构(见第 6 节)。源项目 lib/webhook-types.ts 的 parseWebhookEnvelope(body) 已经同时支持:
{ code: 0, data: [EventItem], msg: "成功" }{ event: "msg.group", version: "2.0", data: {...}, meta: {...} }迁移时保留该函数签名,输出统一为 NormalizedWebhookEvent[]。
// 来自源项目 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'
};
源项目使用 WebhookEvent.eventId(来自 msgUniqueIdentifier 或生成)去重。目标项目没有数据库,去重方式可选:
outputs/webhook/event-id-set.json 中维护最近 N 条已处理 eventId 的集合(LRU 或按日期分片)。outputs/webhook/<YYYY-MM-DD>/<HHmmss>-callback/ 目录 + 文件名携带 eventId 做幂等,处理前检查文件是否存在。mcp/src/core/webhook-server.js 直接解析并落盘,没有验证签名。生产环境任何人都可以向本地端口灌数据。
把 lib/webhook-verify.ts 的核心策略平移到 mcp/src/core/webhook-verify.js:
outputs/webhook/webhook-config.json 中的 secret。Authorization header 提取签名;兼容 Authorization: <secret> 和 Authorization: Bearer <secret>。secret 时默认拒绝(开发环境可通过 QIWEI_WEBHOOK_ALLOW_UNSECURED=true 放行)。crypto.timingSafeEqual 防止时序攻击。目标项目用原生 http 模块,readBody(req) 已经把 body 读成字符串,可直接用于签名验证。注意:验证前不要对 body 做 JSON.stringify,否则 key 顺序/空格变化会导致签名失败。
目标项目统一用 outputs/ 存运行时数据。建议新增/复用以下类别:
| 原 SQLite 表 | 目标文件/目录 | 说明 |
|---|---|---|
WebhookEvent |
outputs/webhook/events/<YYYY-MM-DD>/<HHmmss>-<eventId>.json |
每条事件一个文件;保留 status、parsedType、rawBody |
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.js的OUTPUT_CATEGORIES注册,并更新docs/OUTPUT-STANDARD.md。
参考源项目 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,并写入 processedAt 和 result。
源项目逻辑在 api/module/webhook/routes.ts 的 triggerAutoCreateGroup():
externalUserId;2131 没有时遍历在线设备调用 getWxContactList 查找。Customer。checkFriendConfirmed(deviceGuid, { externalUserId, phone, name })。updateWxContact 设置客户备注(姓名 + 电话)。autoCreateGroup({ brokerId, customerId, supportBrokerId, guid, skipFriendCheck: true })。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 已可用,直接复用,不要重写。源项目 handleGroupCreateWebhook() 逻辑:
ExternalGroup 是否已存在该 roomId。senderId → Broker.wecomUserId 识别经纪人;失败则通过 guid 回退。changedMemberList 解码成员,排除经纪人后匹配 Customer.externalUserId。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/<brokerUserId>.json 中的 wecomUserId 字段匹配。源项目 storeGroupMessageFromWebhook() 逻辑:
fromRoomId 非空的群消息。ExternalGroup 的群。msgUniqueIdentifier 去重。content,识别 senderType。processVoiceMessage()。GroupMessage;更新 ExternalGroup.lastMsgAt。迁移到目标项目:
outputs/messages/<roomId>/ 目录。qiweiTranscribeVoice 工具处理语音。qiweiPrepareCustomerPortrait / qiweiUpdateCustomerPortrait 触发画像更新。qiwei-webhook-relay-run.js 只有配置读写工具,没有真正的长轮询客户端。
把 lib/relay-client.ts 迁移为 mcp/src/core/relay-client.js:
outputs/webhook/relay-config.json 读取 relayBaseUrl、tenantApiKey、tenantApiSecret、privateKey。POST /api/relay/poll(参考源项目 runPollOnce)。.env 中 \n 需还原为真实换行,与源项目 getRelayPrivateKey() 一致)。/api/relay/ack。processWebhookEvents()(复用本地 webhook 处理逻辑)。目标项目是 MCP server(stdio 长连接),不建议在 stdio 主进程内启动长轮询,否则可能阻塞 MCP 消息循环。可选方案:
scripts/start-relay-client.js),由用户显式启动。qiwei_relay_connect 工具内部 fork 子进程启动轮询,主进程立即返回。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 天)。.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 存储 |
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,复用。module.exports。secret、token、privateKey 必须脱敏;参考 fmode-wecom-gateway.js 的 redactSecret()。docs/OUTPUT-STANDARD.md。所有运行时数据进 outputs/,新增类别先注册 OUTPUT_CATEGORIES。127.0.0.1),但 Relay 长轮询建议拆到子进程/dashboard。safeResult 包装。新增工具函数都要通过 shared-gateway.js 的 safeResult() 或类似方式捕获异常。qiwei_auto_create_group、qiwei_sync_group_messages、qiwei_update_customer_portrait、qiwei_transcribe_voice 已存在,不要重写。event-20260716-080000-abc123.json。迁移完成后,按以下顺序验证:
qiwei_webhook_server_start 启动本地 server。qiwei_webhook_auto_setup 配置回调地址到 Fmode 网关。outputs/webhook/events/ 下事件文件是否生成,且 status 正确。AUTO_GROUP_CREATED。outputs/messages/<roomId>/ 出现对应文件。readBody 用 JSON.parse 后再 JSON.stringify 验证。必须保存原始字符串用于 HMAC。guid,需要遍历在线设备或从 Relay payload 里取 deviceGuid。\n 私钥问题:Relay 私钥在 .env 或 JSON 中按单行 \n 存储,读取后必须 .replace(/\\n/g, '\n')。broker.storeId 找在线设备,目标项目没有 Store 概念,可简化为优先用事件 guid,其次用任意在线 guid。parseWebhookEnvelope 的 v2 分支。mcp/src/dashboard/)。qiwei_webhook_search(按日期、类型、状态过滤)。outputs/webhook/webhook-config.json。