--- 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= TENANT_API_SECRET= RELAY_PRIVATE_KEY= ``` ## 二、前提条件 ⚠️ **执行本计划前必须确认**: 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 ` - 事件加密:使用 RSA 公钥加密,本地私钥解密 但缺少: - 租户自助注册接口 - 用户通过 Fmode token 自动开户的能力 ## 四、接口设计 ### 4.1 新增接口:`POST /api/tenant/register` **功能**:用户使用 Fmode token 申请开通 Relay 租户,服务端生成凭证并返回。 **请求头**: ```http POST /api/tenant/register Content-Type: application/json Authorization: Bearer ``` **请求体**(可选): ```json { "description": "张三的本地 Skill", "deviceGuid": "可选,预注册设备" } ``` **响应体**: ```json { "success": true, "relayBaseUrl": "http://8.138.37.248:4000", "tenantId": "tenant_550e8400e29b41d4a716446655440000", "apiKey": "", "apiSecret": "", "privateKey": "", "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 ``` **响应**: ```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 " \ -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 " \ -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 方案。