Просмотр исходного кода

update 个人/企业 回调模式

gangvy 1 месяц назад
Родитель
Сommit
702990d365
46 измененных файлов с 4767 добавлено и 491 удалено
  1. 1 1
      claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json
  2. 8 1
      claude-code/claude-code-qiwe-assistant/.env.example
  3. 14 5
      claude-code/claude-code-qiwe-assistant/README.md
  4. 4 3
      claude-code/claude-code-qiwe-assistant/docs/RELEASE.md
  5. 7 7
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-add-tenant-register-api-plan.md
  6. 62 357
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-local-integration-guide.md
  7. 4 4
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-mode-guide.md
  8. 3 3
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-subscription-flow.md
  9. 6 6
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-webhook-migration-guide.md
  10. 10 5
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js
  11. 115 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/product-mode.js
  12. 5 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/startup-summary.js
  13. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/subscribe-page.js
  14. 3 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js
  15. 3 2
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  16. 11 1
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  17. 34 12
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  18. 87 8
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-api-catalog-run.js
  19. 14 8
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  20. 85 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-product-mode-run.js
  21. 87 46
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js
  22. 2 2
      claude-code/claude-code-qiwe-assistant/package-lock.json
  23. 1 1
      claude-code/claude-code-qiwe-assistant/package.json
  24. 1 0
      claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js
  25. 61 0
      claude-code/claude-code-qiwe-assistant/scripts/product-mode-smoke-test.js
  26. 45 4
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  27. 23 0
      claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
  28. 4 4
      claude-code/claude-code-qiwe-assistant/skill-package-manifest.json
  29. 1 1
      claude-code/claude-code-qiwe-assistant/skills/qiwei-api-catalog/SKILL.md
  30. 9 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md
  31. 12 7
      claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md
  32. 2 0
      docs/企微技能包/03-企微技能包产品化架构与功能.md
  33. 292 0
      docs/企微技能包/05-企微个人版与企业版双模式方案.md
  34. 1 1
      release/npm发布管理文档/NPM技能包发布管理.md
  35. 6 0
      release/npm发布管理文档/变更记录/fmode-qiwei.md
  36. 81 0
      services/qiwei-relay/lib/config.ts
  37. 89 0
      services/qiwei-relay/lib/crypto.ts
  38. 211 0
      services/qiwei-relay/lib/db.ts
  39. 134 0
      services/qiwei-relay/lib/fmode-auth.ts
  40. 1976 0
      services/qiwei-relay/package-lock.json
  41. 26 0
      services/qiwei-relay/package.json
  42. 876 0
      services/qiwei-relay/routes.ts
  43. 113 0
      services/qiwei-relay/schema.ts
  44. 156 0
      services/qiwei-relay/scripts/global-ingest-smoke-test.mjs
  45. 66 0
      services/qiwei-relay/server.ts
  46. 15 0
      services/qiwei-relay/tsconfig.json

+ 1 - 1
claude-code/claude-code-qiwe-assistant/.claude-plugin/plugin.json

@@ -1,7 +1,7 @@
 {
   "name": "qiwei-assistant",
   "description": "Enterprise WeChat intelligent office, goal management, customer service, CRM and API skills for Claude Code and Fmode Studio.",
-  "version": "0.4.2",
+  "version": "0.4.3",
   "author": {
     "name": "fmode"
   }

+ 8 - 1
claude-code/claude-code-qiwe-assistant/.env.example

@@ -10,6 +10,10 @@ QIWEI_AUTH_TOKEN=
 
 # 企业微信接口访问凭据和设备上下文仅由 Fmode 网关管理,客户端无需配置。
 
+# 产品运行模式:personal(个人版,本地主动监听)或 enterprise(企业版,服务端统一回调)。
+# 留空时兼容旧项目:检测到完整 Relay 凭据则判断为企业版,否则为个人版。
+QIWEI_PRODUCT_MODE=
+
 # 可选:覆盖企业微信官方 CLI 程序缓存目录。
 QIWEI_OFFICIAL_CLI_RUNTIME_DIR=
 
@@ -17,7 +21,7 @@ QIWEI_OFFICIAL_CLI_RUNTIME_DIR=
 WECOM_CLI_CONFIG_DIR=
 
 # ---- Relay 中央 Webhook 配置(可选) ----
-# 中央 Relay 服务器地址,留空则不启用 Relay 模式
+# 仅企业版使用。中央 Relay 服务器地址,留空则企业 Relay 尚未就绪。
 RELAY_BASE_URL=
 
 # Relay 租户凭证(可由 qiwei_relay_register 自动写入)
@@ -28,6 +32,9 @@ TENANT_ID=
 # RSA 私钥,必须写成单行,用 \n 替换真实换行符
 RELAY_PRIVATE_KEY=
 
+# 仅限个人版、独立服务 Token 和独立公网部署。默认关闭,普通个人版使用本地主动监听。
+QIWEI_ALLOW_DIRECT_CALLBACK=false
+
 # ---- 智能会话监听与白名单 ----
 # 留空时监听器会拒绝启动,避免误回复真实客户。
 QIWEI_AUTO_REPLY_ALLOWED_SENDERS=

+ 14 - 5
claude-code/claude-code-qiwe-assistant/README.md

@@ -1,6 +1,13 @@
 # fmode-qiwei 企业微信助手技能包
 
-提供两套相互独立的企业微信能力:
+技能包支持两种产品运行模式:
+
+1. **个人版**:本地主动监听、单企微设备和本地数据;
+2. **企业版**:服务端统一回调、多企微设备消息归集、租户隔离和企业管理底座。
+
+使用 `qiwei_product_mode_status` 查看模式,使用 `qiwei_product_mode_set` 切换。企业版属于独立增值服务,当前报价接口金额为 0 元占位,和企微账号席位费分开。
+
+同时提供两套相互独立的企业微信能力通道:
 
 1. 原有 **全量接口清单 + Fmode 网关转发 + 扫码登录 + 包月订阅管理**;
 2. 新增 **企业微信官方 CLI**,用于会议、文档等官方机器人能力。
@@ -83,6 +90,8 @@ Claude Code / MCP
 
 | 工具 | 说明 |
 | --- | --- |
+| `qiwei_product_mode_status` | 查看个人版/企业版、消息接收方式、Relay 状态和企业升级占位 |
+| `qiwei_product_mode_set` | 切换个人版或企业版,并返回后续配置步骤 |
 | `qiwei_api_search` | 检索 100+ 个企业微信接口 |
 | `qiwei_api_doc` | 查看参数、返回字段和调用模板 |
 | `qiwei_api_call` | `POST /api/qiwei/doApi`,传 `{uid, method, params}` |
@@ -167,8 +176,8 @@ Claude Code / MCP
 | `qiwei_webhook_server_stop` | 停止本地 webhook server |
 | `qiwei_webhook_status` | 查询 webhook 状态 |
 | `qiwei_webhook_discover` | 获取本地 webhook 回调地址 |
-| `qiwei_webhook_auto_setup` | 自动配置企微回调 |
-| `qiwei_webhook_setup` | 手动配置企微回调 |
+| `qiwei_webhook_auto_setup` | 企业版注册设备并连接服务端统一回调 |
+| `qiwei_webhook_setup` | 个人版隔离部署的显式直连回调 |
 | `qiwei_relay_config` | 读取 relay 配置 |
 | `qiwei_relay_save_config` | 保存 relay 公钥/租户配置 |
 | `qiwei_relay_register` | 注册 Relay 租户并保存凭证 |
@@ -230,7 +239,7 @@ npm run wecom:status
 启动并预览企微助手
 ```
 
-Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并依次返回:Fmode 鉴权、企微席位、企微在线、测试白名单和 AI 监听五项状态。
+Claude Code 会调用 `qiwei_agent_dashboard_start`,启动 4320 工作台,并返回产品模式、Fmode 鉴权、企微席位、企微在线、企业 Relay(仅企业版)、测试白名单和 AI 监听状态。
 
 源码开发也可以执行:
 
@@ -380,7 +389,7 @@ node install.js workspace <客户项目目录> --smoke
 <客户项目>/.mcp.json
 ```
 
-安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前公开版本为 `fmode-qiwei@0.4.2`;版本与验证记录见 `docs/RELEASE.md`。
+安装过程不会复制 `.env`、`.env.local`、`.npmrc`、真实运行输出、客户 Session、Playwright 会话、压缩包或源码目录中的 `node_modules`。当前公开版本为 `fmode-qiwei@0.4.3`;版本与验证记录见 `docs/RELEASE.md`。
 
 ## 子 Skill 索引
 

+ 4 - 3
claude-code/claude-code-qiwe-assistant/docs/RELEASE.md

@@ -3,8 +3,8 @@
 ## 当前状态
 
 - npm 包名:`fmode-qiwei`
-- 当前版本:`0.4.2`
-- 状态:已发布到 npm,`latest=0.4.2`
+- 当前版本:`0.4.3`
+- 状态:已发布到 npm,`latest=0.4.3`
 - 运行要求:Node.js `22.5.0+`(使用内置 `node:sqlite`)
 - Dashboard:`http://127.0.0.1:4320/`
 
@@ -30,7 +30,7 @@ node install.js workspace <客户项目目录> --smoke
 本地 tgz:
 
 ```powershell
-npm install -g .\fmode-qiwei-0.4.2.tgz
+npm install -g .\fmode-qiwei-0.4.3.tgz
 fmode-qiwei workspace <客户项目目录> --smoke
 ```
 
@@ -61,6 +61,7 @@ workspace 安装会生成:
 ## 已知边界
 
 - 白名单为空时拒绝启动真实监听。
+- 企微订阅为 500 元/席位/月;本地开通页以服务端 `/subscribe/status` 返回价格为准,500 元仅作为状态读取异常时的回退值。
 - Dashboard 可直接读取企微外部联系人并搜索、多选白名单;保存后写入客户项目 `.env.local` 并立即更新运行时安全边界。
 - 私聊高置信自动回复仍受白名单和置信度阈值约束,低置信内容进入人工审核。
 - 客户群默认使用人工审核;“无视风险 · 全自动”必须按群单独开启并二次确认,非空 Agent 回复会直接群发,模型或发送失败时停止并保留草稿。

+ 7 - 7
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-add-tenant-register-api-plan.md

@@ -1,9 +1,9 @@
 ---
 title: 在 Fmode 现有 Relay 上新增租户自助注册接口的实施计划
-updated: 2026-07-16
+updated: 2026-07-27
 project: claude-code-qiwe-assistant
 scope: server-side-relay
-status: plan
+status: implemented
 ---
 
 # 在 Fmode 现有 Relay 上新增租户自助注册接口的实施计划
@@ -376,11 +376,11 @@ curl -X POST http://8.138.37.248:4000/api/relay/poll \
 
 #### 6.6.3 测试企微回调链路
 
-1. 用返回的 `tenantId` 和 `guid` 拼接回调地址:
+1. 使用 Token 级全局回调地址:
    ```text
-   http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
+   http://8.138.37.248:4000/api/webhook/ingest
    ```
-2. 通过 Fmode 网关 `/client/setCallback` 或平台后台配置该地址
+2. 调用 Fmode 专用接口 `POST /api/qiwei/relay/connect`,由服务端配置该地址和签名密钥
 3. 触发一个企微事件(如发送群消息)。
 4. 调用 `/api/relay/poll`,应能取回加密事件。
 5. 用返回的 `privateKey` 解密验证。
@@ -407,8 +407,8 @@ curl -X POST http://8.138.37.248:4000/api/relay/poll \
    Skill 启动 relay-client.js 长轮询
 
 4. 用户调用 qiwei_webhook_auto_setup
-   Skill 自动把回调地址配到 Fmode 网关
-   http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
+   Skill 注册设备后调用 Fmode 专用接口;服务端配置全局回调
+   http://8.138.37.248:4000/api/webhook/ingest
 
 5. 用户正常使用,实时事件自动推送到本地
 ```

+ 62 - 357
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-local-integration-guide.md

@@ -1,404 +1,109 @@
 ---
 title: 企微 Webhook Relay 本地 Skill 接入指南
-updated: 2026-07-16
+updated: 2026-07-27
 project: claude-code-qiwe-assistant
 scope: local-skill-integration
 ---
 
 # 企微 Webhook Relay 本地 Skill 接入指南
 
-> 本文档说明如何在 `claude-code-qiwe-assistant`(MCP 技能包)中接入**中央 Relay 模式**,通过 Fmode 部署的 Relay 服务端 `http://8.138.37.248:4000` 接收企微实时事件回调。
->
-> 适用场景:Skill 运行在本地电脑、内网或无固定公网 IP,不想/不能暴露本地 webhook server。
+本地 Skill 不暴露 Webhook 端口。企微事件先进入中央 Relay,本地通过长轮询领取属于当前租户和设备的事件。
 
-## 一、前置条件
+## 一、正式链路
 
-1. 已获取 Fmode 鉴权 token(`QIWEI_AUTH_TOKEN` / `FMODE_API_KEY`)。
-2. 已完成企微设备登录并拿到 `guid`(通过 `qiwei_login_start` 等工具)。
-3. Relay 服务端已部署并可访问:`http://8.138.37.248:4000`。
-
-## 二、注册 Relay 租户
-
-调用 Relay 的自助注册接口,用你的 Fmode token 换取租户凭证。
-
-```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": "本地 Skill",
-    "deviceGuid": "你的 guid(可选,建议填写)"
-  }'
-```
-
-返回示例:
-
-```json
-{
-  "success": true,
-  "relayBaseUrl": "http://8.138.37.248:4000",
-  "tenantId": "cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d",
-  "apiKey": "qk_xxx",
-  "apiSecret": "xxx",
-  "privateKey": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
-  "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
-  "createdAt": "2026-07-16T08:00:00.000Z"
-}
+```text
+企微消息
+  -> Token 级全局回调 /api/webhook/ingest
+  -> Relay 按消息体真实 guid 找到租户
+  -> 加密、去重并进入租户队列
+  -> 本地 Relay Client 长轮询
+  -> 解密、落盘、业务处理并 ACK
 ```
 
-**务必保存**:`tenantId`、`apiKey`、`apiSecret`、`privateKey`。`privateKey` 只返回一次,Relay 服务端不保存。
-
-> 如果注册时没传 `deviceGuid`,后面可以调用 `POST /api/tenant/device`(见第七节)。
-
-## 三、配置本地 Skill
+企微上游的回调配置对服务 Token 全局生效,不是每个客户或设备一条。因此回调地址固定为:
 
-### 3.1 写入 `.env.local`
-
-在项目根目录 `claude-code/claude-code-qiwe-assistant` 创建或编辑 `.env.local`:
-
-```bash
-# Fmode 鉴权 token(已有)
-QIWEI_AUTH_TOKEN=sk-xxxxx
-
-# Relay 中央服务器地址
-RELAY_BASE_URL=http://8.138.37.248:4000
-
-# Relay 租户凭证
-TENANT_API_KEY=qk_xxx
-TENANT_API_SECRET=xxx
-
-# RSA 私钥,必须写成单行,用 \n 替换真实换行符
-RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQD...\n-----END PRIVATE KEY-----
+```text
+{RELAY_BASE_URL}/api/webhook/ingest
 ```
 
-> 私钥格式:`.env` 中不能包含真实换行符,必须把 PEM 每一行换行替换为 `\n` 字面量
+不要把回调设置成 `/api/webhook/ingest/{tenantId}/{guid}`。最后一次设置会覆盖此前客户,造成消息错投或中断。
 
-### 3.2 写入 `outputs/webhook/relay-config.json`
+## 二、首次接入
 
-```json
-{
-  "relayBaseUrl": "http://8.138.37.248:4000",
-  "tenantId": "cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d",
-  "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----",
-  "updatedAt": "2026-07-16T08:00:00.000Z"
-}
-```
+1. 配置 Fmode 鉴权 Token。
+2. 启动登录页并完成企业微信扫码登录。
+3. Skill 自动注册 Relay 租户,并把真实设备 `guid` 注册到该租户。
+4. Skill 调用 Fmode 专用接口:
 
-也可以调用现有工具 `qiwei_relay_save_config`:
+```http
+POST {QIWEI_API_BASE}/relay/connect
+Authorization: Bearer <Fmode Token>
+Content-Type: application/json
 
-```json
-{
-  "name": "qiwei_relay_save_config",
-  "arguments": {
-    "relayBaseUrl": "http://8.138.37.248:4000",
-    "tenantId": "cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d",
-    "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
-  }
-}
+{"uid":"本地设备 uid"}
 ```
 
-## 四、启动 Relay 长轮询客户端
-
-`claude-code-qiwe-assistant` 是 stdio MCP server,**不要在主进程内启动长轮询**。推荐新增独立脚本:
-
-### 4.1 创建 `scripts/start-relay-client.js`
-
-```js
-/**
- * Relay 长轮询客户端
- *
- * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件,
- * 用本地私钥解密后落盘到 outputs/webhook/events/。
- */
-
-const fs = require('fs');
-const path = require('path');
-const crypto = require('crypto');
-const { outputsRoot, createRunDir } = require('../mcp/src/core/output-paths');
-
-const POLL_WAIT_MS = 30000;
-const INITIAL_BACKOFF_MS = 1000;
-const MAX_BACKOFF_MS = 60000;
-
-function loadEnvLocal() {
-  const envPath = path.resolve(__dirname, '..', '.env.local');
-  if (!fs.existsSync(envPath)) return {};
-  const env = {};
-  for (const line of fs.readFileSync(envPath, 'utf8').split(/\r?\n/)) {
-    const trimmed = line.trim();
-    if (!trimmed || trimmed.startsWith('#')) continue;
-    const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
-    if (!match) continue;
-    let value = match[2].trim();
-    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
-      value = value.slice(1, -1);
-    }
-    env[match[1]] = value;
-  }
-  return env;
-}
-
-function readRelayConfig() {
-  const filePath = path.join(outputsRoot(), 'webhook', 'relay-config.json');
-  if (!fs.existsSync(filePath)) return {};
-  try {
-    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
-  } catch {
-    return {};
-  }
-}
-
-function getRelayPrivateKey() {
-  return (process.env.RELAY_PRIVATE_KEY || '').replace(/\\n/g, '\n');
-}
-
-function decryptPayload(encryptedPayload, privateKeyPem) {
-  const key = crypto.createPrivateKey(privateKeyPem);
-  const buffer = Buffer.from(encryptedPayload, 'base64');
-  const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
-  return decrypted.toString('utf8');
-}
-
-function saveEvent(eventId, payload) {
-  const runDir = createRunDir('webhook', 'relay-event');
-  const fileName = `event-${eventId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`;
-  const filePath = path.join(runDir, fileName);
-  fs.writeFileSync(
-    filePath,
-    JSON.stringify({ receivedAt: new Date().toISOString(), eventId, payload }, null, 2),
-    'utf8'
-  );
-  return filePath;
-}
-
-async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
-  if (!eventIds.length) return;
-  const res = await fetch(`${baseUrl}/api/relay/ack`, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      Authorization: `Bearer ${apiSecret}`,
-    },
-    body: JSON.stringify({ guid, eventIds }),
-  });
-  if (!res.ok) {
-    console.warn('[RelayClient] ACK 失败:', res.status, await res.text());
-  } else {
-    const data = await res.json();
-    console.log(`[RelayClient] ACK ${data.ackedCount} 条事件`);
-  }
-}
-
-async function runPollOnce(baseUrl, tenantId, apiSecret, guid, privateKey) {
-  const res = await fetch(`${baseUrl}/api/relay/poll`, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      Authorization: `Bearer ${apiSecret}`,
-    },
-    body: JSON.stringify({ guid, batchSize: 100, waitMs: POLL_WAIT_MS }),
-  });
-
-  if (!res.ok) {
-    throw new Error(`poll failed: ${res.status} ${await res.text()}`);
-  }
-
-  const data = await res.json();
-  if (!data.events || !data.events.length) return;
-
-  console.log(`[RelayClient] 取回 ${data.events.length} 条事件`);
-  const eventIds = [];
-
-  for (const event of data.events) {
-    try {
-      const decrypted = decryptPayload(event.encryptedPayload, privateKey);
-      const payload = JSON.parse(decrypted);
-      const filePath = saveEvent(event.eventId, payload);
-      console.log(`[RelayClient] 已解密落盘: ${filePath}`);
-      eventIds.push(event.eventId);
-    } catch (err) {
-      console.error(`[RelayClient] 解密/落盘失败 eventId=${event.eventId}:`, err.message);
-      // 解密失败也要 ACK,避免 Relay 重复投递
-      eventIds.push(event.eventId);
-    }
-  }
-
-  await ackEvents(baseUrl, apiSecret, guid, eventIds);
-}
-
-async function main() {
-  const env = { ...loadEnvLocal(), ...process.env };
-  const baseUrl = (env.RELAY_BASE_URL || 'http://8.138.37.248:4000').replace(/\/$/, '');
-  const apiSecret = env.TENANT_API_SECRET || '';
-  const privateKey = getRelayPrivateKey();
-
-  const relayConfig = readRelayConfig();
-  const tenantId = env.TENANT_ID || relayConfig.tenantId;
-
-  // guid 优先级:环境变量 > relay-config.json > 命令行参数
-  const guid = env.RELAY_DEVICE_GUID || relayConfig.deviceGuid || process.argv[2];
-
-  if (!apiSecret || !privateKey || !tenantId) {
-    console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID');
-    process.exit(1);
-  }
-  if (!guid) {
-    console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json');
-    process.exit(1);
-  }
-
-  console.log(`[RelayClient] 启动 Relay 轮询: ${baseUrl}`);
-  console.log(`[RelayClient] tenantId=${tenantId}, guid=${guid}`);
-
-  let backoff = INITIAL_BACKOFF_MS;
-
-  while (true) {
-    try {
-      await runPollOnce(baseUrl, tenantId, apiSecret, guid, privateKey);
-      backoff = INITIAL_BACKOFF_MS;
-    } catch (err) {
-      console.error('[RelayClient] 轮询异常:', err.message);
-      console.log(`[RelayClient] ${backoff}ms 后重试...`);
-      await new Promise((resolve) => setTimeout(resolve, backoff));
-      backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
-    }
-  }
-}
-
-main().catch((err) => {
-  console.error('[RelayClient] 致命错误:', err);
-  process.exit(1);
-});
-```
-
-### 4.2 运行
-
-```bash
-node scripts/start-relay-client.js [device-guid]
-```
-
-或写入 `package.json`:
-
-```json
-{
-  "scripts": {
-    "relay": "node scripts/start-relay-client.js"
-  }
-}
-```
-
-然后:
+5. Fmode 服务端校验订阅和设备后,使用服务端保存的回调地址与签名密钥调用企微 `/client/setCallback`。
+6. 启动本地 Relay Client:
 
 ```bash
 npm run relay
 ```
 
-日志示例:
+客户端请求中只能出现 `uid`,不能提交任意回调 URL、签名密钥、上游 Token 或设备 `guid`。
 
-```text
-[RelayClient] 启动 Relay 轮询: http://8.138.37.248:4000
-[RelayClient] tenantId=cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d, guid=xxx
-[RelayClient] 取回 3 条事件
-[RelayClient] ACK 3 条事件
-```
-
-## 五、配置企微回调地址
+## 三、Fmode 服务端配置
 
-### 5.1 拼接回调 URL
+Future Server 需要由运维设置以下环境变量:
 
 ```text
-http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
+QIWEI_RELAY_CALLBACK_URL=http://8.138.37.248:4000/api/webhook/ingest
+QIWEI_RELAY_CALLBACK_SECRET=<服务端全局回调签名密钥>
 ```
 
-示例:
+签名密钥只保存在 Future Server 和 Relay 服务端,不能进入 Skill、浏览器、npm 包、日志或客户配置文件。
 
-```text
-http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001
-```
+## 四、本地凭据
 
-### 5.2 设置回调
+本地 Relay Client 只保存本租户的领取和解密凭据:
 
-**方式 A:调用现有 MCP 工具 `qiwei_webhook_auto_setup`**
-
-```json
-{
-  "name": "qiwei_webhook_auto_setup",
-  "arguments": {
-    "guid": "test-device-guid-001",
-    "callbackUrl": "http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001"
-  }
-}
+```text
+RELAY_BASE_URL
+TENANT_ID
+TENANT_API_KEY
+TENANT_API_SECRET
+RELAY_PRIVATE_KEY
+RELAY_DEVICE_GUID
 ```
 
-> 不需要本地 webhook server 运行,回调直接指向 Relay
+这些内容写入 `.env.local`,不得提交 Git 或打进 npm 包。`RELAY_PRIVATE_KEY` 使用 `\n` 表示 PEM 换行。
 
-**方式 B:直接调 Fmode 网关**
+## 五、验证
 
-```bash
-curl -X POST https://server.fmode.cn/api/qiwei/doApi \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer <你的 Fmode token>" \
-  -d '{
-    "uid": "你的 uid",
-    "method": "/client/setCallback",
-    "params": {
-      "guid": "test-device-guid-001",
-      "callbackUrl": "http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001",
-      "authSecret": "任意密钥",
-      "authType": "Authorization"
-    }
-  }'
-```
+1. `npm run relay` 保持运行。
+2. 让另一个企微账号给当前登录账号发送一条新的真实消息。
+3. Relay 服务日志应出现全局入口的接收记录和对应 `guid` 路由。
+4. 本地客户端应完成 poll、解密、业务处理和 ACK。
+5. 4320 工作台应只在正确客户或群聊会话中出现该消息。
 
-## 六、验证
+历史消息不通过回调补采,产品口径为“从接入完成后开始接收”。
 
-1. 确保 `scripts/start-relay-client.js` 正在运行。
-2. 让好友或客户在企微里发送一条消息。
-3. 观察 `outputs/webhook/` 目录:
-   - `outputs/webhook/events/2026-07-16/...` 下应出现解密后的事件文件。
-4. 调用状态接口查看当日事件数:
+## 六、故障排查
 
-```bash
-curl http://8.138.37.248:4000/api/tenant/status \
-  -H "Authorization: Bearer <TENANT_API_SECRET>"
-```
+| 现象 | 检查项 |
+|---|---|
+| `/relay/connect` 返回未配置 | Future Server 是否设置两项 Relay 环境变量并重启 |
+| Relay 收到消息但本地没有 | 设备 `guid` 是否属于当前租户,Relay Client 是否在线 |
+| 本地能 poll 但不能解密 | 本地私钥是否属于当前租户,PEM 换行是否正确 |
+| 消息进入错误客户会话 | 检查事件真实 `guid`、会话 ID 和群/私聊类型,禁止按当前页面会话兜底 |
+| 回调突然中断 | 排查是否有个人进程再次调用 `/client/setCallback` 覆盖全局地址 |
 
-## 七、未在注册时传入 deviceGuid 的补救
+## 七、隔离部署
 
-如果注册时没传 `deviceGuid`,先调用
+客户自有服务器的独立服务 Token 可以使用自定义回调,但必须显式开启:
 
-```bash
-curl -X POST http://8.138.37.248:4000/api/tenant/device \
-  -H "Content-Type: application/json" \
-  -H "Authorization: Bearer <TENANT_API_SECRET>" \
-  -d '{"guid":"你的 guid","deviceName":"本地 Skill"}'
+```text
+QIWEI_ALLOW_DIRECT_CALLBACK=true
 ```
 
-返回的 `relaySecret` 就是 `/api/webhook/ingest/:tenantId/:guid` 的签名密钥。
-
-## 八、可选:封装 MCP 工具
-
-为了让用户在对话里一键注册,可在 `mcp/src/tools/qiwei-webhook-relay-run.js` 中新增工具:
-
-- `qiwei_relay_register`:调用 `/api/tenant/register`,并把返回的 `apiKey`、`apiSecret`、`privateKey`、`tenantId` 保存到 `.env.local` 和 `outputs/webhook/relay-config.json`。
-- `qiwei_relay_connect`:检查配置后,后台 `fork` 启动 `scripts/start-relay-client.js`(注意会话结束可能随 MCP server 一起退出,生产环境建议长期独立运行 `npm run relay`)。
-
-## 九、常见问题
-
-| 现象 | 可能原因 | 排查 |
-|------|---------|------|
-| 注册返回 `INVALID_TOKEN` | Fmode token 无效/过期 | 检查 `.env.local` 的 `QIWEI_AUTH_TOKEN` |
-| 注册返回 `ALREADY_REGISTERED` | 该 Fmode 账号已注册 | 用返回的 `tenantId` 查询状态或重置 |
-| 轮询无事件 | 企微回调未配置到 Relay | 检查 `qiwei_webhook_auto_setup` 的 callbackUrl |
-| 解密失败 | 私钥格式错误 | 确认 `.env.local` 中私钥使用 `\n` 单行存储 |
-| 401/403 | `TENANT_API_SECRET` 错误 | 与 Relay 端比对,注意服务端存的是哈希 |
-| 注册返回 `RATE_LIMITED` | IP 或用户触发限流 | 60 秒后重试,或调整 `.env` 的限流配置 |
-
-## 十、相关文档
-
-- [qiwei-relay-mode-guide.md](qiwei-relay-mode-guide.md) — Relay 模式概念与架构
-- [qiwei-relay-add-tenant-register-api-plan.md](qiwei-relay-add-tenant-register-api-plan.md) — Relay 服务端注册接口实施计划
-
----
-
-按以上步骤操作后,本地 Skill 即可通过 `8.138.37.248:4000` 接收企微实时事件。
+共享 Token 环境禁止开启。默认关闭是为了避免个人版或本地测试覆盖企业版全局回调。

+ 4 - 4
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-mode-guide.md

@@ -1,6 +1,6 @@
 ---
 title: 企微 Webhook Relay 模式完整配置指南
-updated: 2026-07-16
+updated: 2026-07-27
 project: claude-code-qiwe-assistant
 mode: relay
 ---
@@ -313,12 +313,12 @@ node scripts/start-relay-client.js
 当 Relay 模式启用后,企微平台侧的回调地址应配置为:
 
 ```text
-{RELAY_BASE_URL}/api/webhook/ingest/{tenantId}/{guid}
+{RELAY_BASE_URL}/api/webhook/ingest
 ```
 
-这个地址由 `lib/webhook-setup.ts`(或目标项目对应逻辑)自动调用 `/client/setCallback` 设置
+企微回调对服务 Token 全局生效。Skill 先把真实设备 `guid` 注册到 Relay,再调用 Fmode 专用接口 `POST /relay/connect`;由 Fmode 服务端持有回调密钥并调用 `/client/setCallback`。客户端不能提交回调 URL 或签名密钥
 
-你也可以通过 Fmode 平台手动配置,指向 Relay 的 ingest 地址
+旧的 `/{tenantId}/{guid}` 入口仅保留兼容,不用于新部署
 
 ## 九、安全注意事项
 

+ 3 - 3
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-subscription-flow.md

@@ -27,9 +27,9 @@
 
 ## 价格
 
-- 单价:每个账号(席位)**¥200/月**(`QIWEI_MONTHLY_PRICE`)。
-- 页面套餐:1 号 ¥200/月、3 号 ¥600/月、10 号 ¥2000/月(推荐)、
-  20 号 ¥4000/月、50 号 ¥10000/月;时长 1/6/12 个月。
+- 单价:每个账号(席位)**¥500/月**(以服务端 `/subscribe/status` 返回的 `price` 为准,本地默认值为 500)。
+- 页面套餐:1 号 ¥500/月、3 号 ¥1500/月、10 号 ¥5000/月(推荐)、
+  20 号 ¥10000/月、50 号 ¥25000/月;时长 1/6/12 个月。
 
 ## 错误码设计
 

+ 6 - 6
claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-webhook-migration-guide.md

@@ -1,6 +1,6 @@
 ---
 title: 企微 Webhook 能力迁移指南
-updated: 2026-07-16
+updated: 2026-07-27
 source_project: d:\caidawork\Qiwei
 target_project: d:\caidawork\openclaw-voc-skill\claude-code\claude-code-qiwe-assistant
 ---
@@ -18,7 +18,7 @@ target_project: d:\caidawork\openclaw-voc-skill\claude-code\claude-code-qiwe-ass
 | 回调接收路由 | `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`;支持 Relay 模式、本地公网地址、localtunnel 自动隧道 |
+| 自动配置回调 | `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` |
@@ -31,7 +31,7 @@ target_project: d:\caidawork\openclaw-voc-skill\claude-code\claude-code-qiwe-ass
 | 文件 | 现状 | 缺失 |
 |------|------|------|
 | `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_*` 等工具 | 工具只调用 `/client/setCallback` 和落盘,未接入业务 |
+| `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 需要增强 |
 
 ## 二、迁移总体策略
@@ -57,7 +57,7 @@ target_project: d:\caidawork\openclaw-voc-skill\claude-code\claude-code-qiwe-ass
 |--------|-------------|---------|
 | `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` | 复用现有 `gatewayCall` 调用 `/client/setCallback`;保留自动发现公网地址/隧道能力 |
+| `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 网关 |
@@ -251,8 +251,8 @@ const ParsedWebhookEvent = {
 | 工具 | 当前行为 | 迁移后行为 |
 |------|---------|-----------|
 | `qiwei_webhook_server_start` | 启动 server,落盘事件 | 启动 server,**先验证签名**,再解析并结构化落盘 |
-| `qiwei_webhook_auto_setup` | 调 `/client/setCallback` | 同上 + 支持自动发现公网地址/隧道(从 `lib/webhook-setup.ts` 迁移) |
-| `qiwei_webhook_setup` | 调 `/client/setCallback` | 同上 |
+| `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) |
 

+ 10 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js

@@ -62,7 +62,7 @@ function buildFlowPageHtml({ monthlyPrice, checkoutToken }) {
   <div class="panel" id="panel">页面加载中…</div>
 </div>
 <script>
-const PRICE = ${monthlyPrice};
+let PRICE = Number(${monthlyPrice}) || 500;
 const CHECKOUT_TOKEN = ${JSON.stringify(String(checkoutToken || ''))};
 const SEATS = ${JSON.stringify(SEAT_PLANS)};
 const DURATIONS = ${JSON.stringify(DURATION_PLANS)};
@@ -93,7 +93,9 @@ async function api(path, opts) {
 
 let subscribeKey = '';
 
-function renderSubscribe(detail) {
+function renderSubscribe(detail, monthlyPrice) {
+  const livePrice = Number(monthlyPrice);
+  if (Number.isFinite(livePrice) && livePrice > 0) PRICE = livePrice;
   setStep(1);
   subscribeKey = 'flow-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
   panel.innerHTML = '<div class="section-title">开通企微账号:每个账号 ¥' + PRICE + '/月,从飞马余额扣费' + (detail ? '(' + detail + ')' : '') + '</div>' +
@@ -165,7 +167,7 @@ function poll() {
       checkErrors++;
       const st = await api('/flow/state');
       if (st.data.online) { clearInterval(pollTimer); renderSuccess(st.data.detail || {}); return; }
-      if (st.data.subscribed === false && !st.data.stateError) { clearInterval(pollTimer); renderSubscribe(st.data.subscribeDetail || '订阅已到期'); return; }
+      if (st.data.subscribed === false && !st.data.stateError) { clearInterval(pollTimer); renderSubscribe(st.data.subscribeDetail || '订阅已到期', st.data.monthlyPrice); return; }
       // 二维码过期或上游瞬时异常:未提交验证码时自动重新生成二维码
       if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); startLogin(); return; }
       if (codeSubmitted && checkErrors >= 10) {
@@ -237,7 +239,7 @@ async function boot() {
     showMsg('err', '网络异常,暂时无法获取订阅状态(不会重复扣费)。<br><button class="main" onclick="boot()">重试</button>');
     return;
   }
-  renderSubscribe(r.data.subscribeDetail || '');
+  renderSubscribe(r.data.subscribeDetail || '', r.data.monthlyPrice);
 }
 boot();
 </script>
@@ -419,9 +421,12 @@ function createFlowHandler(ctx) {
         let online = false;
         let detail = null;
         let stateError = false;
+        let monthlyPrice = QIWEI_MONTHLY_PRICE;
         try {
           const sub = await callFmodeWecomGateway({ gatewayPath: '/subscribe/status', httpMethod: 'GET', token: ctx.token, apiBase: ctx.apiBase });
           subscribed = Boolean(sub.data && sub.data.subscribed);
+          const livePrice = Number(sub.data && sub.data.price);
+          if (Number.isFinite(livePrice) && livePrice > 0) monthlyPrice = livePrice;
           if (!subscribed) subscribeDetail = '尚未开通包月订阅';
         } catch (error) {
           stateError = true;
@@ -436,7 +441,7 @@ function createFlowHandler(ctx) {
             online = false;
           }
         }
-        json(res, 200, { subscribed, subscribeDetail, online, detail, stateError });
+        json(res, 200, { subscribed, subscribeDetail, online, detail, stateError, monthlyPrice });
         return;
       }
       if (url.pathname === '/flow/subscribe' && req.method === 'POST') {

+ 115 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/product-mode.js

@@ -0,0 +1,115 @@
+const path = require('path');
+const { WORKSPACE_ROOT } = require('./output-paths');
+const { readEnvFiles } = require('./credentials');
+const { isRelayEnabled } = require('./relay-config');
+const { writeEnvValue } = require('./allowlist-config');
+
+const PRODUCT_MODES = Object.freeze({
+  personal: Object.freeze({
+    mode: 'personal',
+    label: '个人版',
+    collectionMode: 'local_polling',
+    collectionLabel: '本地主动监听',
+    storageLabel: '单设备本地存储',
+    multiDeviceManagement: false,
+    serverMessageQueue: false,
+    organizationManagement: false,
+  }),
+  enterprise: Object.freeze({
+    mode: 'enterprise',
+    label: '企业版',
+    collectionMode: 'server_relay',
+    collectionLabel: '服务端统一回调',
+    storageLabel: '企业租户分区存储',
+    multiDeviceManagement: true,
+    serverMessageQueue: true,
+    organizationManagement: true,
+  }),
+});
+
+const ENTERPRISE_UPGRADE_OFFER = Object.freeze({
+  sku: 'qiwei-enterprise-relay',
+  name: '企微企业版 Relay 增值服务',
+  amount: 0,
+  currency: 'CNY',
+  billingCycle: 'month',
+  pricingStatus: 'placeholder',
+  quoteEndpoint: '/product/enterprise/quote',
+});
+
+function normalizeProductMode(value) {
+  const normalized = String(value || '').trim().toLowerCase();
+  if (['personal', 'individual', 'single', '个人', '个人版'].includes(normalized)) return 'personal';
+  if (['enterprise', 'company', 'team', '企业', '企业版'].includes(normalized)) return 'enterprise';
+  return '';
+}
+
+function configuredProductMode(input = {}) {
+  const fileEnv = readEnvFiles();
+  const raw = input.productMode || input.deploymentMode || process.env.QIWEI_PRODUCT_MODE || fileEnv.QIWEI_PRODUCT_MODE || '';
+  return { raw: String(raw || '').trim(), mode: normalizeProductMode(raw) };
+}
+
+function resolveProductMode(input = {}, options = {}) {
+  const configured = configuredProductMode(input);
+  const relayConfigured = options.relayEnabled !== undefined
+    ? Boolean(options.relayEnabled)
+    : isRelayEnabled();
+  const mode = configured.mode || (relayConfigured ? 'enterprise' : 'personal');
+  const definition = PRODUCT_MODES[mode];
+  const fileEnv = readEnvFiles();
+  const directCallbackEnabled = /^(1|true|yes|on)$/i.test(String(
+    process.env.QIWEI_ALLOW_DIRECT_CALLBACK || fileEnv.QIWEI_ALLOW_DIRECT_CALLBACK || ''
+  ));
+  const relayReady = mode === 'enterprise' && relayConfigured;
+  return {
+    ...definition,
+    source: configured.mode ? 'configured' : 'inferred',
+    explicit: Boolean(configured.mode),
+    invalidConfiguredValue: Boolean(configured.raw && !configured.mode),
+    relayConfigured,
+    relayReady,
+    directCallbackEnabled: mode === 'personal' && directCallbackEnabled,
+    nextAction: mode === 'enterprise' && !relayReady
+      ? '注册企业 Relay 租户并连接服务端回调'
+      : mode === 'enterprise'
+        ? '保持 Relay Client 运行并在企业工作台管理设备'
+        : '在本机启动企微监听并配置白名单',
+    enterpriseUpgrade: ENTERPRISE_UPGRADE_OFFER,
+  };
+}
+
+function getProductMode(input = {}) {
+  return resolveProductMode(input);
+}
+
+function assertProductMode(expected, input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== expected) {
+    const expectedLabel = PRODUCT_MODES[expected]?.label || expected;
+    const error = new Error(`当前为${product.label},此操作仅适用于${expectedLabel}`);
+    error.kind = 'product_mode';
+    error.product = product;
+    throw error;
+  }
+  return product;
+}
+
+function setProductMode(value, options = {}) {
+  const mode = normalizeProductMode(value);
+  if (!mode) throw new Error('产品模式只支持 personal(个人版)或 enterprise(企业版)');
+  const envPath = options.envPath || path.join(WORKSPACE_ROOT, '.env.local');
+  writeEnvValue(envPath, 'QIWEI_PRODUCT_MODE', mode);
+  process.env.QIWEI_PRODUCT_MODE = mode;
+  return { product: resolveProductMode({ productMode: mode }), envPath };
+}
+
+module.exports = {
+  PRODUCT_MODES,
+  ENTERPRISE_UPGRADE_OFFER,
+  normalizeProductMode,
+  resolveProductMode,
+  getProductMode,
+  assertProductMode,
+  setProductMode,
+};

+ 5 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/startup-summary.js

@@ -3,11 +3,13 @@ function buildStartupSummary(status = {}, agent = {}) {
   const data = agent.data || {};
   const account = data.account || {};
   const listener = data.listener || {};
+  const product = data.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
   const allowedSenderCount = Number(data.config?.allowedSenderCount || 0);
   const checks = [
     { key: 'auth', label: 'Fmode 鉴权', ready: Boolean(summary.authConfigured), action: '在 Fmode Studio 登录当前账号后重新启动' },
     { key: 'subscription', label: '企微席位', ready: Boolean(summary.subscribed), action: '在工作台打开订阅与登录流程,先开通席位' },
     { key: 'account', label: '企微账号', ready: Boolean(account.online), action: account.configured ? '在工作台点击“恢复登录”' : '在工作台完成企微扫码登录' },
+    ...(product.mode === 'enterprise' ? [{ key: 'relay', label: '企业 Relay', ready: Boolean(product.relayReady), action: '完成企业 Relay 注册并连接服务端统一回调' }] : []),
     { key: 'allowlist', label: '测试白名单', ready: allowedSenderCount > 0, action: '配置至少一个测试联系人白名单' },
     { key: 'listener', label: 'AI 监听', ready: Boolean(listener.running), action: '账号在线后,在智能会话页点击“启动 AI 监听”' },
   ];
@@ -22,12 +24,14 @@ function buildStartupSummary(status = {}, agent = {}) {
     online: Boolean(account.online),
     allowedSenderCount,
     listenerRunning: Boolean(listener.running),
+    product,
   };
 }
 
 function startupAssistantMessage(startup = {}) {
   const stateText = (startup.checks || []).map(item => `${item.label}${item.ready ? '完成' : '待处理'}`).join(',');
-  return `企微智能工作台已启动。${stateText}。下一步:${startup.nextAction}`;
+  const productText = startup.product?.label ? `${startup.product.label}(${startup.product.collectionLabel})` : '当前模式';
+  return `企微智能工作台已启动,当前为${productText}。${stateText}。下一步:${startup.nextAction}`;
 }
 
 module.exports = { buildStartupSummary, startupAssistantMessage };

+ 1 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/subscribe-page.js

@@ -1,4 +1,4 @@
-const QIWEI_MONTHLY_PRICE = 200;
+const QIWEI_MONTHLY_PRICE = 500;
 
 const SEAT_PLANS = [
   { seats: 1, label: '1 个号', note: '个人试用' },

+ 3 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/agent-service.js

@@ -19,6 +19,7 @@ const { FmodeQiweiClient } = require('../providers/fmode-agent-transport');
 const { responseMonitor } = require('./response-monitor-service');
 const { GroupAgentService } = require('./group-agent-service');
 const { normalizeAllowlistIds, normalizeAllowlistContact, writeEnvValue } = require('../core/allowlist-config');
+const { getProductMode } = require('../core/product-mode');
 
 const PROJECT_ROOT = WORKSPACE_ROOT;
 const ENV_FILE = path.join(PROJECT_ROOT, '.env.local');
@@ -822,6 +823,7 @@ function publicConversation(row) {
 async function getAgentStatus() {
   const account = await detectAccountStatus();
   const state = workbench.service.state(workbench.poller.status());
+  const product = getProductMode();
   return {
     status: 'ok',
     data: {
@@ -829,6 +831,7 @@ async function getAgentStatus() {
       global: state.global,
       listener: state.poller,
       account,
+      product,
       agent: state.agent,
       knowledge: workbench.knowledge.stats(),
       config: {

+ 3 - 2
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -2362,6 +2362,7 @@
     const listener = status.listener || {};
     const account = status.account || {};
     const config = status.config || {};
+    const product = status.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
     const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
     const global = status.global || {};
     const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
@@ -2423,7 +2424,7 @@
         </div>
         <div class="agent-safety-card">
           <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
-          <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${listenerRunning ? '真实消息监听中' : '监听已停止'}</small></div>
+          <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(product.label)} · ${escapeHtml(product.collectionLabel)} · ${listenerRunning ? '消息处理中' : '处理已停止'}</small></div>
           <div class="agent-safety-actions">
             <button class="btn btn-secondary" data-agent-action="manage-allowlist">管理白名单</button>
             <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采最近消息</button>
@@ -2433,7 +2434,7 @@
       </section>
 
       <section class="agent-modebar">
-        <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>监听与 Agent 策略相互独立,暂停时仍可收消息</span></div>
+        <div class="agent-mode-copy"><strong>全局 Agent 策略</strong><span>${escapeHtml(product.label)} · ${product.mode === 'enterprise' ? (product.relayReady ? '企业 Relay 已接入' : '企业 Relay 待接入') : '数据保存在当前项目'} · 暂停时仍可收消息</span></div>
         <div class="agent-mode-switch">
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
           <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>

+ 11 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js

@@ -103,6 +103,7 @@ async function callFmodeWecomGateway({
   httpMethod = 'POST',
   query,
   body,
+  formData,
   token,
   apiBase,
   timeoutMs = 60000
@@ -121,6 +122,11 @@ async function callFmodeWecomGateway({
 
   const url = buildGatewayUrl(apiBase, gatewayPath, query);
   const method = String(httpMethod || 'POST').toUpperCase();
+  if (body !== undefined && formData !== undefined) {
+    const err = new Error('body and formData are mutually exclusive');
+    err.kind = 'request';
+    throw err;
+  }
   let response;
   try {
     response = await fetchWithNetworkRetry(url, {
@@ -130,7 +136,11 @@ async function callFmodeWecomGateway({
         Accept: 'application/json',
         ...(body !== undefined ? { 'Content-Type': 'application/json' } : {})
       },
-      body: body === undefined ? undefined : JSON.stringify(body)
+      body: formData !== undefined
+        ? formData
+        : body === undefined
+          ? undefined
+          : JSON.stringify(body)
     }, timeoutMs);
   } catch (error) {
     const err = new Error('网络请求失败');

+ 34 - 12
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -3,6 +3,7 @@ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
 const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
 const { z } = require('zod');
 const { redactSecret } = require('./providers/fmode-wecom-gateway');
+const { qiweiProductModeStatus, qiweiProductModeSet } = require('./tools/qiwei-product-mode-run');
 const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('./tools/qiwei-api-catalog-run');
 const {
   qiweiLoginStatus,
@@ -151,9 +152,29 @@ function wrap(handler) {
 function createServer() {
   const server = new McpServer({
     name: 'enterprise-wechat-assistant',
-    version: '0.4.2'
+    version: '0.4.3'
   });
 
+  server.registerTool(
+    'qiwei_product_mode_status',
+    {
+      title: '查看企微个人版或企业版模式',
+      description: '查看当前项目是个人版还是企业版、消息接收和存储方式、企业 Relay 是否就绪,以及企业版升级服务的当前报价占位。',
+      inputSchema: {}
+    },
+    wrap(qiweiProductModeStatus)
+  );
+
+  server.registerTool(
+    'qiwei_product_mode_set',
+    {
+      title: '切换企微个人版或企业版模式',
+      description: '切换项目运行模式。personal 使用本地主动监听和单设备本地数据;enterprise 使用服务端统一回调、多设备归集和企业隔离。切换后按返回步骤重启相关进程。',
+      inputSchema: { mode: z.enum(['personal', 'enterprise']) }
+    },
+    wrap(qiweiProductModeSet)
+  );
+
   server.registerTool(
     'qiwei_api_search',
     {
@@ -199,6 +220,7 @@ function createServer() {
         id: z.string().optional(),
         rawMethod: z.string().optional(),
         params: z.record(z.any()).optional(),
+        filePath: z.string().optional(),
         uid: z.string().optional(),
         authToken: z.string().optional(),
         apiBase: z.string().optional()
@@ -226,7 +248,7 @@ function createServer() {
     {
       title: '开始企业微信扫码登录',
       description: [
-        '登录两步流程入口:默认在本地端口(默认 4310)启动流程页服务并打开浏览器,页面自动推进:未开通订阅时先展示套餐页(每号 ¥200/月,飞马余额扣费),开通后自动生成二维码并每 3 秒轮询扫码状态,需要验证码时页面提示输入。',
+        '登录两步流程入口:默认在本地端口(默认 4310)启动流程页服务并打开浏览器,页面自动推进:未开通订阅时先展示服务端当前套餐价格(飞马余额扣费),开通后自动生成二维码并每 3 秒轮询扫码状态,需要验证码时页面提示输入。',
         '传 flowUi=false 仅用于已开通订阅后的扫码回退模式;未开通时返回 needs_subscription,不生成包含请求凭据的静态付费页。',
         '需要有效企微包月订阅和可用席位;无需提供企业微信接口访问凭据、代理或设备参数。'
       ].join(' '),
@@ -963,7 +985,7 @@ function createServer() {
     'qiwei_webhook_status',
     {
       title: '查询 webhook 状态',
-      description: '查询本地 webhook server 运行状态和已保存的回调配置。',
+      description: '查询当前个人版/企业版消息接收方式、本地 webhook、企业 Relay 和事件状态,不返回任何回调或租户密钥。',
       inputSchema: {}
     },
     wrap(qiweiWebhookStatus)
@@ -972,8 +994,8 @@ function createServer() {
   server.registerTool(
     'qiwei_webhook_discover',
     {
-      title: '获取本地 webhook 回调地址',
-      description: '返回本地 webhook server 的回调 URL。',
+      title: '获取个人版直连回调地址',
+      description: '个人版隔离部署专用:返回本地 webhook server 的回调 URL。企业版使用服务端统一回调。',
       inputSchema: {}
     },
     wrap(qiweiWebhookDiscover)
@@ -982,8 +1004,8 @@ function createServer() {
   server.registerTool(
     'qiwei_webhook_server_start',
     {
-      title: '启动本地 webhook server',
-      description: '在 127.0.0.1 指定端口启动本地 HTTP server 接收企微回调,事件写入 outputs/webhook/。',
+      title: '启动个人版本地 webhook server',
+      description: '个人版隔离部署专用:在本机启动 HTTP server,仍需独立公网入口才能接收企微回调。企业版不能调用。',
       inputSchema: {
         port: z.number().int().min(0).max(65535).optional()
       }
@@ -1004,8 +1026,8 @@ function createServer() {
   server.registerTool(
     'qiwei_webhook_auto_setup',
     {
-      title: '自动配置企回调',
-      description: '启动本地 webhook server(如未运行)并调用 /client/setCallback 将回调地址配置到 Fmode 网关。',
+      title: '自动配置企业版 Relay 回调',
+      description: '企业版专用:注册设备到中央 Relay,并调用 Fmode 服务端专用接口连接 Token 级全局回调。个人版使用本地监听,无需调用。',
       inputSchema: {
         guid: z.string().optional(),
         port: z.number().int().min(0).max(65535).optional(),
@@ -1022,8 +1044,8 @@ function createServer() {
   server.registerTool(
     'qiwei_webhook_setup',
     {
-      title: '配置企微回调',
-      description: '调用 /client/setCallback 配置指定的回调地址。',
+      title: '配置隔离部署的个人回调',
+      description: '仅限个人版独立服务 Token 和独立公网部署;需要显式开启直连回调,企业版不能调用。',
       inputSchema: {
         guid: z.string().optional(),
         callbackUrl: z.string(),
@@ -1079,7 +1101,7 @@ function createServer() {
     'qiwei_relay_connect',
     {
       title: '连接中央 relay',
-      description: '检查 relay 配置,注册设备到 Relay,调用 /client/setCallback 设置回调地址,并提示启动独立轮询客户端。',
+      description: '企业版专用:检查 Relay 配置,注册设备并调用 Fmode `/relay/connect` 接入服务端统一回调。',
       inputSchema: {
         guid: z.string().optional(),
         force: z.boolean().optional()

+ 87 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-api-catalog-run.js

@@ -1,3 +1,5 @@
+const fs = require('fs');
+const path = require('path');
 const {
   loadCatalog,
   listEndpoints,
@@ -13,6 +15,10 @@ const { okResult, errorResult } = require('../core/result-envelope');
 function buildCallTemplate(endpoint) {
   const args = { id: endpoint.id, params: {} };
   for (const param of endpoint.params || []) {
+    if (endpoint.apiPath === '/api/qw/doFileApi' && param.name === 'file') {
+      args.filePath = '<本地文件路径>';
+      continue;
+    }
     if (param.required && !/^(guid|tokenId)$/i.test(param.name)) args.params[param.name] = `<${param.name}>`;
   }
   return args;
@@ -86,7 +92,11 @@ async function getQiweiApiDoc(input = {}) {
     redactSecret(endpoint.summary || ''),
     '',
     `method: ${endpoint.method || '(官方文档未公布,暂不可通过 qiwei_api_call 直接调用)'}(分类:${(endpoint.category || []).join(' > ') || '无'})`,
-    endpoint.apiPath ? `注意:本接口走 ${endpoint.apiPath}${endpoint.contentType ? `(${endpoint.contentType})` : ''};Fmode 网关当前尚未支持 multipart 文件传输,qiwei_api_call 暂不可调用。` : '',
+    endpoint.apiPath
+      ? endpoint.apiPath === FILE_GATEWAY_PATH
+        ? `注意:本接口走 ${endpoint.apiPath}(multipart/form-data);调用 qiwei_api_call 时用 filePath 传入本地文件路径。`
+        : `注意:本接口走 ${endpoint.apiPath}${endpoint.contentType ? `(${endpoint.contentType})` : ''}。`
+      : '',
     '',
     '参数:',
     ...renderParamLines(endpoint.params),
@@ -121,6 +131,22 @@ async function getQiweiApiDoc(input = {}) {
 }
 
 const BLOCKED_METHOD_PREFIXES = ['/login/', '/client/'];
+const FILE_GATEWAY_PATH = '/api/qw/doFileApi';
+const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
+
+function uploadContentType(filePath) {
+  const extension = path.extname(filePath).toLowerCase();
+  return {
+    '.amr': 'audio/amr',
+    '.silk': 'audio/silk',
+    '.mp3': 'audio/mpeg',
+    '.wav': 'audio/wav',
+    '.jpg': 'image/jpeg',
+    '.jpeg': 'image/jpeg',
+    '.png': 'image/png',
+    '.mp4': 'video/mp4'
+  }[extension] || 'application/octet-stream';
+}
 
 async function callQiweiApi(input = {}) {
   const idOrMethod = input.id || input.endpointId;
@@ -155,6 +181,9 @@ async function callQiweiApi(input = {}) {
     return errorResult('登录和设备类接口必须使用 qiwei_login_* 专用工具,不能通过 qiwei_api_call 透传。');
   }
   const params = input.params && typeof input.params === 'object' ? { ...input.params } : {};
+  const isFileEndpoint = endpoint && endpoint.apiPath === FILE_GATEWAY_PATH;
+  const requestedFilePath = String(input.filePath || params.filePath || params.file || '').trim();
+  delete params.filePath;
 
   if (endpoint) {
     if (!endpoint.method) {
@@ -163,7 +192,7 @@ async function callQiweiApi(input = {}) {
         { summary: { endpoint: endpoint.id, errorKind: 'no_method' } }
       );
     }
-    if (endpoint.apiPath && endpoint.apiPath !== '/api/qw/doApi') {
+    if (endpoint.apiPath && endpoint.apiPath !== '/api/qw/doApi' && !isFileEndpoint) {
       return errorResult(
         `接口 ${endpoint.id} 走 ${endpoint.apiPath}(${endpoint.contentType || 'multipart'}),Fmode 网关当前尚未支持文件流,qiwei_api_call 暂不可调用。`
       );
@@ -173,6 +202,7 @@ async function callQiweiApi(input = {}) {
         p =>
           p.name !== 'guid' &&
           p.required &&
+          !(p.name === 'file' && requestedFilePath) &&
           (params[p.name] === undefined || params[p.name] === null || params[p.name] === '')
       )
       .map(p => p.name);
@@ -193,12 +223,61 @@ async function callQiweiApi(input = {}) {
   const apiBase = readQiweiApiBase(input);
   const uid = ensureQiweiUid(input);
   try {
-    const result = await callFmodeWecomGateway({
-      gatewayPath: '/doApi',
-      body: { uid, method, params },
-      token,
-      apiBase
-    });
+    let result;
+    if (isFileEndpoint) {
+      if (!requestedFilePath) {
+        const err = new Error('missing filePath');
+        err.kind = 'request';
+        err.httpStatus = 400;
+        throw err;
+      }
+      const absolutePath = path.resolve(requestedFilePath);
+      if (!fs.existsSync(absolutePath)) {
+        const err = new Error('upload file does not exist');
+        err.kind = 'request';
+        err.httpStatus = 400;
+        throw err;
+      }
+      const stat = fs.statSync(absolutePath);
+      if (!stat.isFile() || stat.size <= 0) {
+        const err = new Error('upload file is empty');
+        err.kind = 'request';
+        err.httpStatus = 400;
+        throw err;
+      }
+      if (stat.size > MAX_UPLOAD_BYTES) {
+        const err = new Error('upload file exceeds 25 MB');
+        err.kind = 'request';
+        err.httpStatus = 400;
+        throw err;
+      }
+      const formData = new FormData();
+      formData.append('uid', uid);
+      formData.append('method', method);
+      for (const [key, value] of Object.entries(params)) {
+        if (key === 'file' || value === undefined || value === null) continue;
+        formData.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value));
+      }
+      formData.append(
+        'file',
+        new Blob([fs.readFileSync(absolutePath)], { type: uploadContentType(absolutePath) }),
+        path.basename(absolutePath)
+      );
+      result = await callFmodeWecomGateway({
+        gatewayPath: '/doFileApi',
+        formData,
+        token,
+        apiBase,
+        timeoutMs: 90000
+      });
+    } else {
+      result = await callFmodeWecomGateway({
+        gatewayPath: '/doApi',
+        body: { uid, method, params },
+        token,
+        apiBase
+      });
+    }
     return okResult({
       assistantMessage: `调用成功:${method}。`,
       summary: { endpoint: endpoint ? endpoint.id : method, method, uid, httpStatus: result.httpStatus },

+ 14 - 8
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js

@@ -14,7 +14,7 @@ const { latestPath } = require('../core/output-paths');
 const { QIWEI_MONTHLY_PRICE } = require('../core/subscribe-page');
 const { startLoginFlowServer } = require('../core/login-flow-server');
 const { startLoginFallbackServer } = require('../core/login-fallback-server');
-const { isRelayEnabled } = require('../core/relay-config');
+const { getProductMode } = require('../core/product-mode');
 const { recordDeviceGuid } = require('../core/device-broker-mapping');
 const { ensureRelayWebhookConfigured, registerRelayTenant } = require('./qiwei-webhook-relay-run');
 
@@ -392,7 +392,8 @@ async function qiweiLoginCheck(input = {}) {
       }
 
       let relaySetup = null;
-      if (isRelayEnabled() || process.env.RELAY_BASE_URL) {
+      const product = getProductMode(input);
+      if (product.mode === 'enterprise') {
         try {
           relaySetup = await ensureRelayAndCallback({ token, apiBase, uid, guid, input });
         } catch (err) {
@@ -401,9 +402,11 @@ async function qiweiLoginCheck(input = {}) {
         }
       }
 
-      const assistantMessage = relaySetup?.success
-        ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。Relay 回调已自动配置:${relaySetup.callbackUrl}。请在服务器上执行 npm run relay 启动轮询。`
-        : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。后续业务请求将通过 Fmode 网关转发的企业微信接口执行。`;
+      const assistantMessage = product.mode === 'enterprise'
+        ? relaySetup?.success
+          ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。企业版 Relay 回调已自动配置:${relaySetup.callbackUrl}。请保持 npm run relay 运行。`
+          : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为企业版,但 Relay 接入尚未完成,请检查企业 Relay 配置。`
+        : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。当前为个人版,消息由本地主动监听处理并保存在当前项目。`;
 
       return okResult({
         assistantMessage,
@@ -415,14 +418,17 @@ async function qiweiLoginCheck(input = {}) {
           nickname: detail.nickname,
           corpId: detail.corpId,
           guid,
+          productMode: product.mode,
           relaySetup: relaySetup
             ? { success: relaySetup.success, callbackUrl: relaySetup.callbackUrl, error: relaySetup.error }
             : null
         },
-        data: { uid, status: detail, guid, relaySetup },
+        data: { uid, status: detail, guid, product, relaySetup },
         nextActions: relaySetup?.success
-          ? ['在服务器上执行 npm run relay 启动轮询客户端']
-          : []
+          ? ['保持 npm run relay 运行']
+          : product.mode === 'enterprise'
+            ? ['完成企业 Relay 注册和服务端回调连接']
+            : ['在 4320 工作台配置白名单并启动 AI 监听']
       });
     }
     if (statusCode === '10') {

+ 85 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-product-mode-run.js

@@ -0,0 +1,85 @@
+const { okResult } = require('../core/result-envelope');
+const { getProductMode, setProductMode } = require('../core/product-mode');
+const { safeResult } = require('../core/shared-gateway');
+const { readQiweiAuthToken, readQiweiApiBase } = require('../core/credentials');
+const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
+
+async function readEnterpriseUpgradeQuote(input = {}) {
+  const fallback = getProductMode(input).enterpriseUpgrade;
+  const token = readQiweiAuthToken(input);
+  if (!token) return { quote: fallback, source: 'local_placeholder', warning: '尚未登录 Fmode,企业版价格暂时显示 0 元占位' };
+  try {
+    const result = await callFmodeWecomGateway({
+      gatewayPath: '/product/enterprise/quote',
+      httpMethod: 'GET',
+      token,
+      apiBase: readQiweiApiBase(input),
+      timeoutMs: 15000,
+    });
+    const quote = result.data && result.data.data !== undefined ? result.data.data : result.data;
+    return { quote: { ...fallback, ...(quote || {}) }, source: 'server' };
+  } catch {
+    return { quote: fallback, source: 'local_placeholder', warning: '企业版报价接口尚未上线,当前显示 0 元占位' };
+  }
+}
+
+function productSummary(product) {
+  return {
+    mode: product.mode,
+    label: product.label,
+    collectionMode: product.collectionMode,
+    collectionLabel: product.collectionLabel,
+    storageLabel: product.storageLabel,
+    relayReady: product.relayReady,
+    explicit: product.explicit,
+    enterpriseUpgrade: product.enterpriseUpgrade,
+  };
+}
+
+const qiweiProductModeStatus = safeResult(async function qiweiProductModeStatus(input = {}) {
+  const product = getProductMode(input);
+  const pricing = await readEnterpriseUpgradeQuote(input);
+  product.enterpriseUpgrade = { ...pricing.quote, source: pricing.source };
+  return okResult({
+    assistantMessage: `当前使用${product.label}:${product.collectionLabel},${product.storageLabel}。下一步:${product.nextAction}。`,
+    summary: productSummary(product),
+    data: { product },
+    nextActions: [product.nextAction],
+    warnings: [
+      ...(product.invalidConfiguredValue ? ['检测到无法识别的产品模式配置,已按现有 Relay 配置自动判断'] : []),
+      ...(pricing.warning ? [pricing.warning] : []),
+    ],
+  });
+});
+
+const qiweiProductModeSet = safeResult(async function qiweiProductModeSet(input = {}) {
+  const before = getProductMode();
+  const { product, envPath } = setProductMode(input.mode);
+  const pricing = await readEnterpriseUpgradeQuote(input);
+  product.enterpriseUpgrade = { ...pricing.quote, source: pricing.source };
+  const changed = before.mode !== product.mode;
+  const warnings = [];
+  const nextActions = [];
+
+  if (product.mode === 'enterprise') {
+    warnings.push(`企业版属于增值服务;当前升级金额为 ${product.enterpriseUpgrade.amount} 元,${pricing.source === 'server' ? '以服务端报价为准' : '当前为占位报价'}`);
+    if (pricing.warning) warnings.push(pricing.warning);
+    nextActions.push(product.relayReady ? '重启工作台并保持 npm run relay 运行' : '调用 qiwei_relay_register 和 qiwei_relay_connect 完成企业 Relay 接入');
+  } else {
+    nextActions.push('停止仍在运行的 Relay Client,重启工作台后启动本地 AI 监听');
+    if (product.relayConfigured) warnings.push('已保留原企业 Relay 凭据,个人版不会使用;以后升级企业版时可以复用');
+  }
+
+  return okResult({
+    assistantMessage: changed
+      ? `已切换为${product.label}。${product.collectionLabel}将在相关进程重启后完整生效。`
+      : `当前已经是${product.label},配置已确认。`,
+    summary: { ...productSummary(product), changed },
+    data: { product, previousMode: before.mode },
+    files: [envPath],
+    nextActions,
+    warnings,
+  });
+});
+
+module.exports = { qiweiProductModeStatus, qiweiProductModeSet, readEnterpriseUpgradeQuote };

+ 87 - 46
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js

@@ -8,6 +8,7 @@ const {
   assertMethodsInCatalog,
   safeResult
 } = require('../core/shared-gateway');
+const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
 const { outputsRoot } = require('../core/output-paths');
 const {
   readQiweiGuid,
@@ -30,6 +31,7 @@ const {
 } = require('../core/webhook-server');
 const { recordDeviceGuid } = require('../core/device-broker-mapping');
 const { isRelayEnabled, getRelayBaseUrl, getTenantApiKey, getTenantApiSecret, getTenantId, getRelayPrivateKey, getRelayCredentials, saveRelayCredentials, writeRelayConfigFile, readRelayConfigFile } = require('../core/relay-config');
+const { getProductMode } = require('../core/product-mode');
 
 const REQUIRED_METHODS = {
   setCallback: '/client/setCallback'
@@ -66,6 +68,18 @@ async function fetchRelay(baseUrl, path, apiSecret, body) {
   return json;
 }
 
+async function connectRelayThroughFmode(input = {}) {
+  const ctx = buildContext(input);
+  const result = await callFmodeWecomGateway({
+    gatewayPath: '/relay/connect',
+    body: { uid: ctx.uid },
+    token: ctx.token,
+    apiBase: ctx.apiBase,
+    timeoutMs: 60000
+  });
+  return result.data && result.data.data !== undefined ? result.data.data : result.data;
+}
+
 async function registerRelayTenant(input = {}) {
   const baseUrl = String(input.relayBaseUrl || getRelayBaseUrl()).replace(/\/$/, '');
   const token = readQiweiAuthToken(input);
@@ -124,6 +138,10 @@ async function registerRelayTenant(input = {}) {
 }
 
 async function ensureRelayWebhookConfigured(input = {}, options = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') {
+    return { success: false, error: '当前为个人版,本地监听不配置企业 Relay 回调' };
+  }
   if (!isRelayEnabled()) {
     return { success: false, error: 'Relay 未启用,请先调用 qiwei_relay_register 注册租户' };
   }
@@ -140,60 +158,44 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
     return { success: false, error: '缺少 tenantId,请先注册 Relay 租户' };
   }
 
-  // 注册/更新设备到 Relay
-  let relaySecret;
+  // Register the device before enabling the global callback so routing is ready
+  // when the first event arrives.
   try {
-    const deviceRes = await fetchRelay(baseUrl, '/api/tenant/device', apiSecret, { guid });
-    relaySecret = deviceRes.relaySecret;
+    await fetchRelay(baseUrl, '/api/tenant/device', apiSecret, { guid });
   } catch (err) {
-    console.warn('[Relay] 注册设备到 Relay 失败,使用本地生成 secret:', err.message);
     return { success: false, error: `Relay device registration failed: ${err.message}` };
   }
-  if (!relaySecret) {
-    return { success: false, error: 'Relay did not return relaySecret for this device' };
-  }
-
-  const callbackUrl = `${baseUrl}/api/webhook/ingest/${tenantId}/${guid}`;
 
-  // 避免重复配置:如果本地已记录相同地址则跳过
-  const config = readConfig();
-  if (config.callbackUrl === callbackUrl && !options.force) {
-    return { success: true, callbackUrl, source: 'cache' };
-  }
+  const callbackUrl = `${baseUrl}/api/webhook/ingest`;
 
-  // 调用 Fmode 网关设置回调
-  const ctx = buildContext(input);
+  // Callback credentials are server-owned. The client only asks Fmode to
+  // connect the authenticated device to the configured global Relay endpoint.
   try {
-    await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
-      callbackUrl,
-      authSecret: relaySecret,
-      authType: 'Authorization'
-    });
-  } catch (err) {
-    const message = String(err.bizMessage || err.message || '');
-    if (/专用端点|登录或设备接口|登录.*设备接口/i.test(message)) {
-      // Fmode 禁止通过 /doApi 自动配置回调,降级为手动配置模式
-      writeConfig({ callbackUrl, secret: relaySecret });
-      writeRelayConfigFile({ deviceGuid: guid, tenantId });
-      return {
-        success: true,
-        callbackUrl,
-        source: 'manual',
-        manualSetup: true,
-        error: '该 Fmode 账号/Token 禁止通过 API 自动设置回调,请手动在 Fmode 后台配置上述回调地址',
-        authSecret: relaySecret
-      };
+    const connected = await connectRelayThroughFmode(input);
+    if (connected && connected.connected === false) {
+      return { success: false, error: 'Fmode Relay connection was not accepted' };
     }
-    throw err;
+  } catch (err) {
+    return { success: false, error: `Fmode Relay connection failed: ${err.message}` };
   }
 
-  writeConfig({ callbackUrl, secret: relaySecret });
+  writeConfig({ callbackUrl, secret: null });
   writeRelayConfigFile({ deviceGuid: guid, tenantId });
 
-  return { success: true, callbackUrl, source: 'relay' };
+  return { success: true, callbackUrl, source: 'fmode_relay' };
 }
 
 async function ensureLocalWebhookConfigured(input = {}, options = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'personal') {
+    return { success: false, error: '企业版使用服务端统一回调,不能由本地设备覆盖' };
+  }
+  if (!product.directCallbackEnabled) {
+    return {
+      success: false,
+      error: 'Direct callback override is disabled. Use Relay mode, or set QIWEI_ALLOW_DIRECT_CALLBACK=true for an isolated deployment.'
+    };
+  }
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
   if (!guid) {
     return { success: false, error: '缺少 guid,请先完成企微设备登录' };
@@ -244,6 +246,8 @@ async function ensureLocalWebhookConfigured(input = {}, options = {}) {
 }
 
 const qiweiWebhookDiscover = safeResult(async function qiweiWebhookDiscover(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'personal') return errorResult('企业版使用服务端统一回调,不提供本地回调地址');
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
   if (!guid) {
     return errorResult('缺少 guid,请先完成企微设备登录');
@@ -261,6 +265,8 @@ const qiweiWebhookDiscover = safeResult(async function qiweiWebhookDiscover(inpu
 });
 
 const qiweiWebhookServerStart = safeResult(async function qiweiWebhookServerStart(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'personal') return errorResult('企业版使用服务端统一回调,不启动本地 webhook server');
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
   if (!guid) {
     return errorResult('缺少 guid,请先完成企微设备登录');
@@ -288,6 +294,10 @@ const qiweiWebhookServerStop = safeResult(async function qiweiWebhookServerStop(
 });
 
 const qiweiWebhookAutoSetup = safeResult(async function qiweiWebhookAutoSetup(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') {
+    return errorResult('当前为个人版,无需配置企业 Relay 回调;请在工作台启动本地 AI 监听');
+  }
   assertMethodsInCatalog(REQUIRED_METHODS);
 
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
@@ -323,6 +333,10 @@ const qiweiWebhookAutoSetup = safeResult(async function qiweiWebhookAutoSetup(in
 });
 
 const qiweiWebhookSetup = safeResult(async function qiweiWebhookSetup(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'personal') {
+    return errorResult('企业版回调由 Fmode 服务端统一管理,不能设置个人回调地址');
+  }
   const guid = String(input.guid || readQiweiGuid(input) || '').trim();
   if (!guid) {
     return errorResult('缺少 guid,请先完成企微设备登录或显式传入 guid');
@@ -348,6 +362,7 @@ const qiweiWebhookSetup = safeResult(async function qiweiWebhookSetup(input = {}
 });
 
 const qiweiRelayConfig = safeResult(async function qiweiRelayConfig() {
+  const product = getProductMode();
   const config = readRelayConfig();
   const enabled = isRelayEnabled();
   const creds = {
@@ -362,12 +377,14 @@ const qiweiRelayConfig = safeResult(async function qiweiRelayConfig() {
     assistantMessage: enabled
       ? `Relay 已启用:${creds.relayBaseUrl},tenantId=${creds.tenantId}`
       : 'Relay 尚未配置。',
-    summary: { enabled, ...creds },
-    data: { enabled, config, creds }
+    summary: { productMode: product.mode, enabled, ...creds },
+    data: { product, enabled, config, creds }
   });
 });
 
 const qiweiRelaySaveConfig = safeResult(async function qiweiRelaySaveConfig(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') return errorResult('当前为个人版,请先切换到企业版再保存 Relay 配置');
   const relayBaseUrl = String(input.relayBaseUrl || '').trim();
   const tenantId = String(input.tenantId || '').trim();
   const publicKey = String(input.publicKey || '').trim();
@@ -382,6 +399,10 @@ const qiweiRelaySaveConfig = safeResult(async function qiweiRelaySaveConfig(inpu
 });
 
 const qiweiRelayRegister = safeResult(async function qiweiRelayRegister(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') {
+    return errorResult('当前为个人版,请先调用 qiwei_product_mode_set 切换到企业版');
+  }
   const token = readQiweiAuthToken(input);
   if (!token) {
     return {
@@ -432,6 +453,10 @@ const qiweiRelayRegister = safeResult(async function qiweiRelayRegister(input =
 });
 
 const qiweiRelayConnect = safeResult(async function qiweiRelayConnect(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') {
+    return errorResult('当前为个人版,本地监听不连接企业 Relay');
+  }
   if (!isRelayEnabled()) {
     return errorResult('Relay 尚未配置,请先调用 qiwei_relay_register 注册租户');
   }
@@ -548,19 +573,32 @@ function getWebhookEventStats() {
 }
 
 const qiweiWebhookStatus = safeResult(async function qiweiWebhookStatus() {
+  const product = getProductMode();
   const config = readConfig();
   const relayEnabled = isRelayEnabled();
   const stats = getWebhookEventStats();
+  const relay = getRelayCredentials();
+  const publicConfig = { ...config, secret: config.secret ? '[CONFIGURED]' : null };
+  const publicRelay = {
+    relayBaseUrl: relay.relayBaseUrl,
+    tenantId: relay.tenantId,
+    deviceGuid: relay.deviceGuid,
+    apiKeyConfigured: Boolean(relay.tenantApiKey),
+    apiSecretConfigured: Boolean(relay.tenantApiSecret),
+    privateKeyConfigured: Boolean(relay.privateKey),
+  };
   return okResult({
-    assistantMessage: relayEnabled
-      ? `本地 webhook 模式已重新启用;Relay 模式状态:${relayEnabled ? '已启用' : '未启用'}。`
-      : '本地 webhook 模式已重新启用;Relay 尚未配置。',
+    assistantMessage: product.mode === 'enterprise'
+      ? `当前为企业版,服务端统一回调${relayEnabled ? '已配置' : '待配置'}。`
+      : `当前为个人版,使用本地主动监听${product.directCallbackEnabled ? ',独立回调已显式开启' : ''}。`,
     summary: {
-      localMode: 'enabled',
+      productMode: product.mode,
+      collectionMode: product.collectionMode,
+      localMode: product.mode === 'personal' ? 'enabled' : 'disabled',
       relayEnabled,
       ...stats
     },
-    data: { config, relayEnabled, relay: getRelayCredentials(), stats, localServer: getWebhookServerStatus() }
+    data: { product, config: publicConfig, relayEnabled, relay: publicRelay, stats, localServer: getWebhookServerStatus() }
   });
 });
 
@@ -628,6 +666,8 @@ function getRelayChildStatus() {
 }
 
 const qiweiRelayStart = safeResult(async function qiweiRelayStart(input = {}) {
+  const product = getProductMode(input);
+  if (product.mode !== 'enterprise') return errorResult('当前为个人版,不启动企业 Relay Client');
   if (getRelayChildStatus().running) {
     return okResult({
       assistantMessage: `Relay 子进程已在运行中,pid=${relayChildProcess.pid}`,
@@ -688,5 +728,6 @@ module.exports = {
   qiweiRelayStop,
   ensureRelayWebhookConfigured,
   ensureLocalWebhookConfigured,
+  connectRelayThroughFmode,
   registerRelayTenant
 };

+ 2 - 2
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.4.2",
+  "version": "0.4.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "fmode-qiwei",
-      "version": "0.4.2",
+      "version": "0.4.3",
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.12.1",
         "cross-spawn": "7.0.6",

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
claude-code/claude-code-qiwe-assistant/package.json


+ 1 - 0
claude-code/claude-code-qiwe-assistant/scripts/preview-dashboard.js

@@ -53,6 +53,7 @@ function printStartupSummary(baseUrl, summary, workspaceRoot, alreadyRunning) {
     '',
     `企微工作台${alreadyRunning ? '已在运行' : '启动成功'}:${baseUrl}/#agent`,
     `项目目录:${workspaceRoot}`,
+    `产品模式:${summary.product?.label || '个人版'} · ${summary.product?.collectionLabel || '本地主动监听'}`,
     '',
     '启动检查:',
     ...summary.checks.map(item => `  ${item.ready ? '[完成]' : '[待处理]'} ${item.label}${item.ready ? '' : ` — ${item.action}`}`),

+ 61 - 0
claude-code/claude-code-qiwe-assistant/scripts/product-mode-smoke-test.js

@@ -0,0 +1,61 @@
+'use strict';
+
+const assert = require('assert/strict');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const {
+  normalizeProductMode,
+  resolveProductMode,
+  setProductMode,
+} = require('../mcp/src/core/product-mode');
+const { buildStartupSummary } = require('../mcp/src/core/startup-summary');
+
+function main() {
+  assert.equal(normalizeProductMode('个人版'), 'personal');
+  assert.equal(normalizeProductMode('enterprise'), 'enterprise');
+
+  const personal = resolveProductMode({ productMode: 'personal' }, { relayEnabled: true });
+  assert.equal(personal.mode, 'personal');
+  assert.equal(personal.collectionMode, 'local_polling');
+  assert.equal(personal.multiDeviceManagement, false);
+
+  const enterprise = resolveProductMode({ productMode: 'enterprise' }, { relayEnabled: false });
+  assert.equal(enterprise.mode, 'enterprise');
+  assert.equal(enterprise.relayReady, false);
+  assert.equal(enterprise.enterpriseUpgrade.amount, 0);
+  assert.equal(enterprise.enterpriseUpgrade.pricingStatus, 'placeholder');
+
+  const startup = buildStartupSummary(
+    { summary: { authConfigured: true, subscribed: true } },
+    { data: {
+      product: enterprise,
+      account: { configured: true, online: true },
+      listener: { running: true },
+      config: { allowedSenderCount: 1 },
+    } },
+  );
+  assert.equal(startup.ready, false);
+  assert.equal(startup.checks.some(item => item.key === 'relay' && !item.ready), true);
+
+  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-product-mode-'));
+  const envPath = path.join(tempRoot, '.env.local');
+  const previous = process.env.QIWEI_PRODUCT_MODE;
+  try {
+    const saved = setProductMode('personal', { envPath });
+    assert.equal(saved.product.mode, 'personal');
+    assert.match(fs.readFileSync(envPath, 'utf8'), /^QIWEI_PRODUCT_MODE=personal$/m);
+  } finally {
+    if (previous === undefined) delete process.env.QIWEI_PRODUCT_MODE;
+    else process.env.QIWEI_PRODUCT_MODE = previous;
+    fs.rmSync(tempRoot, { recursive: true, force: true });
+  }
+
+  process.stdout.write(`${JSON.stringify({ status: 'ok', checks: 12 }, null, 2)}\n`);
+}
+
+try { main(); }
+catch (error) {
+  process.stderr.write(`${error.stack || error.message}\n`);
+  process.exit(1);
+}

+ 45 - 4
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -55,8 +55,11 @@ const {
 const { qiweiTranscribeVoice } = require('../mcp/src/tools/qiwei-voice-run');
 const {
   qiweiWebhookStatus,
-  qiweiWebhookAutoSetup
+  qiweiWebhookAutoSetup,
+  connectRelayThroughFmode
 } = require('../mcp/src/tools/qiwei-webhook-relay-run');
+const { getProductMode } = require('../mcp/src/core/product-mode');
+const { qiweiProductModeStatus } = require('../mcp/src/tools/qiwei-product-mode-run');
 
 const PRIVATE_PROVIDER_NAME = ['private', 'provider'].join('-');
 const PRIVATE_PROVIDER_HOST = ['private', 'provider', 'invalid'].join('.');
@@ -174,6 +177,7 @@ async function startMockGateway({ subscribed = true } = {}) {
     } else if (url.pathname === '/subscribe/status') {
       data = {
         subscribed: subscriptionActive,
+        price: 500,
         seats: 2,
         usedSeats: 1,
         expireAt: '2026-08-10T00:00:00.000Z',
@@ -185,12 +189,23 @@ async function startMockGateway({ subscribed = true } = {}) {
       data = {
         seats: body.seats || 1,
         months: body.months || 1,
-        amount: (body.seats || 1) * (body.months || 1) * 200,
+        amount: (body.seats || 1) * (body.months || 1) * 500,
         expireAt: '2026-08-10T00:00:00.000Z',
         autoRenew: true
       };
     } else if (url.pathname === '/subscribe/autoRenew') {
       data = { autoRenew: body.autoRenew };
+    } else if (url.pathname === '/relay/connect') {
+      data = { connected: true, uid: body.uid };
+    } else if (url.pathname === '/product/enterprise/quote') {
+      data = {
+        sku: 'qiwei-enterprise-relay',
+        amount: 0,
+        currency: 'CNY',
+        billingCycle: 'month',
+        pricingStatus: 'placeholder',
+        chargeOnQuote: false
+      };
     } else {
       res.writeHead(404, { 'Content-Type': 'application/json' });
       res.end(JSON.stringify({ code: 404, mess: 'not found' }));
@@ -282,9 +297,31 @@ async function main() {
     authToken: 'sk-test-relay-token',
     uid: 'qiwei-smoke-device',
     apiBase: gateway.apiBase,
+    productMode: 'personal',
     persistConfig: false
   };
   try {
+    const productStatus = await qiweiProductModeStatus(common);
+    assert.strictEqual(productStatus.status, 'ok');
+    assert.strictEqual(productStatus.summary.mode, 'personal');
+    assert.strictEqual(productStatus.summary.enterpriseUpgrade.amount, 0);
+    assert.strictEqual(productStatus.summary.enterpriseUpgrade.source, 'server');
+    const quoteRequest = gateway.requests.at(-1);
+    assert.strictEqual(quoteRequest.path, '/product/enterprise/quote');
+    assert.strictEqual(quoteRequest.method, 'GET');
+    assert.strictEqual(quoteRequest.authorization, 'Bearer sk-test-relay-token');
+    assert.strictEqual(quoteRequest.body, undefined);
+    console.log('[ok] product mode uses the server enterprise quote without charging');
+
+    const relayConnection = await connectRelayThroughFmode(common);
+    assert.strictEqual(relayConnection.connected, true);
+    const relayConnectRequest = gateway.requests.at(-1);
+    assert.strictEqual(relayConnectRequest.path, '/relay/connect');
+    assert.strictEqual(relayConnectRequest.authorization, 'Bearer sk-test-relay-token');
+    assert.deepStrictEqual(relayConnectRequest.body, { uid: 'qiwei-smoke-device' });
+    assert(!JSON.stringify(relayConnectRequest.body).match(/callback|secret|guid/i));
+    console.log('[ok] relay connection uses the dedicated server endpoint without client callback credentials');
+
     const call = await callQiweiApi({ ...common, id: 'user.getProfile', params: {} });
     assert.strictEqual(call.status, 'ok');
     const apiRequest = gateway.requests.at(-1);
@@ -349,6 +386,8 @@ async function main() {
       assert.ok(!checkoutPage.includes(checkoutGateway.apiBase), 'dynamic checkout page must not expose the gateway URL');
       const tokenMatch = checkoutPage.match(/const CHECKOUT_TOKEN = "([^"]+)";/);
       assert.ok(tokenMatch && tokenMatch[1], 'dynamic checkout page should carry a local one-time confirmation');
+      const checkoutState = await fetch(`${checkoutUrl}flow/state`).then(response => response.json());
+      assert.strictEqual(checkoutState.monthlyPrice, 500);
       const checkoutBody = {
         seats: 3,
         months: 6,
@@ -607,11 +646,13 @@ async function main() {
 
     const statusBefore = await qiweiWebhookStatus({});
     assert.strictEqual(statusBefore.status, 'ok');
-    assert.strictEqual(statusBefore.summary.localMode, 'enabled');
+    const currentProduct = getProductMode();
+    assert.strictEqual(statusBefore.summary.productMode, currentProduct.mode);
+    assert.strictEqual(statusBefore.summary.localMode, currentProduct.mode === 'personal' ? 'enabled' : 'disabled');
     if (!statusBefore.summary.relayEnabled) {
       const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
       assert.strictEqual(autoSetupWithoutRelay.status, 'error');
-      assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('Relay is not configured'), true);
+      assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('个人版'), true);
     }
     console.log('[ok] webhook status reflects local mode and avoids external relay setup during smoke tests');
 

+ 23 - 0
claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js

@@ -13,6 +13,7 @@
 const crypto = require('crypto');
 const { processWebhookEvents } = require('../mcp/src/core/webhook-server');
 const { readQiweiGuid } = require('../mcp/src/core/credentials');
+const { getProductMode } = require('../mcp/src/core/product-mode');
 const {
   getRelayBaseUrl,
   getTenantApiSecret,
@@ -27,6 +28,23 @@ const MAX_BACKOFF_MS = 60000;
 
 function decryptPayload(encryptedPayload, privateKeyPem) {
   const key = crypto.createPrivateKey(privateKeyPem);
+  if (String(encryptedPayload).startsWith('v2:')) {
+    const envelope = JSON.parse(Buffer.from(String(encryptedPayload).slice(3), 'base64').toString('utf8'));
+    const aesKey = crypto.privateDecrypt(
+      { key, oaepHash: 'sha256' },
+      Buffer.from(envelope.key, 'base64')
+    );
+    const decipher = crypto.createDecipheriv(
+      'aes-256-gcm',
+      aesKey,
+      Buffer.from(envelope.iv, 'base64')
+    );
+    decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
+    return Buffer.concat([
+      decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
+      decipher.final()
+    ]).toString('utf8');
+  }
   const buffer = Buffer.from(encryptedPayload, 'base64');
   const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
   return decrypted.toString('utf8');
@@ -100,6 +118,11 @@ function resolveGuid() {
 }
 
 async function main() {
+  const product = getProductMode();
+  if (product.mode !== 'enterprise') {
+    console.error('[RelayClient] 当前为个人版,请使用工作台本地监听;企业 Relay Client 未启动');
+    process.exit(1);
+  }
   const baseUrl = getRelayBaseUrl();
   const apiSecret = getTenantApiSecret();
   const privateKey = getRelayPrivateKey();

+ 4 - 4
claude-code/claude-code-qiwe-assistant/skill-package-manifest.json

@@ -1,6 +1,6 @@
 {
   "name": "fmode-qiwei",
-  "version": "0.4.2",
+  "version": "0.4.3",
   "status": "published",
   "plugin": "qiwei-assistant",
   "description": "企业微信智能办公技能包:日常办公、目标推进、智能客服、CRM 客户管理和底层 Skills/API。",
@@ -9,7 +9,7 @@
     "goalManagement": ["大目标拆解", "里程碑", "会议行动项", "进度/阻塞/逾期跟踪", "企微待办衔接"],
     "customerService": ["真实私信监听", "客户独立 Claude Code Session", "Agent 草稿", "人工审核", "人工接管", "安全会话审阅"],
     "crm": ["客户档案", "客户群", "群消息", "画像", "标签", "顾问 Playbook", "客户交接"],
-    "foundation": ["Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
+    "foundation": ["个人版本地监听", "企业版统一 Relay", "Fmode 网关", "官方 CLI", "104 个接口清单", "MCP 工具", "Webhook/Relay", "白名单与审计"]
   },
   "skills": [
     "qiwei-api-catalog",
@@ -30,14 +30,14 @@
     "qiwei-voice",
     "qiwei-webhook-relay"
   ],
-  "mcpToolCount": 75,
+  "mcpToolCount": 77,
   "apiCatalogCount": 104,
   "npmPackage": "fmode-qiwei",
   "installCommand": "npx --yes fmode-qiwei@latest workspace --smoke",
   "previewCommand": "npx --yes fmode-qiwei@latest preview",
   "workspaceInstallTarget": ".claude/plugins/qiwei-assistant",
   "dashboardUrl": "http://127.0.0.1:4320/",
-  "startupChecks": ["Fmode 鉴权", "企微席位", "企微账号在线", "测试白名单", "AI 监听"],
+  "startupChecks": ["产品模式", "Fmode 鉴权", "企微席位", "企微账号在线", "企业 Relay(企业版)", "测试白名单", "AI 监听"],
   "sessionReviewCommand": "npm run agent:session -- --customer <客户名称>",
   "security": [
     "安装包不复制 .env.local、真实 outputs、Playwright 会话或 node_modules",

+ 1 - 1
claude-code/claude-code-qiwe-assistant/skills/qiwei-api-catalog/SKILL.md

@@ -55,7 +55,7 @@ Fmode 网关负责:
 - `label`:个人标签和客户标签;
 - `session`:会话分页与会话组;
 - `user`:个人与企业信息;
-- `cloud`:文件接口;multipart 文件直传目前不经过中转;
+- `cloud`:文件接口;本地文件通过 `qiwei_api_call` 的 `filePath` 字段走 Fmode multipart 中转;
 - `login` / `client`:仅用于文档检索,实际操作走 `qiwei-login`。
 
 ## 注意事项

+ 9 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-capability-router/SKILL.md

@@ -7,6 +7,15 @@ description: 在真实企微会话、本地 Agent 工作台、Fmode 网关与官
 
 本技能包包含真实消息、内部任务中枢、Fmode 网关和官方 CLI 等相互独立的通道,不混用认证信息,也不因某个官方分类未开放而中断其他能力。
 
+## 产品模式优先判断
+
+处理消息接收、Webhook、Relay、设备聚合或企业管理请求前,先调用 `qiwei_product_mode_status`:
+
+- 个人版:使用本地主动监听和当前项目数据,不注册或启动企业 Relay;
+- 企业版:使用服务端统一回调和企业 Relay,可以接入多企微设备;
+- 用户要求升级或降级时调用 `qiwei_product_mode_set`,并按返回步骤重启相关进程;
+- 企业版是独立增值服务,当前 0 元只是报价占位,不和企微席位月费混用。
+
 ## 路由规则
 
 | 用户意图 | 使用通道 |

+ 12 - 7
claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

@@ -1,24 +1,26 @@
 ---
 name: qiwei-webhook-relay
-description: 企微 Webhook 与 Relay:支持中央 Relay 模式自动注册/配置、Relay 长轮询客户端
+description: 企微 Webhook 与 Relay:支持企业版中央 Relay 自动接入,以及隔离部署下的显式直连模式。
 ---
 
 # Webhook 与 Relay
 
 ## 使用边界
 
+先调用 `qiwei_product_mode_status` 判断产品模式。个人版默认使用本地主动监听;企业版才进入中央 Relay 注册和服务端回调流程。
+
 本 skill 负责:
 
 - 查询 Relay 回调配置状态;
 - 在中央 Relay 服务端注册租户、保存凭证;
-- 自动配置企微回调地址到 Relay
+- 通过 Fmode 服务端专用接口配置企微全局回调
 - 管理 Relay 配置。
 
-> **本地 webhook server 模式已禁用。** 所有回调统一通过中央 Relay 接收
+> 默认禁止本地进程直接覆盖企微回调。独立部署确需直连时,必须显式设置 `QIWEI_ALLOW_DIRECT_CALLBACK=true`
 
 ## 模式说明
 
-### 中央 Relay 模式(唯一可用
+### 中央 Relay 模式(默认
 
 企微平台把事件推送到 Fmode 中央 Relay 服务器(默认 `http://8.138.37.248:4000`),本地 Skill 通过长轮询主动取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
 
@@ -37,7 +39,10 @@ description: 企微 Webhook 与 Relay:仅支持中央 Relay 模式自动注册
    - 提取并保存设备 `guid`;
    - 在中央 Relay 注册租户(如未注册);
    - 保存租户凭证到 `.env.local`;
-   - 调用 `/client/setCallback` 把回调地址设为 `{RELAY_BASE_URL}/api/webhook/ingest/{tenantId}/{guid}`;
+   - 将当前设备注册到 Relay,用真实 `guid` 建立租户路由;
+   - 调用 Fmode 专用接口 `POST /relay/connect`;
+   - 由 Fmode 服务端使用服务端密钥,把 Token 级全局回调设为 `{RELAY_BASE_URL}/api/webhook/ingest`;
+   - 客户端不会接触或提交全局回调签名密钥;
    - 返回配置结果,并提示启动 Relay 轮询客户端。
 4. 在服务器上执行 `npm run relay` 启动长轮询(或使用 systemd/pm2 持久化运行)。
 
@@ -106,10 +111,10 @@ pm2 save
 ## MCP 工具
 
 - `qiwei_webhook_status` — 查询 Relay 回调配置状态
-- `qiwei_webhook_auto_setup` — 自动配置企微回调到 Relay(本地模式已禁用)
+- `qiwei_webhook_auto_setup` — 注册设备并通过 Fmode 专用接口接入中央 Relay
 - `qiwei_relay_config` — 读取 Relay 配置
 - `qiwei_relay_save_config` — 保存 Relay 公钥/租户配置
 - `qiwei_relay_register` — 注册 Relay 租户并保存凭证
 - `qiwei_relay_connect` — 配置 Relay 回调地址
 
-> 已移除的本地模式工具:`qiwei_webhook_discover`、`qiwei_webhook_server_start`、`qiwei_webhook_server_stop`、`qiwei_webhook_setup`
+> 本地模式工具仅供隔离部署使用;默认调用会被拒绝,防止共享 Token 的全局回调被个人电脑覆盖

+ 2 - 0
docs/企微技能包/03-企微技能包产品化架构与功能.md

@@ -11,6 +11,8 @@
 
 客户取得技能包代码后,可以根据自己的行业和业务修改页面、Skills、知识库、数据适配器和业务规则。
 
+产品运行时明确分为个人版和企业版:个人版使用本地主动监听和单设备本地数据;企业版使用服务端统一回调、多设备消息归集、企业租户隔离和组织权限。双模式的边界、升级和验收要求见 [05-企微个人版与企业版双模式方案.md](05-企微个人版与企业版双模式方案.md)。
+
 ## 二、目标用户
 
 | 用户角色 | 主要需求 |

+ 292 - 0
docs/企微技能包/05-企微个人版与企业版双模式方案.md

@@ -0,0 +1,292 @@
+# 企微个人版与企业版双模式方案
+
+## 一、方案结论
+
+企微技能包正式分为两种产品模式:
+
+1. **个人版(单机模式)**:解决一个人使用 AI 管理自己企微账号的问题。消息在当前项目本地接收、处理和保存,以单设备、本地工作台和个人效率为核心。
+2. **企业版(企业 Relay 模式)**:解决一个企业统一管理多个企微设备、客服、客户和智能体的问题。消息先进入企业服务端,完成租户隔离、设备路由、去重和加密,再供企业工作台统一管理。
+
+两种模式共用登录、企微席位、基础接口、Agent、Skills 和本地工作台。企业版是在个人版能力之上增加服务端消息底座、组织权限和企业管理能力,不重新制作另一套技能包。
+
+对外产品口径:
+
+> 个人版解决“一个人如何用 AI 管好自己的企微”;企业版解决“一个企业如何统一管理多个企微设备、客服、客户和智能体”。
+
+## 二、模式名称和技术边界
+
+| 项目 | 个人版 | 企业版 |
+|---|---|---|
+| 产品名称 | 企微个人版 | 企微企业版 |
+| 技术模式 | `personal` | `enterprise` |
+| 消息入口 | 本地主动监听;隔离部署可选本地直连回调 | Fmode 服务端统一回调 |
+| 默认存储 | 当前项目本地 SQLite 和文件 | 企业服务端分租户消息队列 + 企业项目数据 |
+| 设备范围 | 单企微设备为主 | 多企微设备统一接入 |
+| 管理范围 | 当前使用者 | 企业、部门、员工、设备、客户和会话 |
+| 电脑离线 | 离线期间本地处理停止 | 服务端继续接收并排队,客户端恢复后领取 |
+| 数据聚合 | 当前账号数据 | 企业授权范围内跨设备聚合 |
+| 权限 | 本机项目权限 | 企业 RBAC、数据范围和操作审计 |
+| 服务形态 | 标准技能包能力 | 付费增值服务 |
+
+“个人版本地接收”不等于企微平台直接回调 `127.0.0.1`。默认采用本地主动监听;只有客户具备独立服务 Token、独立公网地址和独立服务器时,才允许显式开启直连回调。
+
+## 三、个人版方案
+
+### 1. 目标用户
+
+- 独立顾问、销售、经纪人和客服;
+- 只需要管理自己企微账号的员工;
+- 用于试用、验证和单账号业务定制的客户。
+
+### 2. 运行架构
+
+```mermaid
+flowchart LR
+    A["企微客户消息"] --> B["Fmode 企微接口通道"]
+    B --> C["客户电脑本地主动监听"]
+    C --> D["本地消息数据库"]
+    D --> E["画像、待办、预警"]
+    D --> F["Agent 草稿与自动回复"]
+    F --> G["人工审核与接管"]
+```
+
+### 3. 核心能力
+
+- 单企微账号扫码登录、在线状态和掉线恢复;
+- 私聊和已确认客户群消息监听;
+- 白名单、待审核、高置信自动、人工接管和全局暂停;
+- 客户画像、需求、标签、待办、预警和时间线;
+- 每个客户绑定独立 Claude Code Session;
+- 本地知识库、业务 Skills 和行业数据适配;
+- 本地 SQLite、配置、知识库和输出文件备份;
+- 4320 本地工作台。
+
+### 4. 数据边界
+
+- 数据默认只在当前项目目录保存;
+- 不建立企业级跨设备客户总表;
+- 不提供部门、员工和多角色权限;
+- 不承诺电脑离线期间由平台持续保存消息;
+- 一个项目切换不同企微账号时,数据库和客户 Session 按账号隔离。
+
+### 5. 个人版完成标准
+
+1. 安装后能够完成席位开通、企微登录和工作台启动;
+2. 明确显示“个人版 · 本地主动监听”;
+3. 白名单为空时不能启动真实自动处理;
+4. 不调用企业 Relay 注册、回调连接或长轮询;
+5. 账号、会话、消息、客户 Session 和输出目录隔离;
+6. 停止 AI 监听后切换为人工处理,已有客户数据不丢失。
+
+## 四、企业版方案
+
+### 1. 目标用户
+
+- 多客服、多销售、多门店和多企微账号企业;
+- 需要统一客户运营、客服管理和管理看板的团队;
+- 需要消息留存、设备健康、权限审计和业务系统集成的客户。
+
+### 2. 运行架构
+
+```mermaid
+flowchart TB
+    M["多个企微设备"] --> U["Token 级全局回调"]
+    U --> R["企业 Relay"]
+    R --> T["按 tenantId 隔离企业"]
+    T --> D["按 deviceGuid 路由设备"]
+    D --> Q["去重、加密、排队"]
+    Q --> W["企业工作台或企业本地项目"]
+    W --> O["组织、员工和权限"]
+    W --> C["统一客户与会话"]
+    W --> A["企业 Agent 策略"]
+    W --> BI["运营、客服和转化分析"]
+```
+
+### 3. 服务端回调的价值
+
+- 同时接收多个企微设备的新消息;
+- 客户电脑短时离线时,服务端仍可接收并暂存消息;
+- 按企业和设备完成统一路由,避免消息串租户、串账号和串会话;
+- 为企业设备、客服、客户、会话和 Agent 建立统一管理入口;
+- 支持统一回复时效、预警、审计和运营统计;
+- 后续可以对接企业 CRM、订单、商品、房源和组织系统。
+
+企微回调对服务 Token 全局生效,因此新部署统一使用:
+
+```text
+{RELAY_BASE_URL}/api/webhook/ingest
+```
+
+Relay 从消息体读取真实 `guid` 决定消息归属。不能为每个客户配置不同回调地址,否则最后一次配置会覆盖此前配置。
+
+### 4. 企业隔离模型
+
+| 隔离层 | 主键或范围 | 规则 |
+|---|---|---|
+| 企业租户 | `tenantId` | 不同企业的设备、队列、密钥和数据完全隔离 |
+| Fmode 身份 | `ownerKey` | 只从服务端鉴权结果生成,不接受客户端伪造 |
+| 企微设备 | `deviceGuid` | 一个真实设备只能属于一个有效租户 |
+| 本地设备别名 | `uid` | 在同一 Fmode 身份下映射真实设备,不作为消息路由依据 |
+| 会话 | `conversationId` | 私聊和群聊分别建会话,不能用当前页面会话兜底 |
+| 消息 | `messageId` | 服务端和本地两级幂等去重,避免重复入库、回复和待办 |
+| 客户 | 企业客户主键 + 可靠外部标识 | 不得仅凭昵称合并跨设备客户 |
+| 用户权限 | 企业、部门、角色、员工 | 查看、回复、交接、配置和导出分别授权 |
+
+### 5. 企业级管理能力
+
+#### 设备与席位
+
+- 企微设备列表、账号、所属员工、部门和席位;
+- 在线、离线、最后活跃、回调、Relay Client 和队列状态;
+- 设备登录、重新连接、停用、交接和异常告警;
+- 单设备详情和企业全局健康视图。
+
+#### 消息与客服
+
+- 企业范围内私聊和客户群消息统一检索;
+- 按设备、员工、部门、客户、群和时间筛选;
+- 回复时效、久未回复、客户沉默和投诉预警;
+- Agent 待审核、高置信自动、人工接管和全局暂停策略;
+- 草稿、审核、发送、失败和接管操作审计。
+
+#### 客户与 CRM
+
+- 客户归属员工、部门、门店和企微设备;
+- 在可靠标识基础上建立企业统一客户视图;
+- 聚合画像、需求、标签、待办、预警、推荐和时间线;
+- 客户交接、离职继承、协作跟进和资料包;
+- 对接企业 CRM、商品、房源、订单和任务系统。
+
+#### 组织与权限
+
+- 企业管理员、部门管理员、客服主管、客服和只读审计角色;
+- 按企业、部门、员工、设备和客户范围授权;
+- 敏感配置、导出、自动发送和全局策略需要独立权限;
+- 所有管理操作记录操作者、时间、对象、结果和失败原因。
+
+#### 企业分析
+
+- 各客服消息量、响应时长、待办完成和客户推进;
+- 各设备在线率、错误率、积压量和消息处理状态;
+- 客户阶段、意向、风险和转化漏斗;
+- 门店、部门和企业汇总视图。
+
+### 6. 企业版完成标准
+
+1. 多设备消息经过真实 `guid` 正确进入所属租户;
+2. 同一设备不能跨租户重复绑定;
+3. 服务端完成签名验证、去重、加密、排队和 ACK;
+4. 本地或企业工作台只能领取授权设备消息;
+5. 企业、设备、会话、消息和用户权限隔离均有自动化测试;
+6. 设备离线和客户端暂停期间的消息可在恢复后继续处理;
+7. 管理员可以统一查看设备健康、消息积压和客服处理状态;
+8. 日志、接口和前端不返回服务端回调密钥、租户密钥或私钥。
+
+## 五、升级与付费占位
+
+### 1. 计费关系
+
+企微席位与企业版增值服务是两项独立资源:
+
+| 资源 | 计费对象 | 当前口径 |
+|---|---|---|
+| 企微账号席位 | 每个企微账号 | 现有席位订阅规则 |
+| 企业 Relay 增值服务 | 企业租户 | 独立升级 SKU,当前金额 0 元占位 |
+
+企业版升级 SKU:
+
+```text
+qiwei-enterprise-relay
+```
+
+服务端报价接口:
+
+```http
+GET /api/qiwei/product/enterprise/quote
+```
+
+当前返回的关键字段:
+
+```json
+{
+  "sku": "qiwei-enterprise-relay",
+  "amount": 0,
+  "currency": "CNY",
+  "billingCycle": "month",
+  "pricingStatus": "placeholder",
+  "chargeOnQuote": false
+}
+```
+
+当前 `0` 元只用于接口、页面和流程占位,不代表企业版长期免费。报价接口不执行扣费。后续确定金额后,由服务端调整报价并增加支付、权益状态和续费校验,客户端继续使用相同 SKU 和产品模式协议。
+
+### 2. 升级流程
+
+```text
+个人版
+  -> 查看企业版能力和服务端报价
+  -> 确认升级
+  -> 服务端校验企业版权益
+  -> 设置 QIWEI_PRODUCT_MODE=enterprise
+  -> 注册企业 Relay 租户
+  -> 注册企微设备 guid
+  -> 连接服务端统一回调
+  -> 启动 Relay Client 或企业工作台
+```
+
+从企业版降回个人版时保留原 Relay 凭据和历史数据,但停止企业 Relay Client,后续消息改由本地主动监听处理。
+
+## 六、技能包模式协议
+
+项目配置:
+
+```text
+QIWEI_PRODUCT_MODE=personal
+```
+
+或:
+
+```text
+QIWEI_PRODUCT_MODE=enterprise
+```
+
+兼容旧项目:未配置时,如果检测到完整 Relay 凭据则判断为企业版,否则判断为个人版。
+
+MCP 工具:
+
+- `qiwei_product_mode_status`:查看当前模式、消息接收方式、Relay 状态和企业升级占位;
+- `qiwei_product_mode_set`:切换个人版或企业版,并返回需要重启或继续配置的步骤;
+- `qiwei_relay_register`、`qiwei_relay_connect`:仅企业版可用;
+- 本地直连回调:仅个人版隔离部署并显式设置 `QIWEI_ALLOW_DIRECT_CALLBACK=true` 时可用。
+
+## 七、现阶段完成情况
+
+### 已完成
+
+- 个人版真实消息本地主动监听;
+- 每个企微账号独立本地数据库和客户 Session;
+- 企业 Relay 租户、设备注册、加密队列、长轮询和 ACK;
+- Token 级全局回调入口;
+- 按真实 `guid` 路由并防止跨租户设备冲突;
+- Future Server `/relay/connect` 专用接口源码和构建产物;
+- 个人版/企业版显式模式、状态展示和模式切换工具;
+- 企业版独立 SKU 与 0 元报价占位接口。
+
+### 下一阶段
+
+1. 将最新 Future Server CJS 部署到线上并配置企业 Relay 环境变量;
+2. 建立企业、部门、员工、角色和数据权限模型;
+3. 建立服务端设备管理、消息索引和企业统一工作台;
+4. 建立可靠的跨设备客户映射,禁止按昵称直接合并;
+5. 增加企业设备健康、积压、响应时效和审计看板;
+6. 确定企业版价格,补支付、权益、续费、到期降级和退款规则;
+7. 完成多企业、多设备、跨会话和故障恢复压测。
+
+## 八、产品验收原则
+
+- 个人版优先保证安装简单、单账号稳定、本地数据可控;
+- 企业版优先保证消息稳定、租户隔离、设备路由和权限审计;
+- 企业版可以汇总授权数据,但不能破坏企业、设备和会话隔离;
+- 产品模式、席位订阅和企业增值服务三者分别建模;
+- 所有价格、权益和敏感配置由服务端决定,前端和技能包只展示结果;
+- 企业管理能力按统一事件协议建设,后续可切换到企微官方会话存档通道。

+ 1 - 1
release/npm发布管理文档/NPM技能包发布管理.md

@@ -30,7 +30,7 @@
 | fmode 视觉分析 | `fmode-vision` | `0.1.1` | 已发布 | `claude-code/fmode-vision/`(如有) | https://www.npmjs.com/package/fmode-vision |
 | fmode 音视频处理 | `fmode-ffmpeg` | `0.1.1` | 已发布 | `claude-code/fmode-ffmpeg/` | https://www.npmjs.com/package/fmode-ffmpeg |
 | fmode 录音转写 | `fmode-listen` | `0.1.1` | 已发布 | `claude-code/fmode-listen/`(如有) | https://www.npmjs.com/package/fmode-listen |
-| fmode 企业微信智能办公 | `fmode-qiwei` | `0.4.2` | 已发布 | `claude-code/claude-code-qiwe-assistant/` | https://www.npmjs.com/package/fmode-qiwei |
+| fmode 企业微信智能办公 | `fmode-qiwei` | `0.4.3` | 已发布 | `claude-code/claude-code-qiwe-assistant/` | https://www.npmjs.com/package/fmode-qiwei |
 | 装修量尺图缺标记检查 | `@gangvy/claude-code-measurement-marker-check` | `0.1.2` | 已发布 | `claude-code-measurement-marker-check/` | https://www.npmjs.com/package/@gangvy/claude-code-measurement-marker-check |
 
 ## 安装入口

+ 6 - 0
release/npm发布管理文档/变更记录/fmode-qiwei.md

@@ -2,6 +2,12 @@
 
 > 当前版本号见 [`../NPM技能包发布管理.md`](../NPM技能包发布管理.md) 的「发布状态总览」。本文按版本号倒序记录 `fmode-qiwei` 的更新内容。
 
+## 0.4.3(2026-07-24)
+
+- 企微订阅价格调整为 500 元/席位/月,与 Future Server 实际扣费规则保持一致。
+- 本地开通页改为优先读取 `/subscribe/status` 返回的实时价格,500 元只作为接口异常时的回退值,避免客户端展示价和服务端扣费价不同步。
+- 更新套餐金额、登录说明和订阅流程文档;通过服务端计价单元测试、技能包完整 smoke、Agent smoke、打包检查与线上安装验证。
+
 ## 0.4.2(2026-07-23)
 
 - 已发布并验证 npm `latest=0.4.2`(142 个文件,shasum `cbccf719…`)。

+ 81 - 0
services/qiwei-relay/lib/config.ts

@@ -0,0 +1,81 @@
+/**
+ * Relay 配置加载
+ */
+
+export function getPort(): number {
+  return process.env.PORT ? parseInt(process.env.PORT, 10) : 4000;
+}
+
+export function getDbPath(): string {
+  return process.env.DB_PATH || './data/relay.db';
+}
+
+export function getAdminKey(): string {
+  return process.env.ADMIN_KEY || '';
+}
+
+/** Shared secret configured once on the upstream token-wide callback. */
+export function getUpstreamCallbackSecret(): string {
+  return process.env.UPSTREAM_CALLBACK_SECRET || '';
+}
+
+export function getLogLevel(): string {
+  return process.env.LOG_LEVEL || 'info';
+}
+
+export function getEventTtlSeconds(): number {
+  return process.env.EVENT_TTL_SECONDS ? parseInt(process.env.EVENT_TTL_SECONDS, 10) : 86400;
+}
+
+export function getMaxPendingEventsPerTenant(): number {
+  return process.env.MAX_PENDING_EVENTS_PER_TENANT
+    ? parseInt(process.env.MAX_PENDING_EVENTS_PER_TENANT, 10)
+    : 5000;
+}
+
+export function getPollBatchSize(): number {
+  return process.env.POLL_BATCH_SIZE ? parseInt(process.env.POLL_BATCH_SIZE, 10) : 100;
+}
+
+export function getPollWaitMs(): number {
+  return process.env.POLL_WAIT_MS ? parseInt(process.env.POLL_WAIT_MS, 10) : 30000;
+}
+
+/** Relay 对外公网地址(注册接口返回给客户端) */
+export function getRelayPublicUrl(): string {
+  return (
+    process.env.RELAY_PUBLIC_URL ||
+    `http://${process.env.HOST || '8.138.37.248'}:${getPort()}`
+  );
+}
+
+/** Fmode 网关地址,用于校验用户 token */
+export function getFmodeGatewayUrl(): string {
+  return (process.env.FMODE_GATEWAY_URL || 'https://server.fmode.cn/api/qiwei').replace(/\/$/, '');
+}
+
+/** 校验 Fmode token 时调用的 doApi method(默认用 /user/getProfile) */
+export function getFmodeTokenValidateMethod(): string {
+  return process.env.FMODE_TOKEN_VALIDATE_METHOD || '/user/getProfile';
+}
+
+/** 注册接口 IP 限流窗口(秒),默认 60 秒 */
+export function getRegisterRateLimitSeconds(): number {
+  return process.env.REGISTER_RATE_LIMIT_SECONDS
+    ? parseInt(process.env.REGISTER_RATE_LIMIT_SECONDS, 10)
+    : 60;
+}
+
+/** 注册接口单个 IP 在窗口内最多允许次数 */
+export function getRegisterRateLimitPerIp(): number {
+  return process.env.REGISTER_RATE_LIMIT_PER_IP
+    ? parseInt(process.env.REGISTER_RATE_LIMIT_PER_IP, 10)
+    : 5;
+}
+
+/** 单个 Fmode 用户在窗口内最多注册次数(通常 =1) */
+export function getRegisterRateLimitPerUser(): number {
+  return process.env.REGISTER_RATE_LIMIT_PER_USER
+    ? parseInt(process.env.REGISTER_RATE_LIMIT_PER_USER, 10)
+    : 1;
+}

+ 89 - 0
services/qiwei-relay/lib/crypto.ts

@@ -0,0 +1,89 @@
+/**
+ * Relay 加密与安全工具
+ */
+
+import crypto from 'crypto';
+
+const RSA_KEY_SIZE = 2048;
+
+export interface KeyPair {
+  publicKey: string;
+  privateKey: string;
+}
+
+export function generateKeyPair(): KeyPair {
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+    modulusLength: RSA_KEY_SIZE,
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
+  });
+  return { publicKey, privateKey };
+}
+
+export function encryptWithPublicKey(publicKeyPem: string, plaintext: string): string {
+  const publicKey = crypto.createPublicKey(publicKeyPem);
+  const aesKey = crypto.randomBytes(32);
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv('aes-256-gcm', aesKey, iv);
+  const ciphertext = Buffer.concat([
+    cipher.update(Buffer.from(plaintext, 'utf8')),
+    cipher.final(),
+  ]);
+  const wrappedKey = crypto.publicEncrypt(
+    { key: publicKey, oaepHash: 'sha256' },
+    aesKey,
+  );
+  const envelope = {
+    v: 2,
+    alg: 'RSA-OAEP-256+A256GCM',
+    key: wrappedKey.toString('base64'),
+    iv: iv.toString('base64'),
+    tag: cipher.getAuthTag().toString('base64'),
+    ciphertext: ciphertext.toString('base64'),
+  };
+  return `v2:${Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64')}`;
+}
+
+export function hashApiSecret(secret: string): string {
+  return crypto.createHmac('sha256', 'qiwei-relay-secret-salt').update(secret).digest('hex');
+}
+
+export function generateApiCredentials(): { apiKey: string; apiSecret: string } {
+  return {
+    apiKey: 'qk_' + crypto.randomBytes(16).toString('hex'),
+    apiSecret: crypto.randomBytes(32).toString('hex'),
+  };
+}
+
+export function generateRelaySecret(): string {
+  return crypto.randomBytes(32).toString('hex');
+}
+
+export function verifyHmacSignature(secret: string, rawBody: string, signature: string): boolean {
+  try {
+    const expected = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
+    return timingSafeEqual(expected, signature);
+  } catch {
+    return false;
+  }
+}
+
+export function verifyBearerToken(secret: string, authHeader: string | undefined): boolean {
+  if (!authHeader) return false;
+  const match = authHeader.match(/^Bearer\s+(.+)$/i);
+  if (!match) return false;
+  return timingSafeEqual(match[1], secret);
+}
+
+function timingSafeEqual(a: string, b: string): boolean {
+  if (a.length !== b.length) return false;
+  try {
+    return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
+  } catch {
+    return false;
+  }
+}
+
+export function sha256Hash(input: string): string {
+  return crypto.createHash('sha256').update(input).digest('hex');
+}

+ 211 - 0
services/qiwei-relay/lib/db.ts

@@ -0,0 +1,211 @@
+/**
+ * Relay 数据库封装 — 轻量版
+ *
+ * 与主项目 lib/db.ts 类似,但独立维护,避免跨服务依赖。
+ */
+
+import Database from 'better-sqlite3';
+import type { Database as DatabaseType } from 'better-sqlite3';
+import path from 'path';
+import fs from 'fs';
+import { randomUUID } from 'crypto';
+
+export interface DbRow {
+  objectId: string;
+  createdAt: string;
+  updatedAt: string;
+  [key: string]: unknown;
+}
+
+export interface ColumnDef {
+  name: string;
+  type: 'TEXT' | 'INTEGER' | 'REAL' | 'BOOLEAN';
+  required?: boolean;
+  defaultValue?: unknown;
+}
+
+let db: DatabaseType;
+
+export function initDb(dbPath: string): void {
+  fs.mkdirSync(path.dirname(dbPath), { recursive: true });
+  db = new Database(dbPath);
+  db.pragma('journal_mode = WAL');
+  db.pragma('foreign_keys = ON');
+}
+
+export function getDb(): DatabaseType {
+  if (!db) throw new Error('数据库未初始化,请先调用 initDb()');
+  return db;
+}
+
+export function createTable(tableName: string, columns: ColumnDef[]): void {
+  const systemColumns = `
+    objectId TEXT PRIMARY KEY,
+    createdAt TEXT NOT NULL DEFAULT (datetime('now')),
+    updatedAt TEXT NOT NULL DEFAULT (datetime('now'))
+  `;
+
+  const businessColumns = columns
+    .map((col) => {
+      let sql = `"${col.name}" ${col.type}`;
+      if (col.required) sql += ' NOT NULL';
+      if (col.defaultValue !== undefined) {
+        const dv =
+          typeof col.defaultValue === 'string'
+            ? `'${col.defaultValue}'`
+            : col.defaultValue;
+        sql += ` DEFAULT ${dv}`;
+      }
+      return sql;
+    })
+    .join(',\n    ');
+
+  const sql = `CREATE TABLE IF NOT EXISTS "${tableName}" (\n    ${systemColumns}${businessColumns ? ',\n    ' + businessColumns : ''}\n  )`;
+
+  db.exec(sql);
+}
+
+export class Query<T extends DbRow = DbRow> {
+  private tableName: string;
+  private conditions: string[] = [];
+  private params: unknown[] = [];
+  private _limit = 100;
+  private _skip = 0;
+  private _orderBy = 'createdAt';
+  private _orderDirection: 'asc' | 'desc' = 'desc';
+
+  constructor(tableName: string) {
+    this.tableName = tableName;
+  }
+
+  equalTo(field: string, value: unknown): this {
+    if (value === null || value === undefined) {
+      this.conditions.push(`"${field}" IS NULL`);
+    } else {
+      this.conditions.push(`"${field}" = ?`);
+      this.params.push(value);
+    }
+    return this;
+  }
+
+  notEqualTo(field: string, value: unknown): this {
+    if (value === null || value === undefined) {
+      this.conditions.push(`"${field}" IS NOT NULL`);
+    } else {
+      this.conditions.push(`"${field}" != ?`);
+      this.params.push(value);
+    }
+    return this;
+  }
+
+  containedIn(field: string, values: unknown[]): this {
+    if (values.length === 0) {
+      this.conditions.push('1 = 0');
+      return this;
+    }
+    const placeholders = values.map(() => '?').join(',');
+    this.conditions.push(`"${field}" IN (${placeholders})`);
+    this.params.push(...values);
+    return this;
+  }
+
+  greaterThan(field: string, value: number | string): this {
+    this.conditions.push(`"${field}" > ?`);
+    this.params.push(value);
+    return this;
+  }
+
+  lessThan(field: string, value: number | string): this {
+    this.conditions.push(`"${field}" < ?`);
+    this.params.push(value);
+    return this;
+  }
+
+  descending(field: string): this {
+    this._orderBy = field;
+    this._orderDirection = 'desc';
+    return this;
+  }
+
+  ascending(field: string): this {
+    this._orderBy = field;
+    this._orderDirection = 'asc';
+    return this;
+  }
+
+  limit(n: number): this {
+    this._limit = n;
+    return this;
+  }
+
+  skip(n: number): this {
+    this._skip = n;
+    return this;
+  }
+
+  private buildWhere(): string {
+    return this.conditions.length > 0
+      ? `WHERE ${this.conditions.join(' AND ')}`
+      : '';
+  }
+
+  find(): T[] {
+    const where = this.buildWhere();
+    const sql = `SELECT * FROM "${this.tableName}" ${where} ORDER BY "${this._orderBy}" ${this._orderDirection} LIMIT ? OFFSET ?`;
+    return db.prepare(sql).all(...this.params, this._limit, this._skip) as T[];
+  }
+
+  first(): T | null {
+    const where = this.buildWhere();
+    const sql = `SELECT * FROM "${this.tableName}" ${where} ORDER BY "${this._orderBy}" ${this._orderDirection} LIMIT 1`;
+    return (db.prepare(sql).get(...this.params) as T | undefined) ?? null;
+  }
+
+  count(): number {
+    const where = this.buildWhere();
+    const sql = `SELECT COUNT(*) as count FROM "${this.tableName}" ${where}`;
+    const result = db.prepare(sql).get(...this.params) as { count: number };
+    return result.count;
+  }
+}
+
+export function createObject(tableName: string, data: Record<string, unknown>): DbRow {
+  const now = new Date().toISOString();
+  const objectId = randomUUID();
+
+  const columns: string[] = ['objectId', 'createdAt', 'updatedAt'];
+  const values: unknown[] = [objectId, now, now];
+  const placeholders: string[] = ['?', '?', '?'];
+
+  for (const [key, value] of Object.entries(data)) {
+    columns.push(`"${key}"`);
+    values.push(value);
+    placeholders.push('?');
+  }
+
+  const sql = `INSERT INTO "${tableName}" (${columns.join(', ')}) VALUES (${placeholders.join(', ')})`;
+  db.prepare(sql).run(...values);
+
+  return { objectId, createdAt: now, updatedAt: now, ...data } as DbRow;
+}
+
+export function updateObject(tableName: string, objectId: string, data: Record<string, unknown>): boolean {
+  const now = new Date().toISOString();
+  const sets: string[] = ['updatedAt = ?'];
+  const values: unknown[] = [now];
+
+  for (const [key, value] of Object.entries(data)) {
+    sets.push(`"${key}" = ?`);
+    values.push(value);
+  }
+
+  values.push(objectId);
+  const sql = `UPDATE "${tableName}" SET ${sets.join(', ')} WHERE objectId = ?`;
+  const result = db.prepare(sql).run(...values);
+  return result.changes > 0;
+}
+
+export function getObject<T extends DbRow = DbRow>(tableName: string, objectId: string): T | null {
+  const sql = `SELECT * FROM "${tableName}" WHERE objectId = ?`;
+  return (db.prepare(sql).get(objectId) as T) ?? null;
+}

+ 134 - 0
services/qiwei-relay/lib/fmode-auth.ts

@@ -0,0 +1,134 @@
+/**
+ * Fmode 用户鉴权
+ *
+ * 通过调用 Fmode 网关的 doApi 接口校验 Bearer token 是否有效。
+ * 默认使用 /user/getProfile,不携带 guid 时上游会返回参数错误,
+ * 但只要 token 有效就不会返回“认证失败”,从而在无副作用的情况下完成校验。
+ */
+
+import { getFmodeGatewayUrl, getFmodeTokenValidateMethod } from './config.js';
+import { sha256Hash } from './crypto.js';
+
+export interface FmodeUser {
+  userId: string;
+  nickname?: string;
+  mobile?: string;
+  avatarUrl?: string;
+}
+
+const VALIDATE_TIMEOUT_MS = 15000;
+const MAX_RETRIES = 3;
+const RETRY_DELAY_MS = 800;
+
+function isAuthFailure(resp: Response, json: unknown): boolean {
+  if (resp.status === 401) return true;
+  if (!json || typeof json !== 'object') return false;
+  const payload = json as Record<string, unknown>;
+  const message = String(payload.mess || payload.msg || payload.message || payload.error || '');
+  return /认证失败|unauthorized|invalid token|token.*无效|token.*失效|未授权/i.test(message);
+}
+
+function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+async function tryValidateToken(
+  token: string,
+  deviceGuid?: string,
+): Promise<{ resp: Response; json: unknown }> {
+  const url = `${getFmodeGatewayUrl()}/doApi`;
+  const method = getFmodeTokenValidateMethod();
+  const body = {
+    uid: `relay-${Date.now()}`,
+    method,
+    params: deviceGuid ? { guid: deviceGuid } : {},
+  };
+
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS);
+
+  try {
+    const resp = await fetch(url, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+        Accept: 'application/json',
+      },
+      body: JSON.stringify(body),
+      signal: controller.signal,
+    });
+
+    let json: unknown;
+    try {
+      json = await resp.json();
+    } catch {
+      json = undefined;
+    }
+
+    return { resp, json };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+export async function verifyFmodeToken(
+  token: string,
+  deviceGuid?: string,
+): Promise<FmodeUser> {
+  if (!token) {
+    throw new Error('Missing Fmode token');
+  }
+
+  let lastError: Error | undefined;
+
+  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+    try {
+      const { resp, json } = await tryValidateToken(token, deviceGuid);
+
+      if (isAuthFailure(resp, json)) {
+        throw new Error('Fmode token 无效');
+      }
+
+      const payload = json && typeof json === 'object' ? (json as Record<string, unknown>) : {};
+      const data =
+        payload.data && typeof payload.data === 'object'
+          ? (payload.data as Record<string, unknown>)
+          : {};
+
+      let userId =
+        (data.userId as string) ||
+        (data.acctid as string) ||
+        (data.user_id as string) ||
+        (data.mobile as string);
+
+      // 如果提供了 guid 仍拿不到 userId,降级为 token 哈希(保证一人一租户的风控)
+      if (!userId) {
+        userId = `token_${sha256Hash(token).slice(0, 24)}`;
+        if (deviceGuid) {
+          console.warn('[FmodeAuth] 已提供 guid 但未返回 userId,使用 token 哈希作为用户标识');
+        }
+      }
+
+      return {
+        userId,
+        nickname: data.nickname as string | undefined,
+        mobile: data.mobile as string | undefined,
+        avatarUrl: data.avatarUrl as string | undefined,
+      };
+    } catch (err) {
+      const message = err instanceof Error ? err.message : String(err);
+      // 认证错误不重试
+      if (/Fmode token 无效|Missing Fmode token/i.test(message)) {
+        throw err;
+      }
+      lastError = err instanceof Error ? err : new Error(message);
+      if (attempt < MAX_RETRIES) {
+        console.warn(`[FmodeAuth] 校验失败,第 ${attempt} 次重试: ${message}`);
+        await sleep(RETRY_DELAY_MS * attempt);
+      }
+    }
+  }
+
+  throw new Error(`Fmode 网关无法访问: ${lastError?.message || '未知错误'}`);
+}

+ 1976 - 0
services/qiwei-relay/package-lock.json

@@ -0,0 +1,1976 @@
+{
+  "name": "qiwei-agent-skill-relay",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "qiwei-agent-skill-relay",
+      "version": "0.1.0",
+      "dependencies": {
+        "better-sqlite3": "^9.0.0",
+        "cors": "^2.8.5",
+        "dotenv": "^16.4.0",
+        "express": "^4.21.0"
+      },
+      "devDependencies": {
+        "@types/better-sqlite3": "^7.6.0",
+        "@types/cors": "^2.8.17",
+        "@types/express": "^4.17.21",
+        "@types/node": "^22.0.0",
+        "tsx": "^4.19.0",
+        "typescript": "^5.7.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+      "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+      "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+      "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+      "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+      "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+      "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+      "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+      "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+      "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+      "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+      "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+      "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+      "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+      "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+      "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+      "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+      "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+      "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+      "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+      "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+      "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+      "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/better-sqlite3": {
+      "version": "7.6.13",
+      "resolved": "https://registry.npmmirror.com/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
+      "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/cors": {
+      "version": "2.8.19",
+      "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz",
+      "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "4.17.25",
+      "resolved": "https://registry.npmmirror.com/@types/express/-/express-4.17.25.tgz",
+      "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^4.17.33",
+        "@types/qs": "*",
+        "@types/serve-static": "^1"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "4.19.9",
+      "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
+      "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/mime": {
+      "version": "1.3.5",
+      "resolved": "https://registry.npmmirror.com/@types/mime/-/mime-1.3.5.tgz",
+      "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.20.1",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz",
+      "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "1.15.10",
+      "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-1.15.10.tgz",
+      "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*",
+        "@types/send": "<1"
+      }
+    },
+    "node_modules/@types/serve-static/node_modules/@types/send": {
+      "version": "0.17.6",
+      "resolved": "https://registry.npmmirror.com/@types/send/-/send-0.17.6.tgz",
+      "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/mime": "^1",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/better-sqlite3": {
+      "version": "9.6.0",
+      "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
+      "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.1"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.6",
+      "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.6.tgz",
+      "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "license": "ISC"
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.1",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz",
+      "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.1",
+        "@esbuild/android-arm": "0.28.1",
+        "@esbuild/android-arm64": "0.28.1",
+        "@esbuild/android-x64": "0.28.1",
+        "@esbuild/darwin-arm64": "0.28.1",
+        "@esbuild/darwin-x64": "0.28.1",
+        "@esbuild/freebsd-arm64": "0.28.1",
+        "@esbuild/freebsd-x64": "0.28.1",
+        "@esbuild/linux-arm": "0.28.1",
+        "@esbuild/linux-arm64": "0.28.1",
+        "@esbuild/linux-ia32": "0.28.1",
+        "@esbuild/linux-loong64": "0.28.1",
+        "@esbuild/linux-mips64el": "0.28.1",
+        "@esbuild/linux-ppc64": "0.28.1",
+        "@esbuild/linux-riscv64": "0.28.1",
+        "@esbuild/linux-s390x": "0.28.1",
+        "@esbuild/linux-x64": "0.28.1",
+        "@esbuild/netbsd-arm64": "0.28.1",
+        "@esbuild/netbsd-x64": "0.28.1",
+        "@esbuild/openbsd-arm64": "0.28.1",
+        "@esbuild/openbsd-x64": "0.28.1",
+        "@esbuild/openharmony-arm64": "0.28.1",
+        "@esbuild/sunos-x64": "0.28.1",
+        "@esbuild/win32-arm64": "0.28.1",
+        "@esbuild/win32-ia32": "0.28.1",
+        "@esbuild/win32-x64": "0.28.1"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "license": "(MIT OR WTFPL)",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "license": "MIT"
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "license": "MIT"
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "license": "MIT"
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "license": "MIT"
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/napi-build-utils": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
+      "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.94.0",
+      "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.94.0.tgz",
+      "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.2.tgz",
+      "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^1.0.1",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.15.3",
+      "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
+      "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "es-define-property": "^1.0.1",
+        "side-channel": "^1.1.1"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.8.5",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
+      "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.5.tgz",
+      "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+      "license": "MIT",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.23.0",
+      "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.23.0.tgz",
+      "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    }
+  }
+}

+ 26 - 0
services/qiwei-relay/package.json

@@ -0,0 +1,26 @@
+{
+  "name": "qiwei-agent-skill-relay",
+  "version": "0.1.0",
+  "description": "企微客户群运营 Agent Skill — 中央 Webhook 中继服务",
+  "type": "module",
+  "scripts": {
+    "dev": "npx tsx server.ts",
+    "start": "node dist/server.js",
+    "build": "tsc",
+    "test:smoke": "node scripts/global-ingest-smoke-test.mjs"
+  },
+  "dependencies": {
+    "better-sqlite3": "^9.0.0",
+    "cors": "^2.8.5",
+    "dotenv": "^16.4.0",
+    "express": "^4.21.0"
+  },
+  "devDependencies": {
+    "@types/better-sqlite3": "^7.6.0",
+    "@types/cors": "^2.8.17",
+    "@types/express": "^4.17.21",
+    "@types/node": "^22.0.0",
+    "tsx": "^4.19.0",
+    "typescript": "^5.7.0"
+  }
+}

+ 876 - 0
services/qiwei-relay/routes.ts

@@ -0,0 +1,876 @@
+/**
+ * Relay API 路由
+ */
+
+import { Router, Request, Response } from 'express';
+import { getDb, Query, createObject, updateObject, type DbRow } from './lib/db.js';
+import {
+  generateApiCredentials,
+  generateKeyPair,
+  generateRelaySecret,
+  hashApiSecret,
+  encryptWithPublicKey,
+  verifyHmacSignature,
+  verifyBearerToken,
+  sha256Hash,
+} from './lib/crypto.js';
+import {
+  getAdminKey,
+  getUpstreamCallbackSecret,
+  getEventTtlSeconds,
+  getMaxPendingEventsPerTenant,
+  getPollBatchSize,
+  getPollWaitMs,
+  getRelayPublicUrl,
+  getRegisterRateLimitSeconds,
+  getRegisterRateLimitPerIp,
+  getRegisterRateLimitPerUser,
+} from './lib/config.js';
+import { verifyFmodeToken, type FmodeUser } from './lib/fmode-auth.js';
+
+// ============================================================
+// 类型定义
+// ============================================================
+
+interface Tenant extends DbRow {
+  apiKey: string;
+  apiSecretHash: string;
+  publicKey: string;
+  fmodeUserId: string | null;
+  name: string | null;
+  description: string | null;
+  plan: string;
+  status: string;
+  maxDevices: number;
+  dailyEventLimit: number;
+}
+
+interface TenantDevice extends DbRow {
+  tenantId: string;
+  guid: string;
+  relaySecret: string;
+  deviceName: string | null;
+  lastOnlineAt: string | null;
+  status: string;
+}
+
+interface WebhookRelayEvent extends DbRow {
+  tenantId: string;
+  deviceGuid: string;
+  eventId: string;
+  status: string;
+  encryptedPayload: string;
+  payloadHash: string;
+  retryCount: number;
+  deliveredAt: string | null;
+  expiresAt: string;
+  error: string | null;
+}
+
+interface UsageMetricRow extends DbRow {
+  metricValue: number;
+}
+
+interface AuditLogRow extends DbRow {
+  action: string;
+  tenantId: string | null;
+  fmodeUserId: string | null;
+  ip: string | null;
+  details: string | null;
+}
+
+export const relayRoutes = Router();
+
+// ============================================================
+// 中间件
+// ============================================================
+
+interface AuthenticatedRequest extends Request {
+  tenant?: Tenant;
+}
+
+/** 管理员鉴权 */
+function adminAuth(req: Request, res: Response, next: () => void): void {
+  const key = req.headers['x-admin-key'] as string | undefined;
+  if (!key || !getAdminKey() || key !== getAdminKey()) {
+    res.status(401).json({ error: 'Unauthorized' });
+    return;
+  }
+  next();
+}
+
+/** 租户鉴权:Bearer apiSecret */
+async function tenantAuth(req: AuthenticatedRequest, res: Response, next: () => void): Promise<void> {
+  const auth = req.headers.authorization;
+  if (!auth) {
+    res.status(401).json({ error: 'Missing Authorization' });
+    return;
+  }
+
+  const match = auth.match(/^Bearer\s+(.+)$/i);
+  if (!match) {
+    res.status(401).json({ error: 'Invalid Authorization format' });
+    return;
+  }
+
+  const apiSecret = match[1];
+  const tenant = await findTenantByApiSecret(apiSecret);
+  if (!tenant) {
+    res.status(401).json({ error: 'Invalid credentials' });
+    return;
+  }
+
+  if (tenant.status !== 'active') {
+    res.status(403).json({ error: 'Tenant inactive' });
+    return;
+  }
+
+  req.tenant = tenant;
+  next();
+}
+
+// ============================================================
+// 限流(内存实现,单实例足够;如后续多实例可改 Redis)
+// ============================================================
+
+interface RateLimitBucket {
+  count: number;
+  resetAt: number;
+}
+
+const rateLimitMap = new Map<string, RateLimitBucket>();
+
+function checkRateLimit(key: string, windowMs: number, maxCount: number): boolean {
+  const now = Date.now();
+  const bucket = rateLimitMap.get(key);
+  if (!bucket || now >= bucket.resetAt) {
+    rateLimitMap.set(key, { count: 1, resetAt: now + windowMs });
+    return true;
+  }
+  if (bucket.count >= maxCount) {
+    return false;
+  }
+  bucket.count += 1;
+  return true;
+}
+
+function getClientIp(req: Request): string {
+  const forwarded = req.headers['x-forwarded-for'];
+  if (typeof forwarded === 'string' && forwarded) {
+    return forwarded.split(',')[0].trim();
+  }
+  if (typeof forwarded === 'object' && forwarded.length > 0) {
+    return forwarded[0].trim();
+  }
+  return req.socket.remoteAddress || 'unknown';
+}
+
+// ============================================================
+// 管理员接口
+// ============================================================
+
+/** POST /api/admin/tenant — 创建租户 */
+relayRoutes.post('/admin/tenant', adminAuth, (req: Request, res: Response) => {
+  try {
+    const { name, plan } = req.body;
+    const { apiKey, apiSecret } = generateApiCredentials();
+    const keyPair = generateKeyPair();
+
+    const tenant = createObject('Tenant', {
+      apiKey,
+      apiSecretHash: hashApiSecret(apiSecret),
+      publicKey: keyPair.publicKey,
+      name: name || null,
+      plan: plan || 'free',
+      status: 'active',
+      metadata: JSON.stringify({ privateKey: keyPair.privateKey }),
+    });
+
+    console.log(`[Relay] 创建租户: ${tenant.objectId}, apiKey=${apiKey}`);
+
+    res.json({
+      success: true,
+      tenantId: tenant.objectId,
+      apiKey,
+      apiSecret,
+      privateKey: keyPair.privateKey,
+      publicKey: keyPair.publicKey,
+      name: tenant.name,
+      plan: tenant.plan,
+    });
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    console.error('[Relay] 创建租户失败:', msg);
+    res.status(500).json({ error: `创建租户失败: ${msg}` });
+  }
+});
+
+// ============================================================
+// 租户自助注册接口
+// ============================================================
+
+/** POST /api/tenant/register — 使用 Fmode token 自助注册 */
+relayRoutes.post('/tenant/register', async (req: Request, res: Response) => {
+  try {
+    const auth = req.headers.authorization || '';
+    const token = auth.replace(/^Bearer\s+/i, '');
+    if (!token) {
+      res.status(401).json({
+        success: false,
+        error: '缺少 Fmode token',
+        code: 'MISSING_TOKEN',
+      });
+      return;
+    }
+
+    const ip = getClientIp(req);
+    const rateWindowMs = getRegisterRateLimitSeconds() * 1000;
+    if (!checkRateLimit(`register:ip:${ip}`, rateWindowMs, getRegisterRateLimitPerIp())) {
+      res.status(429).json({
+        success: false,
+        error: '该 IP 注册过于频繁,请稍后再试',
+        code: 'RATE_LIMITED',
+      });
+      return;
+    }
+
+    const { description, deviceGuid } = req.body || {};
+    const descriptionStr = typeof description === 'string' ? description.trim() : '';
+
+    // 1. 校验 Fmode token
+    const fmodeUser: FmodeUser = await verifyFmodeToken(token, deviceGuid);
+
+    // 2. 风控:已注册则直接返回已有租户
+    const existing = await findTenantByFmodeUserId(fmodeUser.userId);
+    if (existing) {
+      res.status(409).json({
+        success: false,
+        error: '该 Fmode 账号已开通 Relay',
+        code: 'ALREADY_REGISTERED',
+        tenantId: existing.objectId,
+      });
+      return;
+    }
+
+    // 3. 单个 Fmode 用户限流
+    if (!checkRateLimit(`register:user:${fmodeUser.userId}`, rateWindowMs, getRegisterRateLimitPerUser())) {
+      res.status(429).json({
+        success: false,
+        error: '该 Fmode 账号注册过于频繁',
+        code: 'RATE_LIMITED',
+      });
+      return;
+    }
+
+    // 3. 生成租户凭证
+    const { apiKey, apiSecret } = generateApiCredentials();
+    const keyPair = generateKeyPair();
+
+    const tenant = createObject('Tenant', {
+      apiKey,
+      apiSecretHash: hashApiSecret(apiSecret),
+      publicKey: keyPair.publicKey,
+      fmodeUserId: fmodeUser.userId,
+      name: descriptionStr.slice(0, 100) || null,
+      description: descriptionStr.slice(0, 255) || null,
+      plan: 'free',
+      status: 'active',
+      maxDevices: 10,
+      dailyEventLimit: 100000,
+      metadata: JSON.stringify({
+        registeredFrom: 'self-service',
+        fmodeNickname: fmodeUser.nickname,
+        fmodeMobile: fmodeUser.mobile,
+      }),
+    });
+
+    // 4. 可选:预注册设备
+    let registeredDevice: { guid: string; relaySecret: string } | null = null;
+    if (deviceGuid && typeof deviceGuid === 'string') {
+      const device = createObject('TenantDevice', {
+        tenantId: tenant.objectId,
+        guid: deviceGuid.trim(),
+        relaySecret: generateRelaySecret(),
+        deviceName: descriptionStr.slice(0, 100) || null,
+        lastOnlineAt: new Date().toISOString(),
+        status: 'active',
+      }) as TenantDevice;
+      registeredDevice = { guid: device.guid, relaySecret: device.relaySecret };
+    }
+
+    // 5. 审计日志
+    createObject('AuditLog', {
+      action: 'tenant.register',
+      tenantId: tenant.objectId,
+      fmodeUserId: fmodeUser.userId,
+      ip,
+      details: JSON.stringify({
+        description: descriptionStr,
+        deviceGuid: registeredDevice?.guid || null,
+      }),
+    });
+
+    console.log(`[Relay] 自助注册租户: ${tenant.objectId}, fmodeUser=${fmodeUser.userId}, ip=${ip}`);
+
+    res.json({
+      success: true,
+      relayBaseUrl: getRelayPublicUrl(),
+      tenantId: tenant.objectId,
+      apiKey,
+      apiSecret,
+      privateKey: keyPair.privateKey,
+      publicKey: keyPair.publicKey,
+      createdAt: tenant.createdAt,
+      ...(registeredDevice
+        ? { deviceGuid: registeredDevice.guid, relaySecret: registeredDevice.relaySecret }
+        : {}),
+    });
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    console.error('[Relay] 租户自助注册失败:', msg);
+    const code = /Fmode token 无效|Missing Fmode token/i.test(msg)
+      ? 'INVALID_TOKEN'
+      : 'REGISTER_FAILED';
+    const status = code === 'INVALID_TOKEN' ? 401 : 500;
+    res.status(status).json({ success: false, error: msg, code });
+  }
+});
+
+// ============================================================
+// 租户接口
+// ============================================================
+
+/** GET /api/tenant/status — 租户状态 */
+relayRoutes.get('/tenant/status', tenantAuth, async (req: AuthenticatedRequest, res: Response) => {
+  try {
+    const tenantId = req.tenant!.objectId;
+
+    const devices = await new Query<TenantDevice>('TenantDevice')
+      .equalTo('tenantId', tenantId)
+      .equalTo('status', 'active')
+      .find();
+
+    const pendingCount = await new Query('WebhookRelayEvent')
+      .equalTo('tenantId', tenantId)
+      .equalTo('status', 'pending')
+      .count();
+
+    const deliveredToday = await countMetric(tenantId, 'webhook_event', new Date().toISOString().slice(0, 10));
+
+    res.json({
+      success: true,
+      tenantId,
+      createdAt: req.tenant!.createdAt,
+      name: req.tenant!.name,
+      plan: req.tenant!.plan,
+      status: req.tenant!.status,
+      deviceCount: devices.length,
+      devices: devices.map((d) => ({
+        guid: d.guid,
+        deviceName: d.deviceName,
+        lastOnlineAt: d.lastOnlineAt,
+      })),
+      pendingEvents: pendingCount,
+      pendingEventCount: pendingCount,
+      webhookEventsToday: deliveredToday,
+      eventCount24h: deliveredToday,
+    });
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    res.status(500).json({ success: false, error: msg });
+  }
+});
+
+/** POST /api/tenant/device — 注册/更新设备 */
+relayRoutes.post('/tenant/device', tenantAuth, async (req: AuthenticatedRequest, res: Response) => {
+  try {
+    const tenantId = req.tenant!.objectId;
+    const { guid, deviceName } = req.body;
+
+    if (!guid || typeof guid !== 'string') {
+      res.status(400).json({ error: 'Missing guid' });
+      return;
+    }
+
+    const assigned = await findDeviceByGuid(guid);
+    if (assigned && assigned.tenantId !== tenantId) {
+      res.status(409).json({ error: 'Device is already assigned to another tenant' });
+      return;
+    }
+
+    let device: TenantDevice | null = await findDevice(tenantId, guid);
+
+    const now = new Date().toISOString();
+    if (!device) {
+      device = createObject('TenantDevice', {
+        tenantId,
+        guid,
+        relaySecret: generateRelaySecret(),
+        deviceName: deviceName || null,
+        lastOnlineAt: now,
+        status: 'active',
+      }) as TenantDevice;
+      console.log(`[Relay] 注册设备: tenant=${tenantId}, guid=${guid}`);
+    } else {
+      updateObject('TenantDevice', device.objectId, {
+        lastOnlineAt: now,
+        deviceName: deviceName || device.deviceName,
+        status: 'active',
+      });
+    }
+
+    res.json({
+      success: true,
+      tenantId,
+      guid,
+      relaySecret: device.relaySecret,
+      deviceName: device.deviceName,
+      lastOnlineAt: device.lastOnlineAt,
+    });
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    res.status(500).json({ error: msg });
+  }
+});
+
+// ============================================================
+// Webhook 接收接口
+// ============================================================
+
+/**
+ * The upstream callback is token-wide, so this endpoint routes each payload by
+ * its real device guid before encrypting it for the owning tenant.
+ */
+relayRoutes.post('/webhook/ingest', async (req: Request, res: Response) => {
+  const startTime = Date.now();
+  const rawBody: string | undefined = (req as any).rawBody;
+  const callbackSecret = getUpstreamCallbackSecret();
+
+  try {
+    if (!callbackSecret) {
+      res.status(503).json({ error: 'Global callback is not configured' });
+      return;
+    }
+
+    const signature = extractSignature(req.headers as Record<string, string | string[] | undefined>);
+    if (!signature || !rawBody || !verifyHmacSignature(callbackSecret, rawBody, signature)) {
+      const auth = req.headers.authorization as string | undefined;
+      if (!auth || !verifyBearerToken(callbackSecret, auth)) {
+        console.warn('[Relay] 全局回调签名验证失败');
+        res.status(401).json({ error: 'Invalid signature' });
+        return;
+      }
+    }
+
+    const groups = splitGlobalPayload(req.body);
+    if (groups.length === 0) {
+      console.warn('[Relay] 全局回调缺少可路由的 guid');
+      res.status(400).json({ error: 'Missing device guid in callback payload' });
+      return;
+    }
+
+    const resolved: Array<{
+      tenant: Tenant;
+      device: TenantDevice;
+      body: unknown;
+      rawBody: string;
+    }> = [];
+    for (const group of groups) {
+      const device = await findDeviceByGuid(group.guid);
+      if (!device) {
+        console.warn(`[Relay] 全局回调设备未注册: guid=${group.guid}`);
+        res.status(409).json({ error: 'Device is not registered' });
+        return;
+      }
+      const tenant = await findTenantById(device.tenantId);
+      if (!tenant || tenant.status !== 'active') {
+        res.status(409).json({ error: 'Tenant is not active' });
+        return;
+      }
+      resolved.push({ tenant, device, body: group.body, rawBody: group.rawBody });
+    }
+
+    let queued = 0;
+    let duplicates = 0;
+    for (const item of resolved) {
+      const result = await storeWebhookEvent(
+        item.tenant,
+        item.device,
+        item.rawBody,
+        item.body,
+      );
+      if (result.duplicate) duplicates += 1;
+      else queued += 1;
+    }
+
+    console.log(
+      `[Relay] 全局回调完成: devices=${resolved.length}, queued=${queued}, duplicates=${duplicates}, elapsed=${Date.now() - startTime}ms`,
+    );
+    res.status(200).json({ code: 0, msg: 'received', queued, duplicates });
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    console.error('[Relay] 全局回调接收失败:', msg);
+    res.status(500).json({ error: 'Global ingest failed' });
+  }
+});
+
+/** POST /api/webhook/ingest/:tenantId/:deviceGuid */
+relayRoutes.post('/webhook/ingest/:tenantId/:deviceGuid', async (req: Request, res: Response) => {
+  const startTime = Date.now();
+  const { tenantId, deviceGuid } = req.params;
+  const rawBody: string | undefined = (req as any).rawBody;
+
+  try {
+    // 1. 查找设备与租户
+    const device = await findDevice(tenantId, deviceGuid);
+
+    if (!device) {
+      res.status(404).json({ error: 'Device not found' });
+      return;
+    }
+
+    const tenant = await findTenantById(tenantId);
+    if (!tenant || tenant.status !== 'active') {
+      res.status(404).json({ error: 'Tenant not found or inactive' });
+      return;
+    }
+
+    // 2. 验证 HMAC 签名(来自 qiweapi 或本地测试)
+    const signature = extractSignature(req.headers as Record<string, string | string[] | undefined>);
+    const relaySecret = device.relaySecret as string;
+
+    if (!signature || !rawBody || !verifyHmacSignature(relaySecret, rawBody, signature)) {
+      // 兼容直接 Bearer secret 的测试方式
+      const auth = req.headers.authorization as string | undefined;
+      if (!auth || !verifyBearerToken(relaySecret, auth)) {
+        console.warn(`[Relay] 签名验证失败: tenant=${tenantId}, guid=${deviceGuid}`);
+        res.status(401).json({ error: 'Invalid signature' });
+        return;
+      }
+    }
+
+    if (!rawBody) {
+      res.status(400).json({ error: 'Missing raw body' });
+      return;
+    }
+    const result = await storeWebhookEvent(tenant, device, rawBody, req.body);
+
+    const elapsed = Date.now() - startTime;
+    console.log(`[Relay] 接收事件: tenant=${tenantId}, guid=${deviceGuid}, eventId=${result.eventId}, elapsed=${elapsed}ms`);
+
+    res.status(200).json({
+      code: 0,
+      msg: result.duplicate ? 'duplicate event' : 'received',
+      eventId: result.eventId,
+    });
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    console.error('[Relay] 接收事件失败:', msg);
+    res.status(500).json({ error: `Ingest failed: ${msg}` });
+  }
+});
+
+// ============================================================
+// Relay 拉取接口
+// ============================================================
+
+/** POST /api/poll 与 /api/relay/poll(别名) */
+async function handlePoll(req: AuthenticatedRequest, res: Response): Promise<void> {
+  const tenantId = req.tenant!.objectId;
+  const { guid, batchSize = getPollBatchSize(), waitMs = getPollWaitMs() } = req.body;
+
+  if (!guid || typeof guid !== 'string') {
+    res.status(400).json({ error: 'Missing guid' });
+    return;
+  }
+
+  // 校验 guid 属于当前租户
+  const device = await findDevice(tenantId, guid);
+
+  if (!device) {
+    res.status(404).json({ error: 'Device not registered for this tenant' });
+    return;
+  }
+
+  // 更新最后在线时间
+  updateObject('TenantDevice', device.objectId, {
+    lastOnlineAt: new Date().toISOString(),
+  });
+
+  // 长轮询:最多等待 waitMs,有新事件或超时返回
+  const deadline = Date.now() + Math.min(waitMs, getPollWaitMs());
+  const pollInterval = 500;
+
+  while (Date.now() < deadline) {
+    const events = await fetchPendingEvents(tenantId, guid, Math.min(batchSize, getPollBatchSize()));
+    if (events.length > 0) {
+      res.json({ success: true, events });
+      return;
+    }
+    await sleep(pollInterval);
+  }
+
+  // 超时返回空
+  res.json({ success: true, events: [] });
+}
+
+relayRoutes.post('/poll', tenantAuth, handlePoll);
+relayRoutes.post('/relay/poll', tenantAuth, handlePoll);
+
+/** POST /api/ack 与 /api/relay/ack(别名) */
+async function handleAck(req: AuthenticatedRequest, res: Response): Promise<void> {
+  const tenantId = req.tenant!.objectId;
+  const { eventIds, guid } = req.body;
+
+  if (!Array.isArray(eventIds) || eventIds.length === 0) {
+    res.status(400).json({ error: 'eventIds must be a non-empty array' });
+    return;
+  }
+
+  const db = getDb();
+  const stmt = db.prepare(
+    `UPDATE "WebhookRelayEvent" SET status = 'delivered', deliveredAt = ? WHERE tenantId = ? AND eventId IN (${eventIds.map(() => '?').join(',')})`
+  );
+  stmt.run(new Date().toISOString(), tenantId, ...eventIds);
+
+  // 可选:校验 guid 归属
+  console.log(`[Relay] ACK: tenant=${tenantId}, guid=${guid}, count=${eventIds.length}`);
+
+  res.json({ success: true, ackedCount: eventIds.length });
+}
+
+relayRoutes.post('/ack', tenantAuth, handleAck);
+relayRoutes.post('/relay/ack', tenantAuth, handleAck);
+
+// ============================================================
+// 健康检查
+// ============================================================
+
+relayRoutes.get('/health', (_req: Request, res: Response) => {
+  res.json({ ok: true, timestamp: new Date().toISOString() });
+});
+
+// ============================================================
+// 辅助函数
+// ============================================================
+
+async function findTenantByApiSecret(apiSecret: string): Promise<Tenant | null> {
+  return new Query<Tenant>('Tenant').equalTo('apiSecretHash', hashApiSecret(apiSecret)).first();
+}
+
+async function findTenantById(tenantId: string): Promise<Tenant | null> {
+  return new Query<Tenant>('Tenant').equalTo('objectId', tenantId).first();
+}
+
+async function findTenantByFmodeUserId(fmodeUserId: string): Promise<Tenant | null> {
+  return new Query<Tenant>('Tenant').equalTo('fmodeUserId', fmodeUserId).first();
+}
+
+async function findDevice(tenantId: string, guid: string): Promise<TenantDevice | null> {
+  return new Query<TenantDevice>('TenantDevice')
+    .equalTo('tenantId', tenantId)
+    .equalTo('guid', guid)
+    .equalTo('status', 'active')
+    .first();
+}
+
+async function findDeviceByGuid(guid: string): Promise<TenantDevice | null> {
+  const devices = await new Query<TenantDevice>('TenantDevice')
+    .equalTo('guid', guid)
+    .equalTo('status', 'active')
+    .find();
+  if (devices.length > 1) {
+    throw new Error(`Device guid is assigned to multiple tenants: ${guid}`);
+  }
+  return devices[0] || null;
+}
+
+function extractGuid(value: unknown): string {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return '';
+  const item = value as Record<string, unknown>;
+  const direct = item.guid || item.deviceGuid || item.device_guid;
+  if (typeof direct === 'string' && /^[A-Za-z0-9._:-]{1,128}$/.test(direct.trim())) {
+    return direct.trim();
+  }
+  if (item.meta && typeof item.meta === 'object') return extractGuid(item.meta);
+  return '';
+}
+
+function splitGlobalPayload(body: unknown): Array<{ guid: string; body: unknown; rawBody: string }> {
+  if (Array.isArray(body)) {
+    const groups = new Map<string, unknown[]>();
+    for (const item of body) {
+      const guid = extractGuid(item);
+      if (!guid) return [];
+      const values = groups.get(guid) || [];
+      values.push(item);
+      groups.set(guid, values);
+    }
+    return [...groups.entries()].map(([guid, values]) => ({
+      guid,
+      body: values,
+      rawBody: JSON.stringify(values),
+    }));
+  }
+
+  if (!body || typeof body !== 'object') return [];
+  const wrapper = body as Record<string, unknown>;
+  const wrapperGuid = extractGuid(wrapper);
+  if (!Array.isArray(wrapper.data)) {
+    return wrapperGuid ? [{ guid: wrapperGuid, body, rawBody: JSON.stringify(body) }] : [];
+  }
+
+  const groups = new Map<string, unknown[]>();
+  for (const item of wrapper.data) {
+    const guid = extractGuid(item) || wrapperGuid;
+    if (!guid) return [];
+    const values = groups.get(guid) || [];
+    values.push(item);
+    groups.set(guid, values);
+  }
+  return [...groups.entries()].map(([guid, values]) => {
+    const groupedBody = { ...wrapper, data: values };
+    return { guid, body: groupedBody, rawBody: JSON.stringify(groupedBody) };
+  });
+}
+
+function extractSignature(headers: Record<string, string | string[] | undefined>): string | null {
+  const auth = getHeader(headers, 'authorization');
+  if (auth) {
+    const match = auth.match(/^Bearer\s+(.+)$/i);
+    if (match) return match[1].trim();
+    return auth.trim();
+  }
+  const sigHeaders = ['x-qiwe-signature', 'x-qiweapi-signature', 'x-signature'];
+  for (const name of sigHeaders) {
+    const val = getHeader(headers, name);
+    if (val) return val;
+  }
+  return null;
+}
+
+function getHeader(headers: Record<string, string | string[] | undefined>, name: string): string | undefined {
+  const lower = name.toLowerCase();
+  for (const [key, value] of Object.entries(headers)) {
+    if (key.toLowerCase() === lower) {
+      return Array.isArray(value) ? value[0] : value;
+    }
+  }
+  return undefined;
+}
+
+function extractEventId(body: unknown, guid: string): string {
+  if (!body || typeof body !== 'object') return `${guid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+  const b = body as Record<string, unknown>;
+
+  // v1: data[].msgUniqueIdentifier
+  if (Array.isArray(b.data)) {
+    const items = b.data as Array<Record<string, unknown>>;
+    const first = items[0];
+    if (first?.msgUniqueIdentifier) return `${guid}_${first.msgUniqueIdentifier}`;
+  }
+
+  // v2: meta.seq / meta.token_id
+  if (b.version === '2.0' && b.meta && typeof b.meta === 'object') {
+    const meta = b.meta as Record<string, unknown>;
+    return `${guid}_${meta.token_id || ''}_${meta.seq || Date.now()}`;
+  }
+
+  return `${guid}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
+}
+
+async function storeWebhookEvent(
+  tenant: Tenant,
+  device: TenantDevice,
+  rawBody: string,
+  body: unknown,
+): Promise<{ eventId: string; duplicate: boolean }> {
+  const eventId = extractEventId(body, device.guid);
+  const existing = await new Query('WebhookRelayEvent')
+    .equalTo('tenantId', tenant.objectId)
+    .equalTo('eventId', eventId)
+    .first();
+  if (existing) return { eventId, duplicate: true };
+
+  const encryptedPayload = encryptWithPublicKey(tenant.publicKey, rawBody);
+  const payloadHash = sha256Hash(rawBody);
+  await enforcePendingEventLimit(tenant.objectId);
+  const expiresAt = new Date(Date.now() + getEventTtlSeconds() * 1000).toISOString();
+  createObject('WebhookRelayEvent', {
+    tenantId: tenant.objectId,
+    deviceGuid: device.guid,
+    eventId,
+    status: 'pending',
+    encryptedPayload,
+    payloadHash,
+    retryCount: 0,
+    expiresAt,
+  });
+  recordMetric(tenant.objectId, device.guid, 'webhook_event');
+  return { eventId, duplicate: false };
+}
+
+async function enforcePendingEventLimit(tenantId: string): Promise<void> {
+  const max = getMaxPendingEventsPerTenant();
+  const db = getDb();
+
+  const countRow = db
+    .prepare('SELECT COUNT(*) as count FROM "WebhookRelayEvent" WHERE tenantId = ? AND status = ?')
+    .get(tenantId, 'pending') as { count: number };
+
+  if (countRow.count >= max) {
+    // 删除最旧的 pending 事件
+    const toDrop = countRow.count - max + 1;
+    db.prepare(
+      `DELETE FROM "WebhookRelayEvent" WHERE objectId IN (
+        SELECT objectId FROM "WebhookRelayEvent"
+        WHERE tenantId = ? AND status = ?
+        ORDER BY createdAt ASC
+        LIMIT ?
+      )`
+    ).run(tenantId, 'pending', toDrop);
+    console.warn(`[Relay] 租户 ${tenantId} pending 事件超过上限,已丢弃 ${toDrop} 条最旧事件`);
+  }
+}
+
+async function fetchPendingEvents(tenantId: string, guid: string, limit: number): Promise<WebhookRelayEvent[]> {
+  return new Query<WebhookRelayEvent>('WebhookRelayEvent')
+    .equalTo('tenantId', tenantId)
+    .equalTo('deviceGuid', guid)
+    .equalTo('status', 'pending')
+    .ascending('createdAt')
+    .limit(limit)
+    .find();
+}
+
+function recordMetric(tenantId: string, deviceGuid: string, metricName: string, value = 1): void {
+  try {
+    const metricDate = new Date().toISOString().slice(0, 10);
+    createObject('UsageMetric', {
+      tenantId,
+      deviceGuid,
+      metricName,
+      metricValue: value,
+      metricDate,
+    });
+  } catch (err: unknown) {
+    console.warn('[Relay] 用量埋点失败:', err instanceof Error ? err.message : String(err));
+  }
+}
+
+async function countMetric(tenantId: string, metricName: string, metricDate: string): Promise<number> {
+  try {
+    const rows = await new Query<UsageMetricRow>('UsageMetric')
+      .equalTo('tenantId', tenantId)
+      .equalTo('metricName', metricName)
+      .equalTo('metricDate', metricDate)
+      .find();
+    return rows.reduce((sum, row) => sum + (row.metricValue || 0), 0);
+  } catch {
+    return 0;
+  }
+}
+
+function sleep(ms: number): Promise<void> {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 113 - 0
services/qiwei-relay/schema.ts

@@ -0,0 +1,113 @@
+/**
+ * Relay 数据库 Schema
+ *
+ * 表:
+ * - Tenant:租户信息、API 凭证、RSA 公钥
+ * - TenantDevice:租户下的设备 guid 映射、relaySecret
+ * - WebhookRelayEvent:待投递事件队列
+ * - UsageMetric:用量埋点
+ * - AuditLog:租户自助注册/重置等审计日志
+ */
+
+import { createTable, initDb, getDb } from './lib/db.js';
+
+export function initRelayDatabase(dbPath: string): void {
+  initDb(dbPath);
+
+  // 租户表
+  createTable('Tenant', [
+    { name: 'apiKey', type: 'TEXT', required: true },
+    { name: 'apiSecretHash', type: 'TEXT', required: true },
+    { name: 'publicKey', type: 'TEXT', required: true },
+    { name: 'fmodeUserId', type: 'TEXT' },
+    { name: 'name', type: 'TEXT' },
+    { name: 'description', type: 'TEXT' },
+    { name: 'plan', type: 'TEXT', defaultValue: 'free' },
+    { name: 'status', type: 'TEXT', defaultValue: 'active' },
+    { name: 'maxDevices', type: 'INTEGER', defaultValue: 10 },
+    { name: 'dailyEventLimit', type: 'INTEGER', defaultValue: 100000 },
+    { name: 'metadata', type: 'TEXT' },
+  ]);
+
+  // 租户设备表
+  createTable('TenantDevice', [
+    { name: 'tenantId', type: 'TEXT', required: true },
+    { name: 'guid', type: 'TEXT', required: true },
+    { name: 'relaySecret', type: 'TEXT', required: true },
+    { name: 'deviceName', type: 'TEXT' },
+    { name: 'lastOnlineAt', type: 'TEXT' },
+    { name: 'status', type: 'TEXT', defaultValue: 'active' },
+  ]);
+
+  // Webhook 事件队列
+  createTable('WebhookRelayEvent', [
+    { name: 'tenantId', type: 'TEXT', required: true },
+    { name: 'deviceGuid', type: 'TEXT', required: true },
+    { name: 'eventId', type: 'TEXT', required: true },
+    { name: 'status', type: 'TEXT', defaultValue: 'pending' }, // pending / delivered / failed / dropped
+    { name: 'encryptedPayload', type: 'TEXT', required: true },
+    { name: 'payloadHash', type: 'TEXT' },
+    { name: 'retryCount', type: 'INTEGER', defaultValue: 0 },
+    { name: 'deliveredAt', type: 'TEXT' },
+    { name: 'expiresAt', type: 'TEXT' },
+    { name: 'error', type: 'TEXT' },
+  ]);
+
+  // 用量埋点
+  createTable('UsageMetric', [
+    { name: 'tenantId', type: 'TEXT', required: true },
+    { name: 'deviceGuid', type: 'TEXT' },
+    { name: 'metricName', type: 'TEXT', required: true }, // webhook_event / message / group_created / device_online
+    { name: 'metricValue', type: 'INTEGER', defaultValue: 1 },
+    { name: 'metricDate', type: 'TEXT', required: true }, // YYYY-MM-DD
+    { name: 'metadata', type: 'TEXT' },
+  ]);
+
+  // 审计日志
+  createTable('AuditLog', [
+    { name: 'action', type: 'TEXT', required: true }, // tenant.register / tenant.reset / tenant.delete
+    { name: 'tenantId', type: 'TEXT' },
+    { name: 'fmodeUserId', type: 'TEXT' },
+    { name: 'ip', type: 'TEXT' },
+    { name: 'details', type: 'TEXT' },
+  ]);
+
+  // 索引
+  const db = getDb();
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_tenant_apiKey ON Tenant(apiKey)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_device_tenant_guid ON TenantDevice(tenantId, guid)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_device_guid ON TenantDevice(guid)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_event_tenant_status ON WebhookRelayEvent(tenantId, status)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_event_device_status ON WebhookRelayEvent(deviceGuid, status)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_event_expires ON WebhookRelayEvent(expiresAt)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_metric_tenant_date ON UsageMetric(tenantId, metricName, metricDate)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_auditlog_tenant ON AuditLog(tenantId)`);
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_auditlog_action ON AuditLog(action, createdAt)`);
+
+  migrateRelayDatabase();
+
+  console.log('[Relay Schema] 数据库表初始化完成');
+}
+
+/** 对已有数据库追加新列/索引(幂等) */
+export function migrateRelayDatabase(): void {
+  const db = getDb();
+
+  const tableInfo = db.prepare(`PRAGMA table_info("Tenant")`).all() as Array<{ name: string }>;
+  const hasColumn = (name: string) => tableInfo.some((col) => col.name === name);
+
+  if (!hasColumn('fmodeUserId')) {
+    db.exec(`ALTER TABLE "Tenant" ADD COLUMN "fmodeUserId" TEXT`);
+  }
+  if (!hasColumn('description')) {
+    db.exec(`ALTER TABLE "Tenant" ADD COLUMN "description" TEXT`);
+  }
+  if (!hasColumn('maxDevices')) {
+    db.exec(`ALTER TABLE "Tenant" ADD COLUMN "maxDevices" INTEGER DEFAULT 10`);
+  }
+  if (!hasColumn('dailyEventLimit')) {
+    db.exec(`ALTER TABLE "Tenant" ADD COLUMN "dailyEventLimit" INTEGER DEFAULT 100000`);
+  }
+
+  db.exec(`CREATE INDEX IF NOT EXISTS idx_tenant_fmodeUserId ON Tenant(fmodeUserId)`);
+}

+ 156 - 0
services/qiwei-relay/scripts/global-ingest-smoke-test.mjs

@@ -0,0 +1,156 @@
+import assert from 'node:assert/strict';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { spawn } from 'node:child_process';
+
+const PORT = 4100;
+const BASE = `http://127.0.0.1:${PORT}`;
+const ADMIN_KEY = 'relay-smoke-admin';
+const CALLBACK_SECRET = 'relay-smoke-callback';
+const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-relay-smoke-'));
+const child = spawn(process.execPath, ['dist/server.js'], {
+  cwd: path.resolve(import.meta.dirname, '..'),
+  env: {
+    ...process.env,
+    PORT: String(PORT),
+    DB_PATH: path.join(tempRoot, 'relay.db'),
+    ADMIN_KEY,
+    UPSTREAM_CALLBACK_SECRET: CALLBACK_SECRET,
+    RELAY_PUBLIC_URL: BASE,
+  },
+  stdio: ['ignore', 'pipe', 'pipe'],
+});
+
+async function request(route, { method = 'GET', body, headers = {} } = {}) {
+  const response = await fetch(`${BASE}${route}`, {
+    method,
+    headers: {
+      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
+      ...headers,
+    },
+    body: body === undefined ? undefined : JSON.stringify(body),
+  });
+  return { response, data: await response.json().catch(() => ({})) };
+}
+
+async function waitForHealth() {
+  const deadline = Date.now() + 15000;
+  while (Date.now() < deadline) {
+    try {
+      const { response } = await request('/api/health');
+      if (response.ok) return;
+    } catch {}
+    await new Promise(resolve => setTimeout(resolve, 150));
+  }
+  throw new Error('Relay smoke server did not become healthy');
+}
+
+function decrypt(encryptedPayload, privateKeyPem) {
+  assert.ok(encryptedPayload.startsWith('v2:'));
+  const envelope = JSON.parse(Buffer.from(encryptedPayload.slice(3), 'base64').toString('utf8'));
+  const privateKey = crypto.createPrivateKey(privateKeyPem);
+  const aesKey = crypto.privateDecrypt(
+    { key: privateKey, oaepHash: 'sha256' },
+    Buffer.from(envelope.key, 'base64'),
+  );
+  const decipher = crypto.createDecipheriv('aes-256-gcm', aesKey, Buffer.from(envelope.iv, 'base64'));
+  decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));
+  return Buffer.concat([
+    decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
+    decipher.final(),
+  ]).toString('utf8');
+}
+
+try {
+  await waitForHealth();
+  const tenantResponse = await request('/api/admin/tenant', {
+    method: 'POST',
+    headers: { 'X-Admin-Key': ADMIN_KEY },
+    body: { name: 'global-ingest-smoke' },
+  });
+  assert.equal(tenantResponse.response.status, 200);
+  const tenant = tenantResponse.data;
+  const guid = `smoke-guid-${crypto.randomUUID()}`;
+  const deviceResponse = await request('/api/tenant/device', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${tenant.apiSecret}` },
+    body: { guid, deviceName: 'smoke-device' },
+  });
+  assert.equal(deviceResponse.response.status, 200);
+
+  const callbackBody = {
+    code: 0,
+    data: [{
+      guid,
+      msgUniqueIdentifier: `smoke-${crypto.randomUUID()}`,
+      msgType: 1,
+      content: 'x'.repeat(5000),
+    }],
+  };
+  const rawBody = JSON.stringify(callbackBody);
+  const signature = crypto.createHmac('sha256', CALLBACK_SECRET).update(rawBody).digest('hex');
+  const ingest = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: { 'X-Signature': signature },
+    body: callbackBody,
+  });
+  assert.equal(ingest.response.status, 200);
+  assert.equal(ingest.data.queued, 1);
+
+  const duplicate = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: { 'X-Signature': signature },
+    body: callbackBody,
+  });
+  assert.equal(duplicate.response.status, 200);
+  assert.equal(duplicate.data.duplicates, 1);
+
+  const poll = await request('/api/relay/poll', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${tenant.apiSecret}` },
+    body: { guid, batchSize: 10, waitMs: 1000 },
+  });
+  assert.equal(poll.response.status, 200);
+  assert.equal(poll.data.events.length, 1);
+  const decrypted = JSON.parse(decrypt(poll.data.events[0].encryptedPayload, tenant.privateKey));
+  assert.equal(decrypted.data[0].guid, guid);
+  assert.equal(decrypted.data[0].content.length, 5000);
+
+  const ack = await request('/api/relay/ack', {
+    method: 'POST',
+    headers: { Authorization: `Bearer ${tenant.apiSecret}` },
+    body: { guid, eventIds: [poll.data.events[0].eventId] },
+  });
+  assert.equal(ack.response.status, 200);
+  assert.equal(ack.data.ackedCount, 1);
+
+  const missingGuid = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: {
+      'X-Signature': crypto.createHmac('sha256', CALLBACK_SECRET).update('{}').digest('hex'),
+    },
+    body: {},
+  });
+  assert.equal(missingGuid.response.status, 400);
+
+  const invalidSignature = await request('/api/webhook/ingest', {
+    method: 'POST',
+    headers: { 'X-Signature': 'invalid' },
+    body: callbackBody,
+  });
+  assert.equal(invalidSignature.response.status, 401);
+
+  process.stdout.write(JSON.stringify({
+    status: 'ok',
+    globalRouting: true,
+    largePayloadBytes: 5000,
+    duplicateProtected: true,
+    missingGuidRejected: true,
+    invalidSignatureRejected: true,
+  }, null, 2));
+} finally {
+  child.kill('SIGTERM');
+  fs.rmSync(tempRoot, { recursive: true, force: true });
+}

+ 66 - 0
services/qiwei-relay/server.ts

@@ -0,0 +1,66 @@
+/**
+ * 中央 Webhook 中继服务入口
+ *
+ * 启动方式:npm run dev (开发)
+ *          npm start (生产,需先 npm run build)
+ */
+
+import 'dotenv/config';
+import express from 'express';
+import cors from 'cors';
+import { initRelayDatabase } from './schema.js';
+import { relayRoutes } from './routes.js';
+import {
+  getPort,
+  getDbPath,
+  getAdminKey,
+  getUpstreamCallbackSecret,
+} from './lib/config.js';
+
+const app = express();
+const PORT = getPort();
+
+// 启动前检查关键配置
+if (!getAdminKey()) {
+  console.warn('[Relay] ADMIN_KEY 未设置,管理员接口无法使用。建议运行 openssl rand -hex 32 生成。');
+}
+if (!getUpstreamCallbackSecret()) {
+  console.warn('[Relay] UPSTREAM_CALLBACK_SECRET 未设置,全局企微回调入口不可用。');
+}
+
+// ---- 中间件 ----
+app.use(cors());
+app.use(
+  express.json({
+    limit: '10mb',
+    verify: (req: express.Request, _res: express.Response, buf: Buffer) => {
+      (req as any).rawBody = buf.toString('utf8');
+    },
+  }),
+);
+
+// ---- 初始化数据库 ----
+initRelayDatabase(getDbPath());
+console.log('[Relay] 数据库初始化完成');
+
+// ---- 路由 ----
+app.use('/api', relayRoutes);
+
+// ---- 全局 404 ----
+app.use((_req, res) => {
+  res.status(404).json({ error: 'Not found' });
+});
+
+// ---- 启动 ----
+app.listen(PORT, () => {
+  console.log(`\n========================================`);
+  console.log(` 企微 Agent Skill 中央 Relay`);
+  console.log(` http://localhost:${PORT}`);
+  console.log(`========================================\n`);
+  console.log(` 健康检查:     http://localhost:${PORT}/api/health`);
+  console.log(` 创建租户:     POST http://localhost:${PORT}/api/admin/tenant`);
+  console.log(` 全局 Webhook: POST http://localhost:${PORT}/api/webhook/ingest`);
+  console.log(` 兼容 Webhook: POST http://localhost:${PORT}/api/webhook/ingest/:tenantId/:deviceGuid`);
+  console.log(` 客户端轮询:   POST http://localhost:${PORT}/api/poll`);
+  console.log(` 客户端确认:   POST http://localhost:${PORT}/api/ack\n`);
+});

+ 15 - 0
services/qiwei-relay/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "esModuleInterop": true,
+    "strict": true,
+    "outDir": "dist",
+    "rootDir": ".",
+    "resolveJsonModule": true,
+    "declaration": true
+  },
+  "include": ["server.ts", "**/*.ts"],
+  "exclude": ["node_modules", "dist"]
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов