---
title: 在 Fmode 现有 Relay 上新增租户自助注册接口的实施计划
updated: 2026-07-27
project: claude-code-qiwe-assistant
scope: server-side-relay
status: implemented
---

# 在 Fmode 现有 Relay 上新增租户自助注册接口的实施计划

> 本计划用于指导其他会话在 Fmode 现有 Relay 服务端（`8.138.37.248`）上新增一个租户自助注册接口。接口上线后，用户可通过调用该接口获取自己的 Relay 凭证，实现「一键开通 Relay」。

## 一、目标

在现有 Relay 服务端新增：

1. `POST /api/tenant/register`：用户凭 Fmode token 自助注册 Relay 租户，返回完整凭证。
2. 配套的数据库/文件存储结构，保存租户与 RSA 公钥。
3. 鉴权与风控机制，防止滥用。

用户拿到返回的凭证后，可直接配置到本地 Skill：

```text
RELAY_BASE_URL=http://8.138.37.248:4000
TENANT_API_KEY=qk_xxx
TENANT_API_SECRET=xxx
RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...
```

## 二、前提条件

⚠️ **执行本计划前必须确认**：

1. 拥有 Relay 服务端所在服务器（`8.138.37.248`）的 SSH/远程访问权限。
2. 拥有 Relay 服务源代码的读取和修改权限。
3. 拥有重新部署/重启 Relay 服务的权限。
4. 了解当前 Relay 服务的技术栈（Node.js/Python/Go 等）。
5. 拥有数据库修改权限（如果 Relay 使用数据库存储租户信息）。

如果以上任一条件不满足，应放弃本计划，改用「自建独立 Relay 服务端」方案。

## 三、当前 Relay 服务端现状（基于 Qiwei 项目反推）

从 Qiwei 项目配置可推断当前 Relay 服务端已具备以下能力：

- 接收企微回调：`POST /api/webhook/ingest/:tenantId/:guid`
- 长轮询取事件：`POST /api/relay/poll`
- ACK 事件：`POST /api/relay/ack`
- 租户鉴权：通过 `Authorization: Bearer <TENANT_API_SECRET>`
- 事件加密：使用 RSA 公钥加密，本地私钥解密

但缺少：

- 租户自助注册接口
- 用户通过 Fmode token 自动开户的能力

## 四、接口设计

### 4.1 新增接口：`POST /api/tenant/register`

**功能**：用户使用 Fmode token 申请开通 Relay 租户，服务端生成凭证并返回。

**请求头**：

```http
POST /api/tenant/register
Content-Type: application/json
Authorization: Bearer <Fmode token>
```

**请求体**（可选）：

```json
{
  "description": "张三的本地 Skill",
  "deviceGuid": "可选，预注册设备"
}
```

**响应体**：

```json
{
  "success": true,
  "relayBaseUrl": "http://8.138.37.248:4000",
  "tenantId": "tenant_550e8400e29b41d4a716446655440000",
  "apiKey": "qk_d91470c99a3075afd9b582453ee2590d",
  "apiSecret": "afea80574a9359ed2499967d11f3c7c2c8906b3d239e2d02e6e9a24b2478d178",
  "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----",
  "createdAt": "2026-07-16T08:00:00.000Z"
}
```

**错误响应**：

```json
{
  "success": false,
  "error": "Fmode token 无效",
  "code": "INVALID_TOKEN"
}
```

### 4.2 新增接口：`GET /api/tenant/status`

**功能**：用户查询自己租户的状态和已用配额。

```http
GET /api/tenant/status
Authorization: Bearer <TENANT_API_SECRET>
```

**响应**：

```json
{
  "success": true,
  "tenantId": "tenant_xxx",
  "createdAt": "2026-07-16T08:00:00.000Z",
  "eventCount24h": 1280,
  "pendingEventCount": 3
}
```

### 4.3 现有接口增强

- `/api/webhook/ingest/:tenantId/:guid`：保持不变，继续接收企微回调。
- `/api/relay/poll`：保持不变，继续使用 `TENANT_API_SECRET` 鉴权。
- `/api/relay/ack`：保持不变。

## 五、数据库/存储变更

### 5.1 租户表（新增/扩展）

如果当前 Relay 已有租户表，增加字段；如果没有，新建表。

```sql
CREATE TABLE relay_tenants (
  tenant_id TEXT PRIMARY KEY,
  api_key TEXT UNIQUE NOT NULL,
  api_secret TEXT NOT NULL,           -- 生产环境建议存哈希
  api_secret_hash TEXT NOT NULL,      -- bcrypt/scrypt 哈希
  public_key TEXT NOT NULL,           -- RSA 公钥，用于加密事件
  fmode_user_id TEXT,                 -- 关联 Fmode 用户/公司（用于风控）
  description TEXT,
  max_devices INTEGER DEFAULT 10,     -- 最多绑定设备数
  daily_event_limit INTEGER DEFAULT 100000,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW(),
  is_active BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_relay_tenants_api_key ON relay_tenants(api_key);
CREATE INDEX idx_relay_tenants_fmode_user_id ON relay_tenants(fmode_user_id);
```

### 5.2 事件队列表

如果当前 Relay 使用文件队列，可保持不变；如果升级为数据库：

```sql
CREATE TABLE relay_events (
  event_id TEXT PRIMARY KEY,
  tenant_id TEXT NOT NULL REFERENCES relay_tenants(tenant_id),
  device_guid TEXT,
  payload TEXT NOT NULL,              -- 事件原始 JSON
  encrypted_payload TEXT,             -- 可选：预加密存储
  received_at TIMESTAMP DEFAULT NOW(),
  acked_at TIMESTAMP,
  is_acked BOOLEAN DEFAULT FALSE
);

CREATE INDEX idx_relay_events_tenant_acked ON relay_events(tenant_id, is_acked);
CREATE INDEX idx_relay_events_device ON relay_events(tenant_id, device_guid);
```

## 六、实施步骤

### 步骤 1：备份现有 Relay 服务

1. 登录 `8.138.37.248` 服务器。
2. 备份当前 Relay 代码目录。
3. 备份当前数据库/租户数据。
4. 记录当前部署脚本和进程管理配置（PM2/systemd）。

### 步骤 2：修改 Relay 服务端代码

新增或修改以下模块：

#### 6.2.1 租户生成模块

```js
// relay-server/src/tenant-service.js
const crypto = require('crypto');

function generateTenant(fmodeUserId, description) {
  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
    modulusLength: 2048,
    publicKeyEncoding: { type: 'spki', format: 'pem' },
    privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
  });

  const tenantId = `tenant_${crypto.randomUUID().replace(/-/g, '')}`;
  const apiKey = `qk_${crypto.randomBytes(16).toString('hex')}`;
  const apiSecret = crypto.randomBytes(32).toString('hex');

  return {
    tenantId,
    apiKey,
    apiSecret,          // 明文返回给用户，只出现一次
    apiSecretHash: hashSecret(apiSecret), // 服务端存哈希
    publicKey,
    privateKey,         // 只返回给用户，服务端不存
    fmodeUserId,
    description,
    createdAt: new Date().toISOString()
  };
}

function hashSecret(secret) {
  return crypto.createHash('sha256').update(secret).digest('hex');
  // 或更安全的 bcrypt：require('bcrypt').hashSync(secret, 10)
}

module.exports = { generateTenant, hashSecret };
```

#### 6.2.2 Fmode token 校验模块

```js
// relay-server/src/fmode-auth.js
async function verifyFmodeToken(token) {
  // 方式 A：调 Fmode 网关的 /user/info 或 /validate 接口
  const response = await fetch('https://server.fmode.cn/api/auth/validate', {
    method: 'GET',
    headers: { 'Authorization': `Bearer ${token}` }
  });

  if (!response.ok) throw new Error('Invalid Fmode token');
  return await response.json(); // 返回 userId / companyId 等
}
```

> 如果 Fmode 没有公开 token 校验接口，需要与 Fmode 后端协商，或改用「管理员手动审批」模式。

#### 6.2.3 注册接口路由

```js
// relay-server/src/routes/tenant.js
const express = require('express');
const router = express.Router();
const { generateTenant } = require('../tenant-service');
const { verifyFmodeToken } = require('../fmode-auth');
const db = require('../db');

router.post('/register', async (req, res) => {
  try {
    const auth = req.headers.authorization || '';
    const token = auth.replace(/^Bearer\s+/i, '');
    if (!token) return res.status(401).json({ success: false, error: '缺少 Fmode token' });

    // 1. 校验 Fmode token
    const fmodeUser = await verifyFmodeToken(token);

    // 2. 风控：检查该用户是否已注册
    const existing = await db.findTenantByFmodeUserId(fmodeUser.userId);
    if (existing) {
      return res.status(409).json({
        success: false,
        error: '该 Fmode 账号已开通 Relay',
        tenantId: existing.tenant_id
      });
    }

    // 3. 生成租户
    const tenant = generateTenant(fmodeUser.userId, req.body.description);

    // 4. 保存到数据库（不存私钥和明文 apiSecret）
    await db.createTenant({
      tenant_id: tenant.tenantId,
      api_key: tenant.apiKey,
      api_secret_hash: tenant.apiSecretHash,
      public_key: tenant.publicKey,
      fmode_user_id: tenant.fmodeUserId,
      description: tenant.description,
      created_at: tenant.createdAt
    });

    // 5. 返回凭证（私钥只返回这一次）
    res.json({
      success: true,
      relayBaseUrl: process.env.RELAY_PUBLIC_URL || `http://${req.headers.host}`,
      tenantId: tenant.tenantId,
      apiKey: tenant.apiKey,
      apiSecret: tenant.apiSecret,
      privateKey: tenant.privateKey,
      createdAt: tenant.createdAt
    });
  } catch (err) {
    console.error('[Relay] 租户注册失败:', err.message);
    res.status(500).json({ success: false, error: err.message });
  }
});

module.exports = router;
```

#### 6.2.4 修改鉴权中间件

现有 `/api/relay/poll` 和 `/api/relay/ack` 通过 `TENANT_API_SECRET` 鉴权。如果之前是明文比对，改为哈希比对：

```js
function authenticateTenant(req, res, next) {
  const auth = req.headers.authorization || '';
  const secret = auth.replace(/^Bearer\s+/i, '');
  const tenant = db.findTenantByApiSecret(secret); // 内部用 hash 比对
  if (!tenant) return res.status(401).json({ error: 'unauthorized' });
  req.tenant = tenant;
  next();
}

app.use('/api/relay/poll', authenticateTenant);
app.use('/api/relay/ack', authenticateTenant);
```

### 步骤 3：配置环境变量

在 Relay 服务端新增：

```bash
# Relay 公网地址
RELAY_PUBLIC_URL=http://8.138.37.248:4000

# 管理员接口密钥（可选，用于 /api/admin/*）
ADMIN_TOKEN=your_very_long_random_admin_token

# Fmode 网关地址
FMODE_GATEWAY_URL=https://server.fmode.cn
```

### 步骤 4：数据库迁移

执行第 5 节的 SQL，创建/扩展租户表和事件表。

### 步骤 5：部署与重启

1. 上传修改后的代码到服务器。
2. 安装新增依赖（如 `bcrypt`、`node-fetch` 等）。
3. 执行数据库迁移。
4. 重启 Relay 服务。
5. 检查日志确认启动成功。

### 步骤 6：接口测试

#### 6.6.1 测试注册接口

```bash
curl -X POST http://8.138.37.248:4000/api/tenant/register \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <Fmode token>" \
  -d '{"description":"测试租户"}'
```

期望返回包含 `tenantId`、`apiKey`、`apiSecret`、`privateKey`、`relayBaseUrl`。

#### 6.6.2 测试 poll 接口

```bash
curl -X POST http://8.138.37.248:4000/api/relay/poll \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <TENANT_API_SECRET>" \
  -d '{"guid":"your-device-guid","batchSize":10}'
```

期望返回 `{ "success": true, "events": [] }`。

#### 6.6.3 测试企微回调链路

1. 使用 Token 级全局回调地址：
   ```text
   http://8.138.37.248:4000/api/webhook/ingest
   ```
2. 调用 Fmode 专用接口 `POST /api/qiwei/relay/connect`，由服务端配置该地址和签名密钥。
3. 触发一个企微事件（如发送群消息）。
4. 调用 `/api/relay/poll`，应能取回加密事件。
5. 用返回的 `privateKey` 解密验证。

## 七、本地 Skill 侧对接

接口上线后，本地 Skill（目标项目）需要增强以下能力：

1. **新增工具 `qiwei_relay_register`**：调用 `/api/tenant/register`，自动保存返回的凭证。
2. **增强 `qiwei_relay_save_config`**：支持保存 `tenantApiKey`、`tenantApiSecret`、`relayPrivateKey`。
3. **增强 `qiwei_relay_connect`**：保存凭证后自动启动 Relay 长轮询客户端。
4. **新增 `scripts/start-relay-client.js`**：独立进程运行长轮询。

用户侧的使用流程：

```text
1. 用户调用 qiwei_relay_register
   输入：Fmode token
   输出：Relay 凭证

2. Skill 自动把凭证写入 .env.local 和 outputs/webhook/relay-config.json

3. 用户调用 qiwei_relay_connect
   Skill 启动 relay-client.js 长轮询

4. 用户调用 qiwei_webhook_auto_setup
   Skill 注册设备后调用 Fmode 专用接口；服务端配置全局回调：
   http://8.138.37.248:4000/api/webhook/ingest

5. 用户正常使用，实时事件自动推送到本地
```

## 八、安全与风控要求

1. **Fmode token 校验**：注册接口必须校验 Fmode token，否则任何人都能批量开户。
2. **单个用户限制**：一个 Fmode 用户只能注册一个租户，防止滥用。
3. **租户配额**：限制每个租户的设备数、每日事件数、队列长度。
4. **私钥只返回一次**：服务端不保存私钥，丢失后只能重置租户。
5. **apiSecret 存哈希**：服务端永远不要存明文 `apiSecret`。
6. **HTTPS 强制**：生产环境注册接口必须走 HTTPS，私钥传输不能明文。
7. **限流**：注册接口按 IP 和 Fmode 用户限流，防止刷接口。
8. **审计日志**：记录每次注册、重置、删除租户的操作。

## 九、回滚方案

如果接口上线后出现问题：

1. 立即回滚到上一版本代码。
2. 恢复旧版数据库（如果迁移失败）。
3. 关闭 `/api/tenant/register` 接口访问（防火墙或路由层）。
4. 通知已注册用户凭证失效，等待修复后重新注册。

## 十、风险与限制

| 风险 | 说明 | 应对 |
|------|------|------|
| 无服务器权限 | 如果无法访问 `8.138.37.248`，本计划无法执行 | 改用自建 Relay 服务端 |
| Fmode token 校验困难 | 如果 Fmode 没有公开校验接口 | 与 Fmode 协商，或改为管理员审批模式 |
| 私钥丢失 | 用户丢失私钥后无法解密历史事件 | 提供重置接口（重新生成密钥对，旧事件废弃） |
| 单点故障 | Relay 服务端宕机，所有用户收不到事件 | 部署多实例 + 负载均衡 + 监控告警 |
| 数据泄露 | 事件 payload 包含聊天记录 | RSA 加密 + HTTPS + 服务端不存私钥 |

## 十一、如果无法修改 Fmode 现有 Relay

如果最终发现没有 `8.138.37.248` 的修改权限，应立即切换到备用方案：

**自建独立 Relay 服务端**

- 在自己可控的服务器上部署全新的 Relay 服务。
- 提供 `/api/tenant/register`、 `/api/webhook/ingest`、 `/api/relay/poll`、 `/api/relay/ack`。
- 企微回调地址指向你的服务器。

具体实现可参考 `docs/specs/qiwei-relay-mode-guide.md` 中的最小 Relay 服务端示例。

## 十二、执行清单

- [ ] 确认拥有 `8.138.37.248` 服务器访问权限
- [ ] 备份现有 Relay 代码和数据
- [ ] 新增 `/api/tenant/register` 接口
- [ ] 新增 `/api/tenant/status` 接口
- [ ] 扩展租户表结构
- [ ] 实现 Fmode token 校验
- [ ] 实现 apiSecret 哈希存储
- [ ] 修改 poll/ack 鉴权为哈希比对
- [ ] 配置环境变量
- [ ] 执行数据库迁移
- [ ] 部署并重启 Relay 服务
- [ ] 测试注册接口
- [ ] 测试 poll 接口
- [ ] 测试完整回调链路
- [ ] 增强本地 Skill 的 `qiwei_relay_register` 工具
- [ ] 更新相关文档

---

执行本计划后，用户确实可以通过调用接口获取到自己的 Relay 凭证。但请务必先确认服务器权限，否则应改用自建 Relay 方案。
