---
title: 企微 Webhook Relay 模式完整配置指南
updated: 2026-07-27
project: claude-code-qiwe-assistant
mode: relay
---

# 企微 Webhook Relay 模式完整配置指南

> 本文档说明如何在 `claude-code-qiwe-assistant`（MCP 技能包）中使用**中央 Relay 模式**接收企微实时事件回调。该模式适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。

## 一、Relay 模式是什么

中央 Relay 模式是 Fmode 提供的一种 webhook 中转方案：

- 企微平台把事件推送到 Fmode 的中央 Relay 服务器（有固定公网地址）。
- 本地运行的 Skill 通过**长轮询**主动从 Relay 取回属于自己的事件。
- 事件在 Relay 端经过 RSA 公钥加密，本地用私钥解密后再处理。

### 与公网直收的区别

| 维度 | 中央 Relay | 公网直收 |
|---|---|---|
| 是否需要公网地址 | 不需要 | 需要 |
| Skill 网络要求 | 能访问公网即可 | 能被公网访问 |
| 事件到达方式 | 本地主动长轮询取回 | 企微平台被动推送 |
| 部署位置 | 本地电脑、内网、云服务器均可 | 必须有公网 IP/域名 |
| 安全性 | RSA 加密 + Tenant Secret 认证 | HMAC/Authorization 校验 |
| 实时性 | 秒级延迟（受轮询间隔影响） | 实时 |
| 适用场景 | 开发调试、本地运行、无公网 IP | 生产服务器、追求低延迟 |

### 架构图

```text
┌─────────────┐      HTTP POST      ┌──────────────────┐
│  企微平台    │ ──────────────────▶ │  Fmode 中央 Relay │
└─────────────┘                     │  (公网服务器)      │
                                    └────────┬─────────┘
                                             │
                              长轮询 /api/relay/poll
                              RSA 私钥解密
                              Tenant Secret 认证
                                             │
                                             ▼
                                    ┌──────────────────┐
                                    │ 本地 Skill       │
                                    │ claude-code-    │
                                    │ qiwei-assistant │
                                    └──────────────────┘
```

## 二、前置条件

1. 已安装并配置好 `claude-code-qiwe-assistant`。
2. 已获取 Fmode 鉴权 token（`QIWEI_AUTH_TOKEN` 或 `FMODE_API_KEY`）。
3. 已完成企微设备登录（已有 `guid`）。
4. 已从 Fmode 提供方申请到 Relay 租户凭证：
   - `TENANT_API_KEY`
   - `TENANT_API_SECRET`
   - `RELAY_PRIVATE_KEY`（RSA 私钥）
   - `RELAY_BASE_URL`（中央 Relay 公网地址）

## 三、获取 Relay 租户凭证

Relay 凭证需要向 Fmode 提供方或你的服务管理员申请。申请时通常需要提供：

- 你的 Fmode 账号/公司标识
- 预计接入的设备数量（guid 数量）
- 是否需要多个 Skill 实例共享同一个租户

申请成功后，你会拿到以下信息：

```text
RELAY_BASE_URL=http://8.138.37.248:4000
TENANT_API_KEY=qk_xxxxxxxxxxxxxxxxxxxxxxxx
TENANT_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
...
-----END PRIVATE KEY-----
```

> **安全提醒**：`TENANT_API_SECRET` 和 `RELAY_PRIVATE_KEY` 是敏感信息，不要截图传播、不要提交到 Git。

## 四、目标项目配置

### 4.1 配置方式

目标项目使用文件存储配置，所有 Relay 配置写入：

```
outputs/webhook/relay-config.json
```

你也可以通过 MCP 工具 `qiwei_relay_save_config` 写入。

### 4.2 通过工具配置（推荐）

在 Claude Code 中调用：

```json
{
  "name": "qiwei_relay_save_config",
  "arguments": {
    "relayBaseUrl": "http://8.138.37.248:4000",
    "tenantId": "你的租户ID（可选）",
    "publicKey": "对应的 RSA 公钥（可选，用于本地调试）"
  }
}
```

> 注意：当前 `qiwei_relay_save_config` 工具只保存 `relayBaseUrl`、`tenantId`、`publicKey`，**不保存 `tenantApiKey`、`tenantApiSecret`、`privateKey`**。为了安全，后三者建议写入 `.env.local` 或环境变量。

### 4.3 通过 .env.local 配置（推荐）

在目标项目根目录创建或编辑 `.env.local`：

```bash
# Fmode 鉴权 token（已有）
QIWEI_AUTH_TOKEN=sk-xxxxxxxx

# Relay 中央服务器地址
RELAY_BASE_URL=http://8.138.37.248:4000

# Relay 租户凭证
TENANT_API_KEY=qk_xxxxxxxx
TENANT_API_SECRET=xxxxxxxx

# RSA 私钥，必须写成单行，用 \n 替换真实换行符
RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----
```

> **私钥格式说明**：`.env` 文件中不能包含真实换行符，必须将 PEM 私钥中的每一行换行替换为 `\n` 字面量。程序读取时会自动还原为真实换行符（与源 Qiwei 项目 `lib/relay-config.ts` 的 `getRelayPrivateKey()` 逻辑一致）。

### 4.4 配置文件示例

`outputs/webhook/relay-config.json`：

```json
{
  "relayBaseUrl": "http://8.138.37.248:4000",
  "tenantId": "tenant_xxx",
  "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----",
  "updatedAt": "2026-07-16T08:00:00.000Z"
}
```

## 五、Relay 客户端实现

### 5.1 当前目标项目缺失的部分

目标项目已有：

- `mcp/src/core/webhook-server.js`：本地 webhook server（仅落盘）
- `mcp/src/tools/qiwei-webhook-relay-run.js`：配置读写工具

但**缺少真正的 Relay 长轮询客户端**。需要从源 Qiwei 项目迁移 `lib/relay-client.ts` 到 `mcp/src/core/relay-client.js`。

### 5.2 需要新增的 relay-client.js

核心职责：

1. 从 `.env.local` / `relay-config.json` 读取 Relay 凭证。
2. 获取本地可用设备 `guid`。
3. 长轮询 `POST {RELAY_BASE_URL}/api/relay/poll`。
4. 用 RSA 私钥解密 `encryptedPayload`。
5. 把解密后的事件喂给本地 webhook 处理逻辑。
6. ACK 已处理事件：`POST {RELAY_BASE_URL}/api/relay/ack`。
7. 失败时指数退避重连。

参考实现要点（来自源项目 `lib/relay-client.ts`）：

```js
const POLL_WAIT_MS = 30000;
const INITIAL_BACKOFF_MS = 1000;
const MAX_BACKOFF_MS = 60000;

async function runPollOnce() {
  const response = await fetch(`${RELAY_BASE_URL}/api/relay/poll`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${TENANT_API_SECRET}`
    },
    body: JSON.stringify({ guid: deviceGuid, batchSize: 100, waitMs: POLL_WAIT_MS })
  });

  const data = await response.json();
  for (const event of data.events) {
    const decrypted = decryptPayload(event.encryptedPayload, RELAY_PRIVATE_KEY);
    const payload = JSON.parse(decrypted);
    await processWebhookEvents({ code: 0, msg: 'from-relay', data: [payload] });
  }

  await ackEvents(deviceGuid, data.events.map(e => e.eventId));
}

function decryptPayload(encryptedPayload, privateKey) {
  const key = crypto.createPrivateKey(privateKey);
  const buffer = Buffer.from(encryptedPayload, 'base64');
  const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
  return decrypted.toString('utf8');
}
```

### 5.3 私钥还原

读取 `.env.local` 中的私钥时，必须做换行还原：

```js
function getRelayPrivateKey() {
  return (process.env.RELAY_PRIVATE_KEY || '').replace(/\\n/g, '\n');
}
```

这是源 Qiwei 项目 `lib/relay-config.ts` 中的关键处理，迁移时必须保留。

## 六、启动 Relay 客户端

### 6.1 启动方式选择

目标项目是 stdio MCP server，**不建议在主进程内启动长轮询**，否则会阻塞 MCP 消息循环。推荐以下方式：

#### 方案 A：独立子进程（推荐）

新增 `scripts/start-relay-client.js`：

```bash
node scripts/start-relay-client.js
```

该脚本单独运行，与 MCP server 解耦。

#### 方案 B：Dashboard 进程内启动

如果已使用 `npm run dashboard` 启动 dashboard，可在 dashboard server 启动时附带启动 Relay 客户端。

#### 方案 C：MCP 工具触发（不推荐长期运行）

通过 `qiwei_relay_connect` 工具 fork 子进程启动。这种方式会话结束后可能随 MCP server 一起退出，不够稳定。

### 6.2 推荐启动流程

```bash
# 1. 确保 .env.local 已配置 Relay 凭证
# 2. 启动 MCP server（正常对话即可）
# 3. 在另一个终端启动 Relay 客户端
node scripts/start-relay-client.js
```

或封装为 npm script：

```json
{
  "scripts": {
    "relay": "node scripts/start-relay-client.js"
  }
}
```

## 七、验证 Relay 是否正常工作

### 7.1 检查配置

调用 MCP 工具：

```json
{ "name": "qiwei_relay_config" }
```

应返回：

```json
{
  "status": "ok",
  "data": {
    "configured": true,
    "relayBaseUrl": "http://8.138.37.248:4000"
  }
}
```

### 7.2 检查 Relay 客户端日志

启动 `scripts/start-relay-client.js` 后，观察日志：

```text
[RelayClient] 启动 Relay 轮询
[RelayClient] 取回 3 条事件
[RelayClient] ACK 3 条事件
```

### 7.3 触发真实事件

让好友通过你的企微账号，或在客户群里发送一条消息。观察：

- `outputs/webhook/events/` 目录下是否生成新事件文件
- `outputs/messages/<roomId>/` 是否出现新消息文件
- 画像文件 `outputs/portraits/<externalUserId>.json` 是否被触发更新

### 7.4 常见问题排查

| 现象 | 可能原因 | 排查方法 |
|------|---------|---------|
| 轮询无事件 | 企微回调未配置到 Relay | 检查 Fmode 平台设置的回调地址是否为 Relay 的 ingest URL |
| 解密失败 | 私钥格式错误 | 确认 `.env.local` 中私钥使用 `\n` 单行存储，程序正确还原 |
| 401/403 | Tenant Secret 错误 | 检查 `TENANT_API_SECRET` 是否与 Relay 端匹配 |
| 获取不到 guid | 设备未登录 | 先调用 `qiwei_login_start` 完成扫码登录 |
| 事件处理报错 | webhook 处理逻辑未迁移 | 检查 `processWebhookEvents()` 是否正常 |

## 八、Relay 回调地址说明

当 Relay 模式启用后，企微平台侧的回调地址应配置为：

```text
{RELAY_BASE_URL}/api/webhook/ingest
```

企微回调对服务 Token 全局生效。Skill 先把真实设备 `guid` 注册到 Relay，再调用 Fmode 专用接口 `POST /relay/connect`；由 Fmode 服务端持有回调密钥并调用 `/client/setCallback`。客户端不能提交回调 URL 或签名密钥。

旧的 `/{tenantId}/{guid}` 入口仅保留兼容，不用于新部署。

## 九、安全注意事项

1. **不要把 `TENANT_API_SECRET` 和 `RELAY_PRIVATE_KEY` 提交到 Git**。目标项目 `outputs/` 已在 `.gitignore` 中，但 `.env.local` 需要自行确认是否忽略。
2. **私钥单行存储时使用 `\n` 字面量**，不要直接粘贴带真实换行的 PEM。
3. **定期轮换密钥**。如果怀疑凭证泄露，立即联系 Fmode 提供方重置。
4. **ACK 所有事件**，包括解密失败的，避免 Relay 端重复投递导致死循环。
5. **本地 webhook server 签名验证仍可保留**。即使事件来自 Relay，本地处理前也可以再做一层校验。

## 十、与源 Qiwei 项目的差异

| 源 Qiwei 项目 | 目标 MCP 项目 |
|---|---|
| `lib/relay-client.ts` | 需新增 `mcp/src/core/relay-client.js` |
| `lib/relay-config.ts` | 复用 `mcp/src/core/webhook-server.js` 的配置读写，加 `.env.local` 读取 |
| `lib/webhook-setup.ts` | 合并到 `mcp/src/tools/qiwei-webhook-relay-run.js` |
| SQLite `WebhookEvent` 表 | `outputs/webhook/events/` 文件 |
| `processWebhookEvents()` 在 `api/module/webhook/routes.ts` | 迁移到 `mcp/src/core/webhook-processor.js` |
| 启动时自动启动 Relay 客户端 | 改为独立进程 `scripts/start-relay-client.js` |

## 十一、下一步建议

1. 在目标项目创建 `mcp/src/core/relay-client.js`（从 Qiwei `lib/relay-client.ts` 迁移）。
2. 创建 `scripts/start-relay-client.js` 作为独立启动入口。
3. 增强 `qiwei_relay_connect` 工具，支持一键启动 Relay 客户端。
4. 把 `processWebhookEvents()` 和事件解析逻辑迁移到 `mcp/src/core/webhook-processor.js`。
5. 写完后按第 7 节验证清单测试。

需要我继续执行实际的代码迁移吗？
