---
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` + `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/<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` | 每条事件一个文件；保留 `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`。

### 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/<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` 读取 `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/<roomId>/` 出现对应文件。
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 事件统计（每小时/每天接收量、成功率）。
