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

更新 qiwei 企微助手:dashboard、relay/webhook 模块与多项 skills

- 新增 dashboard 服务端与启动脚本(mcp/src/dashboard/、scripts/start-dashboard.js)
- 新增 relay/webhook 相关核心模块(shared-gateway、webhook-server、relay-config、login-fallback-server)
- 新增 8 个 qiwei skills(broker-playbook、customer-ops、customer-transfer、dashboard、group-management、portrait-tags、voice、webhook-relay)
- 扩展 tools 列表,新增 agent-skill、customer-ops、group-management 等运行入口
- 完善登录流程与错误处理(login-flow-server、qiwei-login-run、credentials)
- 更新 README、接口文档与开发日志(docs/specs/、DASHBOARD-DEV-LOG.md)
- 更新 package.json、smoke-test 与 .env.example
- 更新根目录及子项目 .gitignore,忽略 *.log 与 qwmsgs* 等本地运行数据
ETO-kai 2 месяцев назад
Родитель
Сommit
c96212096d
44 измененных файлов с 12454 добавлено и 34 удалено
  1. 7 0
      .gitignore
  2. 12 0
      claude-code/claude-code-qiwe-assistant/.env.example
  3. 7 0
      claude-code/claude-code-qiwe-assistant/.gitignore
  4. 157 2
      claude-code/claude-code-qiwe-assistant/README.md
  5. 289 0
      claude-code/claude-code-qiwe-assistant/docs/DASHBOARD-DEV-LOG.md
  6. 479 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-add-tenant-register-api-plan.md
  7. 404 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-local-integration-guide.md
  8. 350 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-relay-mode-guide.md
  9. 324 0
      claude-code/claude-code-qiwe-assistant/docs/specs/qiwei-webhook-migration-guide.md
  10. 19 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js
  11. 118 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-fallback-server.js
  12. 140 11
      claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js
  13. 8 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js
  14. 224 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js
  15. 88 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/shared-gateway.js
  16. 157 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-server.js
  17. 2457 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  18. 73 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  19. 435 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  20. 899 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  21. 1 1
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  22. 783 0
      claude-code/claude-code-qiwe-assistant/mcp/src/server.js
  23. 355 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-skill-run.js
  24. 325 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js
  25. 437 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  26. 208 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js
  27. 942 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  28. 104 5
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  29. 534 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  30. 151 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-voice-run.js
  31. 418 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js
  32. 104 0
      claude-code/claude-code-qiwe-assistant/package-lock.json
  33. 4 1
      claude-code/claude-code-qiwe-assistant/package.json
  34. 415 13
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  35. 10 0
      claude-code/claude-code-qiwe-assistant/scripts/start-dashboard.js
  36. 139 0
      claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
  37. 82 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-broker-playbook/SKILL.md
  38. 115 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md
  39. 55 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-transfer/SKILL.md
  40. 110 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md
  41. 204 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-group-management/SKILL.md
  42. 118 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md
  43. 50 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-voice/SKILL.md
  44. 143 0
      claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

+ 7 - 0
.gitignore

@@ -47,3 +47,10 @@ milin-clashverge-wireguard-udp443.yaml
 
 # Generated runtime outputs
 output/
+
+# 企微助手本地运行日志与测试数据
+*.log
+qwmsgs.json
+qwmsgs500.json
+qwrooms-once.json
+qwsessions.json

+ 12 - 0
claude-code/claude-code-qiwe-assistant/.env.example

@@ -15,3 +15,15 @@ QIWEI_OFFICIAL_CLI_RUNTIME_DIR=
 
 # 可选:覆盖官方 CLI 的本地加密认证目录;默认 ~/.config/wecom。
 WECOM_CLI_CONFIG_DIR=
+
+# ---- Relay 中央 Webhook 配置(可选) ----
+# 中央 Relay 服务器地址,留空则不启用 Relay 模式
+RELAY_BASE_URL=http://8.138.37.248:4000
+
+# Relay 租户凭证(可由 qiwei_relay_register 自动写入)
+TENANT_API_KEY=
+TENANT_API_SECRET=
+TENANT_ID=
+
+# RSA 私钥,必须写成单行,用 \n 替换真实换行符
+RELAY_PRIVATE_KEY=

+ 7 - 0
claude-code/claude-code-qiwe-assistant/.gitignore

@@ -3,3 +3,10 @@ outputs/
 .env
 .env.local
 !.env.example
+
+# 本地运行日志与企微测试数据
+*.log
+qwmsgs.json
+qwmsgs500.json
+qwrooms-once.json
+qwsessions.json

+ 157 - 2
claude-code/claude-code-qiwe-assistant/README.md

@@ -32,16 +32,34 @@ Claude Code / MCP
 │       ├── core/api-catalog.js
 │       ├── core/wecom-cli-runtime.js
 │       ├── core/credentials.js
+│       ├── core/shared-gateway.js
+│       ├── core/webhook-server.js
+│       ├── core/output-paths.js
 │       ├── providers/fmode-wecom-gateway.js
 │       ├── providers/wecom-official-cli.js
 │       └── tools/
 │           ├── qiwei-api-catalog-run.js
 │           ├── qiwei-login-run.js
 │           ├── qiwei-subscription-run.js
+│           ├── qiwei-customer-ops-run.js
+│           ├── qiwei-group-management-run.js
+│           ├── qiwei-portrait-tags-run.js
+│           ├── qiwei-broker-playbook-run.js
+│           ├── qiwei-customer-transfer-run.js
+│           ├── qiwei-voice-run.js
+│           ├── qiwei-webhook-relay-run.js
 │           └── wecom-official-cli-run.js
 ├── skills/
 │   ├── qiwei-api-catalog/SKILL.md
 │   ├── qiwei-login/SKILL.md
+│   ├── qiwei-subscription/SKILL.md
+│   ├── qiwei-customer-ops/SKILL.md
+│   ├── qiwei-group-management/SKILL.md
+│   ├── qiwei-portrait-tags/SKILL.md
+│   ├── qiwei-broker-playbook/SKILL.md
+│   ├── qiwei-customer-transfer/SKILL.md
+│   ├── qiwei-voice/SKILL.md
+│   ├── qiwei-webhook-relay/SKILL.md
 │   ├── qiwei-capability-router/SKILL.md
 │   ├── qiwei-official-meeting/SKILL.md
 │   └── qiwei-official-doc/SKILL.md
@@ -54,15 +72,17 @@ Claude Code / MCP
 
 所有生成文件遵循 [docs/OUTPUT-STANDARD.md](docs/OUTPUT-STANDARD.md):
 
-- 运行期产物统一写入 `outputs/<类别>/`(`login`、`api-calls`、`subscription`、`meetings`、`docs`、`messages`、`smoke`、`tmp`),根目录可用 `QIWEI_OUTPUTS_DIR` 覆盖;
+- 运行期产物统一写入 `outputs/<类别>/`(`login`、`api-calls`、`subscription`、`meetings`、`docs`、`messages`、`groups`、`portraits`、`tags`、`broker-playbooks`、`transfers`、`voice`、`webhook`、`smoke`、`tmp`),根目录可用 `QIWEI_OUTPUTS_DIR` 覆盖;
 - 覆盖型文件用 latest 模式(如 `outputs/login/qiwei-login-qrcode.png`),按次归档用 run 模式(`outputs/<类别>/<YYYY-MM-DD>/<HHmmss>-<slug>/` + `manifest.json`);
 - 路径一律通过 `mcp/src/core/output-paths.js` 解析;
 - 校验:`npm run outputs:validate`。
 
 ## MCP 工具
 
+### 基础能力
+
 | 工具 | 说明 |
-|---|---|
+| --- | --- |
 | `qiwei_api_search` | 检索 100+ 个企业微信接口 |
 | `qiwei_api_doc` | 查看参数、返回字段和调用模板 |
 | `qiwei_api_call` | `POST /api/qiwei/doApi`,传 `{uid, method, params}` |
@@ -73,6 +93,91 @@ Claude Code / MCP
 | `qiwei_subscription_status` | 查询订阅、席位、到期时间和余额 |
 | `qiwei_subscribe` | 开通、续费或增购席位 |
 | `qiwei_subscription_auto_renew` | 设置自动续费 |
+
+### 客户运营
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_batch_add_friends` | 按手机号批量搜索并添加企微好友 |
+| `qiwei_auto_create_group` | 创建客户服务群、设置群名、邀请协作成员、发送欢迎语 |
+| `qiwei_check_friend_status` | 检查好友申请状态 |
+| `qiwei_get_customer_profile` | 查询客户档案 |
+
+### 群管理
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_sync_external_groups` | 同步外部群列表 |
+| `qiwei_list_external_groups` | 列出并识别客户群 |
+| `qiwei_analyze_group_members` | 分析群详情 |
+| `qiwei_confirm_external_group` | 确认外部群为客户群 |
+| `qiwei_add_external_group` | 手动添加外部群 |
+| `qiwei_sync_group_messages` | 同步群历史消息 |
+
+### 画像与标签
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_prepare_customer_portrait` | 准备客户画像分析上下文 |
+| `qiwei_update_customer_portrait` | 更新客户画像(Agent 驱动 / keyword 模式) |
+| `qiwei_save_customer_portrait` | 保存客户画像 |
+| `qiwei_batch_update_customer_portrait` | 批量更新客户画像 |
+| `qiwei_batch_save_customer_portrait` | 批量保存客户画像 |
+| `qiwei_export_customer_portraits` | 导出客户画像为 Excel |
+| `qiwei_add_customer_tags` | 添加客户本地标签 |
+| `qiwei_remove_customer_tags` | 移除客户本地标签 |
+| `qiwei_list_customer_tags` | 列出客户本地标签 |
+| `qiwei_list_all_tags` | 列出所有本地标签 |
+| `qiwei_sync_personal_labels` | 同步企微个人标签 |
+| `qiwei_create_personal_label` | 创建企微个人标签 |
+| `qiwei_update_personal_label` | 更新企微个人标签 |
+| `qiwei_delete_personal_label` | 删除企微个人标签 |
+| `qiwei_apply_personal_labels` | 应用企微个人标签到客户 |
+
+### 顾问 Playbook
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_prepare_broker_playbook` | 准备顾问 playbook 分析上下文 |
+| `qiwei_distill_broker` | 蒸馏顾问 playbook |
+| `qiwei_save_broker_playbook` | 保存顾问 playbook |
+| `qiwei_batch_distill_broker` | 批量蒸馏顾问 playbook |
+| `qiwei_batch_save_broker_playbook` | 批量保存顾问 playbook |
+| `qiwei_export_broker_playbooks` | 导出顾问 playbook 为 Excel |
+| `qiwei_get_broker_playbook` | 读取顾问 playbook |
+
+### 客户交接
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_preview_transfer_package` | 预览交接包 |
+| `qiwei_execute_transfer` | 执行群成员变更完成交接 |
+
+### 语音
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_transcribe_voice` | 保存语音、可选解码 silk、可选转写 |
+
+### Webhook 与 Relay
+
+| 工具 | 说明 |
+| --- | --- |
+| `qiwei_webhook_server_start` | 启动本地 webhook server |
+| `qiwei_webhook_server_stop` | 停止本地 webhook server |
+| `qiwei_webhook_status` | 查询 webhook 状态 |
+| `qiwei_webhook_discover` | 获取本地 webhook 回调地址 |
+| `qiwei_webhook_auto_setup` | 自动配置企微回调 |
+| `qiwei_webhook_setup` | 手动配置企微回调 |
+| `qiwei_relay_config` | 读取 relay 配置 |
+| `qiwei_relay_save_config` | 保存 relay 公钥/租户配置 |
+| `qiwei_relay_register` | 注册 Relay 租户并保存凭证 |
+| `qiwei_relay_connect` | 配置 Relay 回调地址 |
+
+### 官方 CLI
+
+| 工具 | 说明 |
+| --- | --- |
 | `qiwei_official_status` | 检查官方 CLI 下载与授权状态 |
 | `qiwei_official_prepare` | 下载并缓存固定版本官方 CLI |
 | `qiwei_official_help` | 读取官方 category/method 帮助 |
@@ -151,6 +256,56 @@ npm run outputs:validate
 冒烟测试会启动本地 mock Fmode 网关,验证 Authorization、`uid/method/params` 请求信封、登录和订阅接口,不访问真实服务。
 同时会使用本地假 CLI 验证官方运行时状态、结构化参数传递和命令注入防护,不访问真实企业微信。
 
+## Dashboard(本地 Web 界面)
+
+本项目包含一个独立的本地 Web Dashboard,用于在浏览器中管理客户群、账号状态等:
+
+```bash
+cd claude-code/claude-code-qiwe-assistant
+npm run dashboard
+```
+
+启动后访问:
+
+```text
+http://127.0.0.1:4320/
+```
+
+### 为什么必须在项目目录下启动
+
+Dashboard 需要读取项目根目录的 `.env.local` 才能拿到 `QIWEI_AUTH_TOKEN`、`QIWEI_UID` 和 `QIWEI_API_BASE`。如果从错误目录启动,会显示「网络请求失败」或鉴权失败,即使浏览器能直接访问 `https://server.fmode.cn/`。
+
+### 端口与状态
+
+- 默认端口 `4320`,可通过 `QIWEI_DASHBOARD_PORT` 覆盖;
+- 健康检查:`curl http://127.0.0.1:4320/api/health`;
+- 状态汇总:`curl http://127.0.0.1:4320/api/status`。
+
+### 离线恢复
+
+账号离线时,Dashboard「账号状态」页会显示「恢复登录」按钮。系统也会自动尝试免扫码恢复登录;若无法自动恢复,点击按钮后会进入二维码/验证码登录流程。
+
+## 子 Skill 索引
+
+Agent 可按业务场景直接定位到对应 Skill 文档,每个 Skill 内部包含标准流程、前置条件、错误处理和工具选择建议。
+
+| Skill | 路径 | 适用场景 | 核心工具 |
+| --- | --- | --- | --- |
+| qiwei-dashboard | skills/qiwei-dashboard/SKILL.md | 启动本地 Web Dashboard、检查服务状态、恢复登录 | npm run dashboard、/api/health、/api/status |
+| qiwei-api-catalog | skills/qiwei-api-catalog/SKILL.md | 检索、阅读、调用企业微信开放接口 | qiwei_api_search、qiwei_api_doc、qiwei_api_call |
+| qiwei-login | skills/qiwei-login/SKILL.md | 设备登录、扫码、验证码 | qiwei_login_status、qiwei_login_start、qiwei_login_check、qiwei_login_verify |
+| qiwei-subscription | skills/qiwei-subscription/SKILL.md | 订阅查询、开通、续费、自动续费 | qiwei_subscription_status、qiwei_subscribe、qiwei_subscription_auto_renew |
+| qiwei-customer-ops | skills/qiwei-customer-ops/SKILL.md | 批量加好友、自动建群、客户档案 | qiwei_batch_add_friends、qiwei_auto_create_group、qiwei_check_friend_status、qiwei_get_customer_profile |
+| qiwei-group-management | skills/qiwei-group-management/SKILL.md | 同步群列表、识别/确认客户群、同步群消息 | qiwei_sync_external_groups、qiwei_list_external_groups、qiwei_confirm_external_group、qiwei_sync_group_messages |
+| qiwei-portrait-tags | skills/qiwei-portrait-tags/SKILL.md | 客户画像分析、标签管理、企微个人标签 | qiwei_update_customer_portrait、qiwei_save_customer_portrait、qiwei_add_customer_tags、qiwei_apply_personal_labels |
+| qiwei-broker-playbook | skills/qiwei-broker-playbook/SKILL.md | 顾问 playbook 蒸馏、保存、导出 | qiwei_distill_broker、qiwei_save_broker_playbook、qiwei_export_broker_playbooks |
+| qiwei-customer-transfer | skills/qiwei-customer-transfer/SKILL.md | 客户交接预览与执行 | qiwei_preview_transfer_package、qiwei_execute_transfer |
+| qiwei-voice | skills/qiwei-voice/SKILL.md | 语音保存、解码、转写 | qiwei_transcribe_voice |
+| qiwei-webhook-relay | skills/qiwei-webhook-relay/SKILL.md | 本地 webhook 接收、企微回调配置、Relay 自动注册与配置 | qiwei_webhook_server_start、qiwei_webhook_auto_setup、qiwei_relay_register、qiwei_relay_connect、qiwei_relay_save_config |
+| qiwei-capability-router | skills/qiwei-capability-router/SKILL.md | 选择 Fmode 网关还是官方 CLI 通道 | 按 Skill 内部规则路由 |
+| qiwei-official-meeting | skills/qiwei-official-meeting/SKILL.md | 官方会议能力 | qiwei_official_call |
+| qiwei-official-doc | skills/qiwei-official-doc/SKILL.md | 官方文档能力 | qiwei_official_call |
+
 ## 限制
 
 - `cloud` 模块的 multipart 文件直传尚未由 Fmode 网关支持,`qiwei_api_call` 会拒绝这类接口。

+ 289 - 0
claude-code/claude-code-qiwe-assistant/docs/DASHBOARD-DEV-LOG.md

@@ -0,0 +1,289 @@
+# Qiwei Dashboard 开发过程记录
+
+## 项目背景
+
+为 `claude-code/claude-code-qiwe-assistant` 项目构建一个本地 Web Dashboard,让用户可以通过浏览器直观地完成企微核心能力的操作,降低纯对话调用的门槛。第一期聚焦在**客户群管理**模块,同时搭建好账号状态、登录恢复等公共基础设施。
+
+## 已完成的模块
+
+### 1. Dashboard 基础设施
+
+- **HTTP 桥接服务**:`mcp/src/dashboard/server.js`
+  - 静态资源服务(HTML/CSS/JS)
+  - REST API 路由封装
+  - 异步 Job 队列(`createJob` / `getJob`)
+  - 文件上传与下载(`/api/upload`、`/api/outputs`)
+  - 状态汇总接口(`/api/status`)
+  - 登录相关接口(`/api/login/start`、`/api/login/check`、`/api/login/verify`)
+  - 群管理接口(`/api/groups/*`)
+  - **客户运营接口(新增)**:`/api/customer-ops/*`
+  - **画像与标签接口(新增)**:`/api/portraits/*`、`/api/tags/*`、`/api/personal-labels/*`
+  - **客户交接接口(新增)**:`/api/transfers/*`
+
+- **前端 SPA 骨架**:`mcp/src/dashboard/index.html`
+  - 侧边栏导航、顶部账号切换器、主内容区、Toast 容器
+  - **新增导航项(客户运营、画像与标签、客户交接)**
+
+- **前端逻辑**:`mcp/src/dashboard/app.js`
+  - 路由(hash 路由:`#groups`、`#status`、`#customer-ops`、`#portraits`、`#transfers`)
+  - API 客户端封装
+  - 状态管理(内存 + localStorage)
+  - 公共组件:Toast、Modal、Job Tracker、Account Switcher
+  - **新增页面渲染器**:`renderCustomerOpsPage`、`renderPortraitsPage`、`renderTransfersPage`
+
+- **样式系统**:`mcp/src/dashboard/styles.css`
+  - 复用企微品牌色(`#fa8c16`)
+  - 卡片、表格、表单、按钮、Badge、进度条、骨架屏、动画
+
+- **启动入口**:`scripts/start-dashboard.js`
+- **文档**:`skills/qiwei-dashboard/SKILL.md`、`README.md` Dashboard 章节
+
+### 2. 账号状态模块
+
+实现位置:`app.js` 中 `renderStatusPage`、`attemptAutoRecover`、`recoverLogin`、`syncAccountOnlineStatus` 等函数。
+
+- 显示当前账号在线/离线状态、订阅状态、鉴权配置状态
+- **离线恢复登录按钮**:账号离线时显示「恢复登录」按钮
+- **自动恢复登录**:检测到离线且 `statusCode === 0`(设备已配置)时,自动调用 `/api/login/check?manual=true` 尝试免扫码恢复
+- 手动恢复时若无法免扫码,自动生成二维码/验证码流程
+- **已保存账号状态同步**:修复了已保存账号表格状态与当前状态不一致的问题
+
+### 3. 客户群管理模块
+
+实现位置:`app.js` 中 `renderGroupsPage`、`renderGroupTable`、`renderGroupSyncCard`、`bindGroupSync`、`bindGroupFilters`、`bindGroupTableActions`、`confirmGroup`、`batchConfirmGroups`、`syncGroupMessages`、`openGroupDetailModal` 等函数。
+
+#### 群列表展示
+
+- 表格展示:群名、人数、识别结果、置信度、消息同步状态、命中关键词、操作
+- 空状态提示
+- 搜索过滤(按群名/roomId)
+- 状态过滤(全部、自动确认、建议确认、已确认、已忽略)
+- **显示切换按钮**:「只看可能的客户群」/「展示全部状态的群聊」,默认展示全部群聊
+
+#### 扫描客户群
+
+- 扫描范围:全部群、我创建的群、最近聊天里的群、从消息记录里找群
+- 扫描深度(分页数)
+- 客户群关键词、高置信度词配置
+- 自动识别客户群开关
+- 异步 Job 执行扫描,实时进度条
+- 扫描结果统计卡片:扫描群数、自动确认、建议确认、普通群、已确认、已忽略
+
+#### 批量操作
+
+- 全选本页/单选
+- **批量确认为客户群**:直接调用 confirm API,不弹窗
+- **批量同步选中群消息**
+
+#### 单行操作
+
+- **确认**:直接确认为客户群(不弹窗)
+- **忽略**:弹出原因输入框
+- **同步消息**:同步单个群消息
+- **群详情**(仅已确认群):查看/编辑客户信息
+
+#### 客户信息自动识别
+
+- 后端 `qiwei-group-management-run.js` 中 `createRoomRecord` 现在保存群成员列表
+- 已确认群的「群详情」弹窗会自动从成员列表中识别客户:
+  - 优先选择 `type === 2` 的外部联系人
+  - 无明确标识时排除常见内部角色后取第一个
+  - 自动预填充客户姓名和客户企微 ID
+- 用户可在弹窗中修改客户信息并保存
+
+#### 消息同步状态持久化
+
+- 已同步消息的群状态保存在 `localStorage`
+- 修复了刷新页面后同步状态丢失的问题(移除了账号离线时清空同步状态的逻辑)
+
+### 4. 客户运营模块(新增)
+
+实现位置:`app.js` 中 `renderCustomerOpsPage`、`bindCustomerOpsPage` 及相关渲染函数。
+
+#### 批量加好友
+
+- 文本导入:每行 `13800138000 张三`,自动解析为可编辑表格
+- Excel 上传:通过 `/api/upload` 保存后读取 customers 列表追加到表格
+- 默认验证消息模板,支持 `{{name}}`、`{{phone}}` 变量
+- 表格中每行可单独编辑姓名和验证消息,留空则使用默认模板
+- 顶部实时预览第一条验证消息
+- 执行设置:每分钟速率限制(1-60)、最大重试次数(1-5)
+- 异步 Job 执行,结果展示统计卡片 + 明细表格
+
+#### 好友状态检查
+
+- 输入手机号列表,异步 Job 执行
+- 结果展示:已确认、待通过、未找到统计 + 明细表格
+- 状态徽标:已是好友、已被其他人添加、未添加、未找到
+
+#### 客户档案
+
+- 通过手机号或 externalUserId 查询
+- 展示 externalUserId、匹配联系人、相关群、本地画像
+
+#### 自动建群
+
+- 输入成员 externalUserId、群名、协作成员、欢迎语
+- 异步 Job 执行建群
+- 展示 roomId、群名、成员数、协作成员邀请状态、欢迎语发送状态
+
+### 5. 画像与标签模块(新增)
+
+实现位置:`app.js` 中 `renderPortraitsPage`、`bindPortraitsPage` 及相关渲染函数。
+
+#### 客户画像
+
+- 关键词模式:直接生成并保存画像
+- AI 分析模式:生成 context 文件,可下载后人工分析
+- 手动保存画像 JSON:通过 prompt 输入 JSON 后保存
+
+#### 批量画像
+
+- 输入多个 externalUserId,关键词模式批量生成
+
+#### 导出画像
+
+- 异步 Job 导出全部本地画像为 Excel
+- 结果提供下载链接
+
+#### 本地标签
+
+- 按客户添加/移除/查询标签
+- 查看全部去重标签
+
+#### 企微个人标签
+
+- 同步企微个人标签列表
+- 创建/更新/删除个人标签
+- 应用标签到指定客户
+
+### 6. 客户交接模块(新增)
+
+实现位置:`app.js` 中 `renderTransfersPage`、`bindTransfersPage` 及相关渲染函数。
+
+- 输入 `fromUserId` / `toUserId`
+- 支持通过 externalUserIds 或已确认群 roomIds 指定客户
+- 生成交接包预览文件,展示明细列表
+- 执行交接:可移除原顾问、设置欢迎语
+- 结果展示成功/失败/跳过统计与明细
+
+### 7. Bug 修复与体验优化
+
+| 问题 | 修复 |
+| --- | --- |
+| 账号状态页「已保存账号」状态与当前在线状态不一致 | 新增 `syncAccountOnlineStatus`,在渲染表格前先同步状态 |
+| 刷新页面后已同步消息群显示「未同步」 | 移除 `checkExistingLogin` 中的 `resetSyncedGroups()` 无条件清空逻辑 |
+| 账号离线时点击「扫描客户群」提示不明确 | 增加离线拦截,提示先恢复登录并跳转到状态页 |
+| 离线时点击「同步消息」也会失败 | 同样增加离线拦截 |
+| Dashboard 从错误目录启动导致「网络请求失败」 | 在 README 和 Skill 文档中明确必须在项目目录下启动 |
+
+## 尚未实现的内容(对比原始 plan)
+
+原始 plan 中提到的以下模块已在本轮实现:
+
+- 客户运营:批量加好友、检查好友状态、客户档案、自动建群
+- 画像与标签:客户画像、本地标签、企微个人标签
+- 客户交接:交接包预览、执行交接
+
+当前尚未实现的扩展模块:
+
+- API Catalog
+- 语音转写
+- Webhook & Relay
+- 顾问 Playbook
+
+当前 Dashboard 已完成 plan 中「系统状态」、「群管理」、「客户运营」、「画像与标签」、「客户交接」五个模块的核心功能。
+
+## 与原始 plan 的差异
+
+### 客户运营模块实现与 plan 的差异
+
+1. **Excel 上传流程**
+   - Plan:前端直接解析 Excel。
+   - 实际:前端通过 `/api/upload` 上传文件拿到路径后,调用 `qiweiBatchAddFriends` 读取 Excel 并追加到表格。这样复用了后端已有的 `readCustomersFromExcel` 逻辑。
+
+2. **客户档案查询**
+   - Plan:仅提及输入手机号查询。
+   - 实际:同时支持手机号或 externalUserId 查询。
+
+### 群管理模块实现与 plan 的差异
+
+1. **确认客户群流程**
+   - Plan:未明确描述确认流程细节
+   - 实际:单个/批量确认均不弹窗,确认后才通过「群详情」编辑客户信息
+
+2. **客户信息自动识别**
+   - Plan:未提及
+   - 实际:基于群成员列表自动识别外部联系人并预填充客户信息
+
+3. **关键词配置**
+   - Plan:独立的「关键词配置」折叠面板
+   - 实际:关键词配置集成在「扫描客户群」卡片中
+
+4. **同步群消息**
+   - Plan:支持多选已确认群或「全部已确认群」,设置分页和每群消息上限
+   - 实际:支持多选已确认群批量同步,但暂不支持「全部已确认群」一键同步和分页设置
+
+5. **群列表默认展示**
+   - Plan:未明确默认过滤行为
+   - 实际:默认展示全部群聊,可通过按钮切换为「只看可能的客户群」
+
+6. **添加已创建客户群按钮**
+   - Plan:未明确
+   - 实际:曾经实现后已删除
+
+## Plan 修改建议
+
+基于当前已完成的群管理模块,原始 plan 应在以下方面更新:
+
+1. **群管理确认流程需要明确**
+   - 当前 plan 只写「行操作包括确认、拒绝、分析、同步消息」,未说明确认时是否弹窗、是否需要填写客户信息。
+   - 建议改为:「确认」操作不弹窗,直接标记为客户群;客户信息在确认后通过「群详情」编辑,并支持自动识别。
+
+2. **新增「客户信息自动识别」功能**
+   - 当前 plan 完全未提及。
+   - 建议在群管理交互中增加:已确认群支持查看群成员,系统自动识别外部联系人并预填充客户姓名和企微 ID。
+
+3. **关键词配置位置调整**
+   - Plan 中写「关键词配置折叠面板」。
+   - 实际实现中关键词配置集成在「扫描客户群」卡片内,以减少页面切换。Plan 应更新为「扫描客户群卡片内包含关键词/高置信度词输入」。
+
+4. **同步群消息范围调整**
+   - Plan 写「多选已确认群或全部已确认群,设置分页和每群消息上限」。
+   - 当前仅实现「多选已确认群」批量同步。Plan 可拆分为已实现和后续增强:
+     - 已实现:多选已确认群批量同步
+     - 待实现:「全部已确认群」一键同步、分页/每群上限设置
+
+5. **群列表默认展示状态**
+   - Plan 未明确默认过滤行为。
+   - 建议补充:默认展示全部扫描到的群聊,提供按钮切换为「只看可能的客户群」。
+
+6. **系统状态模块补充**
+   - Plan 中系统状态只写「登录状态、订阅状态、全局 guid 输入」。
+   - 实际已实现:离线恢复登录按钮、自动恢复登录、已保存账号列表及状态同步。Plan 应补充这些功能。
+
+7. **新增文件清单修正**
+   - Plan 中列出 `mcp/src/dashboard/pages.js`(可选拆分)。
+   - 当前未拆分,app.js 约 1300 行仍可维护。建议从 plan 中移除或标注为「未拆分」。
+
+8. **范围与优先级调整**
+   - 当前实际只完成了系统状态 + 群管理。建议把客户运营、画像标签、客户交接明确标注为「待实现/第二期」,避免与实际进度混淆。
+
+9. **新增验证项**
+   - Plan 的验证方案中应增加:
+     - 账号离线时恢复登录按钮可用
+     - 自动恢复登录成功/失败场景
+     - 已同步消息状态刷新后保留
+     - 批量确认客户群和群详情编辑
+
+10. **移除「添加已创建客户群」按钮**
+    - 实际开发中曾实现该按钮,后已删除。Plan 中如提到手动添加群,应说明通过业务工具或后续在群列表中提供入口。
+
+## 后续建议
+
+1. 群管理可补充「全部已确认群一键同步消息」和同步参数设置
+2. 客户信息自动识别可进一步优化:结合群名正则、AI 分析群消息等多维度识别
+3. 客户运营可补充「从已确认群批量导入客户」功能
+4. 画像模块可接入 Claude API 自动分析 context 文件并保存
+5. 增加 Dashboard 的使用统计和错误日志收集
+6. 按需扩展 API Catalog、语音转写、Webhook & Relay、顾问 Playbook 等模块

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

@@ -0,0 +1,479 @@
+---
+title: 在 Fmode 现有 Relay 上新增租户自助注册接口的实施计划
+updated: 2026-07-16
+project: claude-code-qiwe-assistant
+scope: server-side-relay
+status: plan
+---
+
+# 在 Fmode 现有 Relay 上新增租户自助注册接口的实施计划
+
+> 本计划用于指导其他会话在 Fmode 现有 Relay 服务端(`8.138.37.248`)上新增一个租户自助注册接口。接口上线后,用户可通过调用该接口获取自己的 Relay 凭证,实现「一键开通 Relay」。
+
+## 一、目标
+
+在现有 Relay 服务端新增:
+
+1. `POST /api/tenant/register`:用户凭 Fmode token 自助注册 Relay 租户,返回完整凭证。
+2. 配套的数据库/文件存储结构,保存租户与 RSA 公钥。
+3. 鉴权与风控机制,防止滥用。
+
+用户拿到返回的凭证后,可直接配置到本地 Skill:
+
+```text
+RELAY_BASE_URL=http://8.138.37.248:4000
+TENANT_API_KEY=qk_xxx
+TENANT_API_SECRET=xxx
+RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n...
+```
+
+## 二、前提条件
+
+⚠️ **执行本计划前必须确认**:
+
+1. 拥有 Relay 服务端所在服务器(`8.138.37.248`)的 SSH/远程访问权限。
+2. 拥有 Relay 服务源代码的读取和修改权限。
+3. 拥有重新部署/重启 Relay 服务的权限。
+4. 了解当前 Relay 服务的技术栈(Node.js/Python/Go 等)。
+5. 拥有数据库修改权限(如果 Relay 使用数据库存储租户信息)。
+
+如果以上任一条件不满足,应放弃本计划,改用「自建独立 Relay 服务端」方案。
+
+## 三、当前 Relay 服务端现状(基于 Qiwei 项目反推)
+
+从 Qiwei 项目配置可推断当前 Relay 服务端已具备以下能力:
+
+- 接收企微回调:`POST /api/webhook/ingest/:tenantId/:guid`
+- 长轮询取事件:`POST /api/relay/poll`
+- ACK 事件:`POST /api/relay/ack`
+- 租户鉴权:通过 `Authorization: Bearer <TENANT_API_SECRET>`
+- 事件加密:使用 RSA 公钥加密,本地私钥解密
+
+但缺少:
+
+- 租户自助注册接口
+- 用户通过 Fmode token 自动开户的能力
+
+## 四、接口设计
+
+### 4.1 新增接口:`POST /api/tenant/register`
+
+**功能**:用户使用 Fmode token 申请开通 Relay 租户,服务端生成凭证并返回。
+
+**请求头**:
+
+```http
+POST /api/tenant/register
+Content-Type: application/json
+Authorization: Bearer <Fmode token>
+```
+
+**请求体**(可选):
+
+```json
+{
+  "description": "张三的本地 Skill",
+  "deviceGuid": "可选,预注册设备"
+}
+```
+
+**响应体**:
+
+```json
+{
+  "success": true,
+  "relayBaseUrl": "http://8.138.37.248:4000",
+  "tenantId": "tenant_550e8400e29b41d4a716446655440000",
+  "apiKey": "qk_d91470c99a3075afd9b582453ee2590d",
+  "apiSecret": "afea80574a9359ed2499967d11f3c7c2c8906b3d239e2d02e6e9a24b2478d178",
+  "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----",
+  "createdAt": "2026-07-16T08:00:00.000Z"
+}
+```
+
+**错误响应**:
+
+```json
+{
+  "success": false,
+  "error": "Fmode token 无效",
+  "code": "INVALID_TOKEN"
+}
+```
+
+### 4.2 新增接口:`GET /api/tenant/status`
+
+**功能**:用户查询自己租户的状态和已用配额。
+
+```http
+GET /api/tenant/status
+Authorization: Bearer <TENANT_API_SECRET>
+```
+
+**响应**:
+
+```json
+{
+  "success": true,
+  "tenantId": "tenant_xxx",
+  "createdAt": "2026-07-16T08:00:00.000Z",
+  "eventCount24h": 1280,
+  "pendingEventCount": 3
+}
+```
+
+### 4.3 现有接口增强
+
+- `/api/webhook/ingest/:tenantId/:guid`:保持不变,继续接收企微回调。
+- `/api/relay/poll`:保持不变,继续使用 `TENANT_API_SECRET` 鉴权。
+- `/api/relay/ack`:保持不变。
+
+## 五、数据库/存储变更
+
+### 5.1 租户表(新增/扩展)
+
+如果当前 Relay 已有租户表,增加字段;如果没有,新建表。
+
+```sql
+CREATE TABLE relay_tenants (
+  tenant_id TEXT PRIMARY KEY,
+  api_key TEXT UNIQUE NOT NULL,
+  api_secret TEXT NOT NULL,           -- 生产环境建议存哈希
+  api_secret_hash TEXT NOT NULL,      -- bcrypt/scrypt 哈希
+  public_key TEXT NOT NULL,           -- RSA 公钥,用于加密事件
+  fmode_user_id TEXT,                 -- 关联 Fmode 用户/公司(用于风控)
+  description TEXT,
+  max_devices INTEGER DEFAULT 10,     -- 最多绑定设备数
+  daily_event_limit INTEGER DEFAULT 100000,
+  created_at TIMESTAMP DEFAULT NOW(),
+  updated_at TIMESTAMP DEFAULT NOW(),
+  is_active BOOLEAN DEFAULT TRUE
+);
+
+CREATE INDEX idx_relay_tenants_api_key ON relay_tenants(api_key);
+CREATE INDEX idx_relay_tenants_fmode_user_id ON relay_tenants(fmode_user_id);
+```
+
+### 5.2 事件队列表
+
+如果当前 Relay 使用文件队列,可保持不变;如果升级为数据库:
+
+```sql
+CREATE TABLE relay_events (
+  event_id TEXT PRIMARY KEY,
+  tenant_id TEXT NOT NULL REFERENCES relay_tenants(tenant_id),
+  device_guid TEXT,
+  payload TEXT NOT NULL,              -- 事件原始 JSON
+  encrypted_payload TEXT,             -- 可选:预加密存储
+  received_at TIMESTAMP DEFAULT NOW(),
+  acked_at TIMESTAMP,
+  is_acked BOOLEAN DEFAULT FALSE
+);
+
+CREATE INDEX idx_relay_events_tenant_acked ON relay_events(tenant_id, is_acked);
+CREATE INDEX idx_relay_events_device ON relay_events(tenant_id, device_guid);
+```
+
+## 六、实施步骤
+
+### 步骤 1:备份现有 Relay 服务
+
+1. 登录 `8.138.37.248` 服务器。
+2. 备份当前 Relay 代码目录。
+3. 备份当前数据库/租户数据。
+4. 记录当前部署脚本和进程管理配置(PM2/systemd)。
+
+### 步骤 2:修改 Relay 服务端代码
+
+新增或修改以下模块:
+
+#### 6.2.1 租户生成模块
+
+```js
+// relay-server/src/tenant-service.js
+const crypto = require('crypto');
+
+function generateTenant(fmodeUserId, description) {
+  const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
+    modulusLength: 2048,
+    publicKeyEncoding: { type: 'spki', format: 'pem' },
+    privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
+  });
+
+  const tenantId = `tenant_${crypto.randomUUID().replace(/-/g, '')}`;
+  const apiKey = `qk_${crypto.randomBytes(16).toString('hex')}`;
+  const apiSecret = crypto.randomBytes(32).toString('hex');
+
+  return {
+    tenantId,
+    apiKey,
+    apiSecret,          // 明文返回给用户,只出现一次
+    apiSecretHash: hashSecret(apiSecret), // 服务端存哈希
+    publicKey,
+    privateKey,         // 只返回给用户,服务端不存
+    fmodeUserId,
+    description,
+    createdAt: new Date().toISOString()
+  };
+}
+
+function hashSecret(secret) {
+  return crypto.createHash('sha256').update(secret).digest('hex');
+  // 或更安全的 bcrypt:require('bcrypt').hashSync(secret, 10)
+}
+
+module.exports = { generateTenant, hashSecret };
+```
+
+#### 6.2.2 Fmode token 校验模块
+
+```js
+// relay-server/src/fmode-auth.js
+async function verifyFmodeToken(token) {
+  // 方式 A:调 Fmode 网关的 /user/info 或 /validate 接口
+  const response = await fetch('https://server.fmode.cn/api/auth/validate', {
+    method: 'GET',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+
+  if (!response.ok) throw new Error('Invalid Fmode token');
+  return await response.json(); // 返回 userId / companyId 等
+}
+```
+
+> 如果 Fmode 没有公开 token 校验接口,需要与 Fmode 后端协商,或改用「管理员手动审批」模式。
+
+#### 6.2.3 注册接口路由
+
+```js
+// relay-server/src/routes/tenant.js
+const express = require('express');
+const router = express.Router();
+const { generateTenant } = require('../tenant-service');
+const { verifyFmodeToken } = require('../fmode-auth');
+const db = require('../db');
+
+router.post('/register', async (req, res) => {
+  try {
+    const auth = req.headers.authorization || '';
+    const token = auth.replace(/^Bearer\s+/i, '');
+    if (!token) return res.status(401).json({ success: false, error: '缺少 Fmode token' });
+
+    // 1. 校验 Fmode token
+    const fmodeUser = await verifyFmodeToken(token);
+
+    // 2. 风控:检查该用户是否已注册
+    const existing = await db.findTenantByFmodeUserId(fmodeUser.userId);
+    if (existing) {
+      return res.status(409).json({
+        success: false,
+        error: '该 Fmode 账号已开通 Relay',
+        tenantId: existing.tenant_id
+      });
+    }
+
+    // 3. 生成租户
+    const tenant = generateTenant(fmodeUser.userId, req.body.description);
+
+    // 4. 保存到数据库(不存私钥和明文 apiSecret)
+    await db.createTenant({
+      tenant_id: tenant.tenantId,
+      api_key: tenant.apiKey,
+      api_secret_hash: tenant.apiSecretHash,
+      public_key: tenant.publicKey,
+      fmode_user_id: tenant.fmodeUserId,
+      description: tenant.description,
+      created_at: tenant.createdAt
+    });
+
+    // 5. 返回凭证(私钥只返回这一次)
+    res.json({
+      success: true,
+      relayBaseUrl: process.env.RELAY_PUBLIC_URL || `http://${req.headers.host}`,
+      tenantId: tenant.tenantId,
+      apiKey: tenant.apiKey,
+      apiSecret: tenant.apiSecret,
+      privateKey: tenant.privateKey,
+      createdAt: tenant.createdAt
+    });
+  } catch (err) {
+    console.error('[Relay] 租户注册失败:', err.message);
+    res.status(500).json({ success: false, error: err.message });
+  }
+});
+
+module.exports = router;
+```
+
+#### 6.2.4 修改鉴权中间件
+
+现有 `/api/relay/poll` 和 `/api/relay/ack` 通过 `TENANT_API_SECRET` 鉴权。如果之前是明文比对,改为哈希比对:
+
+```js
+function authenticateTenant(req, res, next) {
+  const auth = req.headers.authorization || '';
+  const secret = auth.replace(/^Bearer\s+/i, '');
+  const tenant = db.findTenantByApiSecret(secret); // 内部用 hash 比对
+  if (!tenant) return res.status(401).json({ error: 'unauthorized' });
+  req.tenant = tenant;
+  next();
+}
+
+app.use('/api/relay/poll', authenticateTenant);
+app.use('/api/relay/ack', authenticateTenant);
+```
+
+### 步骤 3:配置环境变量
+
+在 Relay 服务端新增:
+
+```bash
+# Relay 公网地址
+RELAY_PUBLIC_URL=http://8.138.37.248:4000
+
+# 管理员接口密钥(可选,用于 /api/admin/*)
+ADMIN_TOKEN=your_very_long_random_admin_token
+
+# Fmode 网关地址
+FMODE_GATEWAY_URL=https://server.fmode.cn
+```
+
+### 步骤 4:数据库迁移
+
+执行第 5 节的 SQL,创建/扩展租户表和事件表。
+
+### 步骤 5:部署与重启
+
+1. 上传修改后的代码到服务器。
+2. 安装新增依赖(如 `bcrypt`、`node-fetch` 等)。
+3. 执行数据库迁移。
+4. 重启 Relay 服务。
+5. 检查日志确认启动成功。
+
+### 步骤 6:接口测试
+
+#### 6.6.1 测试注册接口
+
+```bash
+curl -X POST http://8.138.37.248:4000/api/tenant/register \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer <Fmode token>" \
+  -d '{"description":"测试租户"}'
+```
+
+期望返回包含 `tenantId`、`apiKey`、`apiSecret`、`privateKey`、`relayBaseUrl`。
+
+#### 6.6.2 测试 poll 接口
+
+```bash
+curl -X POST http://8.138.37.248:4000/api/relay/poll \
+  -H "Content-Type: application/json" \
+  -H "Authorization: Bearer <TENANT_API_SECRET>" \
+  -d '{"guid":"your-device-guid","batchSize":10}'
+```
+
+期望返回 `{ "success": true, "events": [] }`。
+
+#### 6.6.3 测试企微回调链路
+
+1. 用返回的 `tenantId` 和 `guid` 拼接回调地址:
+   ```text
+   http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
+   ```
+2. 通过 Fmode 网关 `/client/setCallback` 或平台后台配置该地址。
+3. 触发一个企微事件(如发送群消息)。
+4. 调用 `/api/relay/poll`,应能取回加密事件。
+5. 用返回的 `privateKey` 解密验证。
+
+## 七、本地 Skill 侧对接
+
+接口上线后,本地 Skill(目标项目)需要增强以下能力:
+
+1. **新增工具 `qiwei_relay_register`**:调用 `/api/tenant/register`,自动保存返回的凭证。
+2. **增强 `qiwei_relay_save_config`**:支持保存 `tenantApiKey`、`tenantApiSecret`、`relayPrivateKey`。
+3. **增强 `qiwei_relay_connect`**:保存凭证后自动启动 Relay 长轮询客户端。
+4. **新增 `scripts/start-relay-client.js`**:独立进程运行长轮询。
+
+用户侧的使用流程:
+
+```text
+1. 用户调用 qiwei_relay_register
+   输入:Fmode token
+   输出:Relay 凭证
+
+2. Skill 自动把凭证写入 .env.local 和 outputs/webhook/relay-config.json
+
+3. 用户调用 qiwei_relay_connect
+   Skill 启动 relay-client.js 长轮询
+
+4. 用户调用 qiwei_webhook_auto_setup
+   Skill 自动把回调地址配到 Fmode 网关:
+   http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
+
+5. 用户正常使用,实时事件自动推送到本地
+```
+
+## 八、安全与风控要求
+
+1. **Fmode token 校验**:注册接口必须校验 Fmode token,否则任何人都能批量开户。
+2. **单个用户限制**:一个 Fmode 用户只能注册一个租户,防止滥用。
+3. **租户配额**:限制每个租户的设备数、每日事件数、队列长度。
+4. **私钥只返回一次**:服务端不保存私钥,丢失后只能重置租户。
+5. **apiSecret 存哈希**:服务端永远不要存明文 `apiSecret`。
+6. **HTTPS 强制**:生产环境注册接口必须走 HTTPS,私钥传输不能明文。
+7. **限流**:注册接口按 IP 和 Fmode 用户限流,防止刷接口。
+8. **审计日志**:记录每次注册、重置、删除租户的操作。
+
+## 九、回滚方案
+
+如果接口上线后出现问题:
+
+1. 立即回滚到上一版本代码。
+2. 恢复旧版数据库(如果迁移失败)。
+3. 关闭 `/api/tenant/register` 接口访问(防火墙或路由层)。
+4. 通知已注册用户凭证失效,等待修复后重新注册。
+
+## 十、风险与限制
+
+| 风险 | 说明 | 应对 |
+|------|------|------|
+| 无服务器权限 | 如果无法访问 `8.138.37.248`,本计划无法执行 | 改用自建 Relay 服务端 |
+| Fmode token 校验困难 | 如果 Fmode 没有公开校验接口 | 与 Fmode 协商,或改为管理员审批模式 |
+| 私钥丢失 | 用户丢失私钥后无法解密历史事件 | 提供重置接口(重新生成密钥对,旧事件废弃) |
+| 单点故障 | Relay 服务端宕机,所有用户收不到事件 | 部署多实例 + 负载均衡 + 监控告警 |
+| 数据泄露 | 事件 payload 包含聊天记录 | RSA 加密 + HTTPS + 服务端不存私钥 |
+
+## 十一、如果无法修改 Fmode 现有 Relay
+
+如果最终发现没有 `8.138.37.248` 的修改权限,应立即切换到备用方案:
+
+**自建独立 Relay 服务端**
+
+- 在自己可控的服务器上部署全新的 Relay 服务。
+- 提供 `/api/tenant/register`、 `/api/webhook/ingest`、 `/api/relay/poll`、 `/api/relay/ack`。
+- 企微回调地址指向你的服务器。
+
+具体实现可参考 `docs/specs/qiwei-relay-mode-guide.md` 中的最小 Relay 服务端示例。
+
+## 十二、执行清单
+
+- [ ] 确认拥有 `8.138.37.248` 服务器访问权限
+- [ ] 备份现有 Relay 代码和数据
+- [ ] 新增 `/api/tenant/register` 接口
+- [ ] 新增 `/api/tenant/status` 接口
+- [ ] 扩展租户表结构
+- [ ] 实现 Fmode token 校验
+- [ ] 实现 apiSecret 哈希存储
+- [ ] 修改 poll/ack 鉴权为哈希比对
+- [ ] 配置环境变量
+- [ ] 执行数据库迁移
+- [ ] 部署并重启 Relay 服务
+- [ ] 测试注册接口
+- [ ] 测试 poll 接口
+- [ ] 测试完整回调链路
+- [ ] 增强本地 Skill 的 `qiwei_relay_register` 工具
+- [ ] 更新相关文档
+
+---
+
+执行本计划后,用户确实可以通过调用接口获取到自己的 Relay 凭证。但请务必先确认服务器权限,否则应改用自建 Relay 方案。

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

@@ -0,0 +1,404 @@
+---
+title: 企微 Webhook Relay 本地 Skill 接入指南
+updated: 2026-07-16
+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。
+
+## 一、前置条件
+
+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"
+}
+```
+
+**务必保存**:`tenantId`、`apiKey`、`apiSecret`、`privateKey`。`privateKey` 只返回一次,Relay 服务端不保存。
+
+> 如果注册时没传 `deviceGuid`,后面可以调用 `POST /api/tenant/device`(见第七节)。
+
+## 三、配置本地 Skill
+
+### 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-----
+```
+
+> 私钥格式:`.env` 中不能包含真实换行符,必须把 PEM 每一行换行替换为 `\n` 字面量。
+
+### 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"
+}
+```
+
+也可以调用现有工具 `qiwei_relay_save_config`:
+
+```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-----"
+  }
+}
+```
+
+## 四、启动 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"
+  }
+}
+```
+
+然后:
+
+```bash
+npm run relay
+```
+
+日志示例:
+
+```text
+[RelayClient] 启动 Relay 轮询: http://8.138.37.248:4000
+[RelayClient] tenantId=cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d, guid=xxx
+[RelayClient] 取回 3 条事件
+[RelayClient] ACK 3 条事件
+```
+
+## 五、配置企微回调地址
+
+### 5.1 拼接回调 URL
+
+```text
+http://8.138.37.248:4000/api/webhook/ingest/{tenantId}/{guid}
+```
+
+示例:
+
+```text
+http://8.138.37.248:4000/api/webhook/ingest/cfd3b3d7-e150-4e0a-a6fe-82ff02283c9d/test-device-guid-001
+```
+
+### 5.2 设置回调
+
+**方式 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"
+  }
+}
+```
+
+> 不需要本地 webhook server 运行,回调直接指向 Relay。
+
+**方式 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. 确保 `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>"
+```
+
+## 七、未在注册时传入 deviceGuid 的补救
+
+如果注册时没传 `deviceGuid`,先调用:
+
+```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"}'
+```
+
+返回的 `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` 接收企微实时事件。

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

@@ -0,0 +1,350 @@
+---
+title: 企微 Webhook Relay 模式完整配置指南
+updated: 2026-07-16
+project: claude-code-qiwe-assistant
+mode: relay
+---
+
+# 企微 Webhook Relay 模式完整配置指南
+
+> 本文档说明如何在 `claude-code-qiwe-assistant`(MCP 技能包)中使用**中央 Relay 模式**接收企微实时事件回调。该模式适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
+
+## 一、Relay 模式是什么
+
+中央 Relay 模式是 Fmode 提供的一种 webhook 中转方案:
+
+- 企微平台把事件推送到 Fmode 的中央 Relay 服务器(有固定公网地址)。
+- 本地运行的 Skill 通过**长轮询**主动从 Relay 取回属于自己的事件。
+- 事件在 Relay 端经过 RSA 公钥加密,本地用私钥解密后再处理。
+
+### 与公网直收的区别
+
+| 维度 | 中央 Relay | 公网直收 |
+|---|---|---|
+| 是否需要公网地址 | 不需要 | 需要 |
+| Skill 网络要求 | 能访问公网即可 | 能被公网访问 |
+| 事件到达方式 | 本地主动长轮询取回 | 企微平台被动推送 |
+| 部署位置 | 本地电脑、内网、云服务器均可 | 必须有公网 IP/域名 |
+| 安全性 | RSA 加密 + Tenant Secret 认证 | HMAC/Authorization 校验 |
+| 实时性 | 秒级延迟(受轮询间隔影响) | 实时 |
+| 适用场景 | 开发调试、本地运行、无公网 IP | 生产服务器、追求低延迟 |
+
+### 架构图
+
+```text
+┌─────────────┐      HTTP POST      ┌──────────────────┐
+│  企微平台    │ ──────────────────▶ │  Fmode 中央 Relay │
+└─────────────┘                     │  (公网服务器)      │
+                                    └────────┬─────────┘
+                                             │
+                              长轮询 /api/relay/poll
+                              RSA 私钥解密
+                              Tenant Secret 认证
+                                             │
+                                             ▼
+                                    ┌──────────────────┐
+                                    │ 本地 Skill       │
+                                    │ claude-code-    │
+                                    │ qiwei-assistant │
+                                    └──────────────────┘
+```
+
+## 二、前置条件
+
+1. 已安装并配置好 `claude-code-qiwe-assistant`。
+2. 已获取 Fmode 鉴权 token(`QIWEI_AUTH_TOKEN` 或 `FMODE_API_KEY`)。
+3. 已完成企微设备登录(已有 `guid`)。
+4. 已从 Fmode 提供方申请到 Relay 租户凭证:
+   - `TENANT_API_KEY`
+   - `TENANT_API_SECRET`
+   - `RELAY_PRIVATE_KEY`(RSA 私钥)
+   - `RELAY_BASE_URL`(中央 Relay 公网地址)
+
+## 三、获取 Relay 租户凭证
+
+Relay 凭证需要向 Fmode 提供方或你的服务管理员申请。申请时通常需要提供:
+
+- 你的 Fmode 账号/公司标识
+- 预计接入的设备数量(guid 数量)
+- 是否需要多个 Skill 实例共享同一个租户
+
+申请成功后,你会拿到以下信息:
+
+```text
+RELAY_BASE_URL=http://8.138.37.248:4000
+TENANT_API_KEY=qk_xxxxxxxxxxxxxxxxxxxxxxxx
+TENANT_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxx
+RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...
+...
+-----END PRIVATE KEY-----
+```
+
+> **安全提醒**:`TENANT_API_SECRET` 和 `RELAY_PRIVATE_KEY` 是敏感信息,不要截图传播、不要提交到 Git。
+
+## 四、目标项目配置
+
+### 4.1 配置方式
+
+目标项目使用文件存储配置,所有 Relay 配置写入:
+
+```
+outputs/webhook/relay-config.json
+```
+
+你也可以通过 MCP 工具 `qiwei_relay_save_config` 写入。
+
+### 4.2 通过工具配置(推荐)
+
+在 Claude Code 中调用:
+
+```json
+{
+  "name": "qiwei_relay_save_config",
+  "arguments": {
+    "relayBaseUrl": "http://8.138.37.248:4000",
+    "tenantId": "你的租户ID(可选)",
+    "publicKey": "对应的 RSA 公钥(可选,用于本地调试)"
+  }
+}
+```
+
+> 注意:当前 `qiwei_relay_save_config` 工具只保存 `relayBaseUrl`、`tenantId`、`publicKey`,**不保存 `tenantApiKey`、`tenantApiSecret`、`privateKey`**。为了安全,后三者建议写入 `.env.local` 或环境变量。
+
+### 4.3 通过 .env.local 配置(推荐)
+
+在目标项目根目录创建或编辑 `.env.local`:
+
+```bash
+# Fmode 鉴权 token(已有)
+QIWEI_AUTH_TOKEN=sk-xxxxxxxx
+
+# Relay 中央服务器地址
+RELAY_BASE_URL=http://8.138.37.248:4000
+
+# Relay 租户凭证
+TENANT_API_KEY=qk_xxxxxxxx
+TENANT_API_SECRET=xxxxxxxx
+
+# RSA 私钥,必须写成单行,用 \n 替换真实换行符
+RELAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----
+```
+
+> **私钥格式说明**:`.env` 文件中不能包含真实换行符,必须将 PEM 私钥中的每一行换行替换为 `\n` 字面量。程序读取时会自动还原为真实换行符(与源 Qiwei 项目 `lib/relay-config.ts` 的 `getRelayPrivateKey()` 逻辑一致)。
+
+### 4.4 配置文件示例
+
+`outputs/webhook/relay-config.json`:
+
+```json
+{
+  "relayBaseUrl": "http://8.138.37.248:4000",
+  "tenantId": "tenant_xxx",
+  "publicKey": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...\n-----END PUBLIC KEY-----",
+  "updatedAt": "2026-07-16T08:00:00.000Z"
+}
+```
+
+## 五、Relay 客户端实现
+
+### 5.1 当前目标项目缺失的部分
+
+目标项目已有:
+
+- `mcp/src/core/webhook-server.js`:本地 webhook server(仅落盘)
+- `mcp/src/tools/qiwei-webhook-relay-run.js`:配置读写工具
+
+但**缺少真正的 Relay 长轮询客户端**。需要从源 Qiwei 项目迁移 `lib/relay-client.ts` 到 `mcp/src/core/relay-client.js`。
+
+### 5.2 需要新增的 relay-client.js
+
+核心职责:
+
+1. 从 `.env.local` / `relay-config.json` 读取 Relay 凭证。
+2. 获取本地可用设备 `guid`。
+3. 长轮询 `POST {RELAY_BASE_URL}/api/relay/poll`。
+4. 用 RSA 私钥解密 `encryptedPayload`。
+5. 把解密后的事件喂给本地 webhook 处理逻辑。
+6. ACK 已处理事件:`POST {RELAY_BASE_URL}/api/relay/ack`。
+7. 失败时指数退避重连。
+
+参考实现要点(来自源项目 `lib/relay-client.ts`):
+
+```js
+const POLL_WAIT_MS = 30000;
+const INITIAL_BACKOFF_MS = 1000;
+const MAX_BACKOFF_MS = 60000;
+
+async function runPollOnce() {
+  const response = await fetch(`${RELAY_BASE_URL}/api/relay/poll`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'Authorization': `Bearer ${TENANT_API_SECRET}`
+    },
+    body: JSON.stringify({ guid: deviceGuid, batchSize: 100, waitMs: POLL_WAIT_MS })
+  });
+
+  const data = await response.json();
+  for (const event of data.events) {
+    const decrypted = decryptPayload(event.encryptedPayload, RELAY_PRIVATE_KEY);
+    const payload = JSON.parse(decrypted);
+    await processWebhookEvents({ code: 0, msg: 'from-relay', data: [payload] });
+  }
+
+  await ackEvents(deviceGuid, data.events.map(e => e.eventId));
+}
+
+function decryptPayload(encryptedPayload, privateKey) {
+  const key = crypto.createPrivateKey(privateKey);
+  const buffer = Buffer.from(encryptedPayload, 'base64');
+  const decrypted = crypto.privateDecrypt({ key, oaepHash: 'sha256' }, buffer);
+  return decrypted.toString('utf8');
+}
+```
+
+### 5.3 私钥还原
+
+读取 `.env.local` 中的私钥时,必须做换行还原:
+
+```js
+function getRelayPrivateKey() {
+  return (process.env.RELAY_PRIVATE_KEY || '').replace(/\\n/g, '\n');
+}
+```
+
+这是源 Qiwei 项目 `lib/relay-config.ts` 中的关键处理,迁移时必须保留。
+
+## 六、启动 Relay 客户端
+
+### 6.1 启动方式选择
+
+目标项目是 stdio MCP server,**不建议在主进程内启动长轮询**,否则会阻塞 MCP 消息循环。推荐以下方式:
+
+#### 方案 A:独立子进程(推荐)
+
+新增 `scripts/start-relay-client.js`:
+
+```bash
+node scripts/start-relay-client.js
+```
+
+该脚本单独运行,与 MCP server 解耦。
+
+#### 方案 B:Dashboard 进程内启动
+
+如果已使用 `npm run dashboard` 启动 dashboard,可在 dashboard server 启动时附带启动 Relay 客户端。
+
+#### 方案 C:MCP 工具触发(不推荐长期运行)
+
+通过 `qiwei_relay_connect` 工具 fork 子进程启动。这种方式会话结束后可能随 MCP server 一起退出,不够稳定。
+
+### 6.2 推荐启动流程
+
+```bash
+# 1. 确保 .env.local 已配置 Relay 凭证
+# 2. 启动 MCP server(正常对话即可)
+# 3. 在另一个终端启动 Relay 客户端
+node scripts/start-relay-client.js
+```
+
+或封装为 npm script:
+
+```json
+{
+  "scripts": {
+    "relay": "node scripts/start-relay-client.js"
+  }
+}
+```
+
+## 七、验证 Relay 是否正常工作
+
+### 7.1 检查配置
+
+调用 MCP 工具:
+
+```json
+{ "name": "qiwei_relay_config" }
+```
+
+应返回:
+
+```json
+{
+  "status": "ok",
+  "data": {
+    "configured": true,
+    "relayBaseUrl": "http://8.138.37.248:4000"
+  }
+}
+```
+
+### 7.2 检查 Relay 客户端日志
+
+启动 `scripts/start-relay-client.js` 后,观察日志:
+
+```text
+[RelayClient] 启动 Relay 轮询
+[RelayClient] 取回 3 条事件
+[RelayClient] ACK 3 条事件
+```
+
+### 7.3 触发真实事件
+
+让好友通过你的企微账号,或在客户群里发送一条消息。观察:
+
+- `outputs/webhook/events/` 目录下是否生成新事件文件
+- `outputs/messages/<roomId>/` 是否出现新消息文件
+- 画像文件 `outputs/portraits/<externalUserId>.json` 是否被触发更新
+
+### 7.4 常见问题排查
+
+| 现象 | 可能原因 | 排查方法 |
+|------|---------|---------|
+| 轮询无事件 | 企微回调未配置到 Relay | 检查 Fmode 平台设置的回调地址是否为 Relay 的 ingest URL |
+| 解密失败 | 私钥格式错误 | 确认 `.env.local` 中私钥使用 `\n` 单行存储,程序正确还原 |
+| 401/403 | Tenant Secret 错误 | 检查 `TENANT_API_SECRET` 是否与 Relay 端匹配 |
+| 获取不到 guid | 设备未登录 | 先调用 `qiwei_login_start` 完成扫码登录 |
+| 事件处理报错 | webhook 处理逻辑未迁移 | 检查 `processWebhookEvents()` 是否正常 |
+
+## 八、Relay 回调地址说明
+
+当 Relay 模式启用后,企微平台侧的回调地址应配置为:
+
+```text
+{RELAY_BASE_URL}/api/webhook/ingest/{tenantId}/{guid}
+```
+
+这个地址由 `lib/webhook-setup.ts`(或目标项目对应逻辑)自动调用 `/client/setCallback` 设置。
+
+你也可以通过 Fmode 平台手动配置,指向 Relay 的 ingest 地址。
+
+## 九、安全注意事项
+
+1. **不要把 `TENANT_API_SECRET` 和 `RELAY_PRIVATE_KEY` 提交到 Git**。目标项目 `outputs/` 已在 `.gitignore` 中,但 `.env.local` 需要自行确认是否忽略。
+2. **私钥单行存储时使用 `\n` 字面量**,不要直接粘贴带真实换行的 PEM。
+3. **定期轮换密钥**。如果怀疑凭证泄露,立即联系 Fmode 提供方重置。
+4. **ACK 所有事件**,包括解密失败的,避免 Relay 端重复投递导致死循环。
+5. **本地 webhook server 签名验证仍可保留**。即使事件来自 Relay,本地处理前也可以再做一层校验。
+
+## 十、与源 Qiwei 项目的差异
+
+| 源 Qiwei 项目 | 目标 MCP 项目 |
+|---|---|
+| `lib/relay-client.ts` | 需新增 `mcp/src/core/relay-client.js` |
+| `lib/relay-config.ts` | 复用 `mcp/src/core/webhook-server.js` 的配置读写,加 `.env.local` 读取 |
+| `lib/webhook-setup.ts` | 合并到 `mcp/src/tools/qiwei-webhook-relay-run.js` |
+| SQLite `WebhookEvent` 表 | `outputs/webhook/events/` 文件 |
+| `processWebhookEvents()` 在 `api/module/webhook/routes.ts` | 迁移到 `mcp/src/core/webhook-processor.js` |
+| 启动时自动启动 Relay 客户端 | 改为独立进程 `scripts/start-relay-client.js` |
+
+## 十一、下一步建议
+
+1. 在目标项目创建 `mcp/src/core/relay-client.js`(从 Qiwei `lib/relay-client.ts` 迁移)。
+2. 创建 `scripts/start-relay-client.js` 作为独立启动入口。
+3. 增强 `qiwei_relay_connect` 工具,支持一键启动 Relay 客户端。
+4. 把 `processWebhookEvents()` 和事件解析逻辑迁移到 `mcp/src/core/webhook-processor.js`。
+5. 写完后按第 7 节验证清单测试。
+
+需要我继续执行实际的代码迁移吗?

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

@@ -0,0 +1,324 @@
+---
+title: 企微 Webhook 能力迁移指南
+updated: 2026-07-16
+source_project: d:\caidawork\Qiwei
+target_project: d:\caidawork\openclaw-voc-skill\claude-code\claude-code-qiwe-assistant
+---
+
+# 企微 Webhook 能力迁移指南
+
+> 本指导用于把 `d:\caidawork\Qiwei`(原「企微客户群运营 Agent Skill」后端服务)中**成熟运行的 Webhook 接收、解析、业务处理、Relay 长轮询**能力,迁移到 `claude-code-qiwe-assistant`(MCP 技能包)中。目标项目当前已有本地 webhook server 壳子,但只落盘事件,缺少签名验证、事件解析、自动建群、消息入库、画像触发等核心业务逻辑。
+
+## 一、迁移前的能力现状
+
+### 1.1 源项目(Qiwei)已具备的完整能力
+
+| 能力 | 关键文件 | 说明 |
+|------|---------|------|
+| 回调接收路由 | `api/module/webhook/routes.ts` | `POST /api/webhook/callback` 入口,立即返回 200,异步处理事件 |
+| 签名验证 | `lib/webhook-verify.ts` | `Authorization` / `Authorization: Bearer <secret>` 比对,兼容 HMAC-SHA256;支持 `WEBHOOK_ALLOW_UNSECURED` 开发开关 |
+| 事件解析 | `lib/webhook-types.ts` | v1/v2 envelope 识别,`cmd` + `msgType` → `ParsedWebhookEvent`;2131/2357/1006/15000 等事件分类 |
+| 自动配置回调 | `lib/webhook-setup.ts` | 启动时自动调用 `/client/setCallback`;支持 Relay 模式、本地公网地址、localtunnel 自动隧道 |
+| Relay 客户端 | `lib/relay-client.ts` | 长轮询 `/api/relay/poll`,RSA 私钥解密 payload,ACK 已处理事件 |
+| 自动建群 | `api/module/webhook/routes.ts` 调用 `lib/group-service.ts` | 好友通过(2131/2357)→ 匹配 `Customer` → 二次确认 → `autoCreateGroup` |
+| 群新增识别 | `api/module/webhook/routes.ts` | msgType=1006 时识别 `Broker`/`Customer`,写入 `ExternalGroup` |
+| 群消息入库 | `api/module/webhook/routes.ts` | `NEW_MESSAGE` 写入 `GroupMessage`,更新 `ExternalGroup.lastMsgAt`,触发画像更新 |
+| 配置中心 | `lib/config.ts` | `getWebhookConfig()` / `discoverPublicBaseUrl()` / `getQiweApiToken()` 等 |
+| 数据库 | `lib/schema.ts` + `lib/db.ts` | SQLite,含 `WebhookEvent`、`ExternalGroup`、`GroupMessage`、`Customer`、`Broker`、`WeComDevice` 等表 |
+
+### 1.2 目标项目当前已有的 webhook 壳子
+
+| 文件 | 现状 | 缺失 |
+|------|------|------|
+| `mcp/src/core/webhook-server.js` | 启动本地 HTTP server,把事件 JSON 写入 `outputs/webhook/` | 无签名验证、无事件解析、无业务处理 |
+| `mcp/src/tools/qiwei-webhook-relay-run.js` | 提供 `qiwei_webhook_server_start/auto_setup/setup/status/relay_*` 等工具 | 工具只调用 `/client/setCallback` 和落盘,未接入业务 |
+| `mcp/src/server.js` | 已注册 9 个 webhook/relay 工具 | 工具 handler 需要增强 |
+
+## 二、迁移总体策略
+
+推荐分阶段迁移,**先让「接收 + 落盘 + 签名验证 + 事件解析」跑通,再逐步接入业务处理**。
+
+```text
+阶段 1:签名验证 + 事件解析 + 结构化落盘
+阶段 2:好友通过自动建群(2131/2357)
+阶段 3:群新增识别与 ExternalGroup 落盘(1006)
+阶段 4:群消息实时入库与画像触发(NEW_MESSAGE)
+阶段 5:Relay 长轮询客户端接入
+阶段 6:删掉/归档源项目重复代码,目标项目成为主入口
+```
+
+> 目标项目没有 SQLite,业务数据以文件形式存在 `outputs/`。迁移时需要把源项目的数据库操作改为文件读写,并遵循 `docs/OUTPUT-STANDARD.md`。
+
+## 三、核心文件迁移清单
+
+### 3.1 必须迁移/重写的文件
+
+| 源文件 | 目标路径建议 | 迁移要点 |
+|--------|-------------|---------|
+| `lib/webhook-types.ts` | `mcp/src/core/webhook-types.js` | 类型改为普通 JS 对象/枚举;保留 v1/v2 envelope 解析、`normalizeItems`、`ParsedWebhookEvent` |
+| `lib/webhook-verify.ts` | `mcp/src/core/webhook-verify.js` | 签名验证逻辑直接平移;从 `webhook-config.json` 读 `secret` |
+| `lib/webhook-setup.ts` | 合并进 `mcp/src/tools/qiwei-webhook-relay-run.js` | 复用现有 `gatewayCall` 调用 `/client/setCallback`;保留自动发现公网地址/隧道能力 |
+| `lib/relay-client.ts` | `mcp/src/core/relay-client.js` | TypeScript → JavaScript;长轮询、RSA 解密、ACK |
+| `api/module/webhook/routes.ts` 中的处理逻辑 | 拆分为 `mcp/src/core/webhook-processor.js` | 好友通过检查、自动建群、群新增识别、消息入库 |
+| `lib/group-service.ts` | `mcp/src/core/group-service.js` | `autoCreateGroup`、`checkFriendConfirmed` 等;调用 Fmode 网关 |
+| `lib/room-sync.ts` | 按需迁移到 `mcp/src/core/group-store.js` | 群列表同步、成员识别 |
+| `lib/portrait-service.ts` | 复用/扩展 `mcp/src/tools/qiwei-portrait-tags-run.js` | 画像触发入口 |
+
+### 3.2 不需要迁移但要参考的规范
+
+- `lib/config.ts`:目标项目用 `credentials.js` + `.env` 管理鉴权,用 `webhook-config.json` 管理回调配置,不需要整个配置中心。
+- `lib/schema.ts`:目标项目没有 SQLite,不需要建表脚本。但要把源项目的表结构映射为 `outputs/` 下的文件结构(见第 6 节)。
+
+## 四、事件解析迁移要点
+
+### 4.1 v1/v2 envelope 兼容
+
+源项目 `lib/webhook-types.ts` 的 `parseWebhookEnvelope(body)` 已经同时支持:
+
+- v1: `{ code: 0, data: [EventItem], msg: "成功" }`
+- v2: `{ event: "msg.group", version: "2.0", data: {...}, meta: {...} }`
+
+迁移时保留该函数签名,输出统一为 `NormalizedWebhookEvent[]`。
+
+### 4.2 关键事件类型
+
+```js
+// 来自源项目 lib/webhook-types.ts
+const ParsedWebhookEvent = {
+  CONTACT_ADDED_OR_CHANGED: 'CONTACT_ADDED_OR_CHANGED', // 2131 外部联系人变动
+  FRIEND_REQUEST_RECEIVED: 'FRIEND_REQUEST_RECEIVED',   // 2357 好友申请通知
+  ACCOUNT_ONLINE: 'ACCOUNT_ONLINE',
+  ACCOUNT_OFFLINE: 'ACCOUNT_OFFLINE',
+  GROUP_MEMBER_JOINED: 'GROUP_MEMBER_JOINED',
+  GROUP_CREATED: 'GROUP_CREATED',                       // 1006 群新增
+  GROUP_EVENT: 'GROUP_EVENT',
+  NEW_MESSAGE: 'NEW_MESSAGE',                           // 普通群消息
+  UNKNOWN: 'UNKNOWN'
+};
+```
+
+### 4.3 事件去重
+
+源项目使用 `WebhookEvent.eventId`(来自 `msgUniqueIdentifier` 或生成)去重。目标项目没有数据库,去重方式可选:
+
+- **方案 A(推荐)**:在 `outputs/webhook/event-id-set.json` 中维护最近 N 条已处理 `eventId` 的集合(LRU 或按日期分片)。
+- **方案 B**:按 `outputs/webhook/<YYYY-MM-DD>/<HHmmss>-callback/` 目录 + 文件名携带 `eventId` 做幂等,处理前检查文件是否存在。
+
+## 五、签名验证迁移要点
+
+### 5.1 当前目标项目的风险
+
+`mcp/src/core/webhook-server.js` 直接解析并落盘,**没有验证签名**。生产环境任何人都可以向本地端口灌数据。
+
+### 5.2 必须接入的验证逻辑
+
+把 `lib/webhook-verify.ts` 的核心策略平移到 `mcp/src/core/webhook-verify.js`:
+
+1. 读取 `outputs/webhook/webhook-config.json` 中的 `secret`。
+2. 从 `Authorization` header 提取签名;兼容 `Authorization: <secret>` 和 `Authorization: Bearer <secret>`。
+3. 未配置 `secret` 时默认拒绝(开发环境可通过 `QIWEI_WEBHOOK_ALLOW_UNSECURED=true` 放行)。
+4. 兼容 HMAC-SHA256 防御性校验。
+5. 使用 `crypto.timingSafeEqual` 防止时序攻击。
+
+### 5.3 rawBody 捕获
+
+目标项目用原生 `http` 模块,`readBody(req)` 已经把 body 读成字符串,可直接用于签名验证。注意:**验证前不要对 body 做 JSON.stringify**,否则 key 顺序/空格变化会导致签名失败。
+
+## 六、数据存储改造(SQLite → 文件)
+
+### 6.1 文件结构映射
+
+目标项目统一用 `outputs/` 存运行时数据。建议新增/复用以下类别:
+
+| 原 SQLite 表 | 目标文件/目录 | 说明 |
+|-------------|--------------|------|
+| `WebhookEvent` | `outputs/webhook/events/<YYYY-MM-DD>/<HHmmss>-<eventId>.json` | 每条事件一个文件;保留 `status`、`parsedType`、`rawBody` |
+| `Customer` | `outputs/customers/<externalUserId or phone>.json` | 客户档案文件 |
+| `Broker` | `outputs/brokers/<brokerUserId>.json` | 顾问/经纪人档案 |
+| `ExternalGroup` | `outputs/groups/confirmed-mapping.json` + `outputs/groups/imported-mapping.json` | 复用目标项目已有映射文件 |
+| `GroupMessage` | `outputs/messages/<roomId>/<seq>-<msgUniqueId>.json` | 复用目标项目已有的消息目录 |
+| `CustomerPortrait` | `outputs/portraits/<externalUserId>.json` | 复用目标项目已有画像文件 |
+
+> 新增 `outputs/` 类别前,先在 `mcp/src/core/output-paths.js` 的 `OUTPUT_CATEGORIES` 注册,并更新 `docs/OUTPUT-STANDARD.md`。
+
+### 6.2 WebhookEvent 落盘规范
+
+参考源项目 `logWebhookEvent()`,目标项目每条事件文件至少包含:
+
+```json
+{
+  "eventId": "...",
+  "guid": "...",
+  "cmd": 15500,
+  "msgType": 2131,
+  "parsedType": "CONTACT_ADDED_OR_CHANGED",
+  "externalUserId": "...",
+  "status": "PENDING",
+  "receivedAt": "2026-07-16T08:00:00.000Z",
+  "processedAt": null,
+  "result": null,
+  "rawBody": { ... }
+}
+```
+
+处理完成后把 `status` 更新为 `PROCESSED` / `IGNORED` / `ERROR` / `AUTO_GROUP_CREATED`,并写入 `processedAt` 和 `result`。
+
+## 七、业务处理迁移要点
+
+### 7.1 好友通过自动建群(2131 / 2357)
+
+源项目逻辑在 `api/module/webhook/routes.ts` 的 `triggerAutoCreateGroup()`:
+
+1. 从事件提取 `externalUserId`;2131 没有时遍历在线设备调用 `getWxContactList` 查找。
+2. 匹配 `Customer`。
+3. 二次确认 `checkFriendConfirmed(deviceGuid, { externalUserId, phone, name })`。
+4. 调用 `updateWxContact` 设置客户备注(姓名 + 电话)。
+5. 调用 `autoCreateGroup({ brokerId, customerId, supportBrokerId, guid, skipFriendCheck: true })`。
+6. 写入 `InteractionTimeline`。
+
+迁移到目标项目时:
+
+- 用 `outputs/customers/` 文件替换 `Customer` 表查询。
+- 用 `outputs/brokers/` 文件替换 `Broker` 表查询。
+- 用 `gatewayCall(ctx, '/contact/getExternalContactList', { guid })` 或 `/contact/searchContact` 替代 `qiweapi.getWxContactList`。
+- 用 `qiweiAutoCreateGroup` 工具内部逻辑或 `mcp/src/core/group-service.js` 替代 `lib/group-service.ts`。
+- 如果目标项目的 `qiwei_auto_create_group` 已可用,直接复用,不要重写。
+
+### 7.2 群新增识别(1006)
+
+源项目 `handleGroupCreateWebhook()` 逻辑:
+
+1. 检查 `ExternalGroup` 是否已存在该 `roomId`。
+2. 通过 `senderId` → `Broker.wecomUserId` 识别经纪人;失败则通过 `guid` 回退。
+3. 从 `changedMemberList` 解码成员,排除经纪人后匹配 `Customer.externalUserId`。
+4. 写入 `ExternalGroup`(ACTIVE / IMPORTED / orphan)。
+
+迁移到目标项目:
+
+- 复用 `outputs/groups/confirmed-mapping.json` 和 `imported-mapping.json`。
+- `decodeChangedMemberList()` 函数从 `lib/webhook-types.ts` 迁移到 `mcp/src/core/webhook-types.js`。
+- 经纪人识别可通过 `outputs/brokers/<brokerUserId>.json` 中的 `wecomUserId` 字段匹配。
+
+### 7.3 群消息实时入库(NEW_MESSAGE)
+
+源项目 `storeGroupMessageFromWebhook()` 逻辑:
+
+1. 只处理 `fromRoomId` 非空的群消息。
+2. 只保存已记录在 `ExternalGroup` 的群。
+3. 按 `msgUniqueIdentifier` 去重。
+4. 提取 `content`,识别 `senderType`。
+5. 语音消息调用 `processVoiceMessage()`。
+6. 写入 `GroupMessage`;更新 `ExternalGroup.lastMsgAt`。
+7. 触发画像更新任务。
+
+迁移到目标项目:
+
+- 复用 `outputs/messages/<roomId>/` 目录。
+- 复用 `qiweiTranscribeVoice` 工具处理语音。
+- 复用 `qiweiPrepareCustomerPortrait` / `qiweiUpdateCustomerPortrait` 触发画像更新。
+
+## 八、Relay 模式迁移要点
+
+### 8.1 当前目标项目 Relay 状态
+
+`qiwei-webhook-relay-run.js` 只有配置读写工具,没有真正的长轮询客户端。
+
+### 8.2 需要接入的完整逻辑
+
+把 `lib/relay-client.ts` 迁移为 `mcp/src/core/relay-client.js`:
+
+1. 从 `outputs/webhook/relay-config.json` 读取 `relayBaseUrl`、`tenantApiKey`、`tenantApiSecret`、`privateKey`。
+2. 长轮询 `POST /api/relay/poll`(参考源项目 `runPollOnce`)。
+3. RSA 私钥解密事件 payload(注意 `.env` 中 `\n` 需还原为真实换行,与源项目 `getRelayPrivateKey()` 一致)。
+4. 调用 ACK `/api/relay/ack`。
+5. 把解密后的事件喂给 `processWebhookEvents()`(复用本地 webhook 处理逻辑)。
+6. 指数退避重连。
+
+### 8.3 启动时机
+
+目标项目是 MCP server(stdio 长连接),**不建议在 stdio 主进程内启动长轮询**,否则可能阻塞 MCP 消息循环。可选方案:
+
+- **方案 A**:把 Relay 客户端做成独立子进程(`scripts/start-relay-client.js`),由用户显式启动。
+- **方案 B**:在 `qiwei_relay_connect` 工具内部 `fork` 子进程启动轮询,主进程立即返回。
+- **方案 C**:如果迁移后目标项目也提供 HTTP dashboard(`scripts/start-dashboard.js` 已有),在 dashboard 进程内启动 Relay 客户端。
+
+推荐 **方案 A 或 C**,保持 MCP server 本身轻量。
+
+## 九、工具注册与参数规范
+
+`mcp/src/server.js` 已经注册了 9 个 webhook/relay 工具。迁移后需要增强以下工具的行为:
+
+| 工具 | 当前行为 | 迁移后行为 |
+|------|---------|-----------|
+| `qiwei_webhook_server_start` | 启动 server,落盘事件 | 启动 server,**先验证签名**,再解析并结构化落盘 |
+| `qiwei_webhook_auto_setup` | 调 `/client/setCallback` | 同上 + 支持自动发现公网地址/隧道(从 `lib/webhook-setup.ts` 迁移) |
+| `qiwei_webhook_setup` | 调 `/client/setCallback` | 同上 |
+| `qiwei_webhook_status` | 返回 server 状态 | 增加 `lastReceivedAt`、`lastProcessedType`、`pendingCount`、`relayRunning` 等 |
+| `qiwei_relay_connect` | 仅检查配置 | 实际启动 Relay 长轮询(子进程或后台 worker) |
+
+新增工具建议:
+
+- `qiwei_webhook_replay`:重放某条 `WebhookEvent` 文件,用于调试。
+- `qiwei_webhook_purge`:清理 `outputs/webhook/` 过期事件(保留最近 30 天)。
+
+## 十、配置项映射
+
+### 10.1 源项目 `.env.example` → 目标项目
+
+| 源项目变量 | 目标项目建议 | 说明 |
+|-----------|-------------|------|
+| `WEBHOOK_ENABLED` | `QIWEI_WEBHOOK_ENABLED` | 是否启用 webhook 工具 |
+| `WEBHOOK_BASE_URL` | 无需环境变量 | 目标项目通过 `qiwei_webhook_auto_setup` 时传入,或自动发现 |
+| `WEBHOOK_AUTH_SECRET` | 写入 `outputs/webhook/webhook-config.json` 的 `secret` | 不要放 `.env`,避免泄露 |
+| `WEBHOOK_AUTO_TUNNEL` | 无需环境变量 | 目标项目 `auto_setup` 时由用户决定是否启动本地 server |
+| `WEBHOOK_ALLOW_UNSECURED` | `QIWEI_WEBHOOK_ALLOW_UNSECURED` | 仅开发环境 |
+| `RELAY_BASE_URL` | 写入 `outputs/webhook/relay-config.json` | 同上 |
+| `TENANT_API_KEY` | 写入 `outputs/webhook/relay-config.json` | 同上 |
+| `TENANT_API_SECRET` | 写入 `outputs/webhook/relay-config.json` | 同上 |
+| `RELAY_PRIVATE_KEY` | 写入 `outputs/webhook/relay-config.json` | 注意单行 `\n` 存储 |
+
+### 10.2 目标项目已有配置
+
+- `QIWEI_AUTH_TOKEN` / `FMODE_API_KEY` / `FMODE_API_TOKEN`:已由 `mcp/src/core/credentials.js` 统一管理,迁移时直接复用。
+- `QIWEI_UID`:已由 `credentials.js` 的 `ensureQiweiUid()` 管理,迁移时复用。
+- `QIWEI_API_BASE`:默认 `https://server.fmode.cn/api/qiwei`,复用。
+
+## 十一、代码规范
+
+1. **不要直接复制 TypeScript 文件**。目标项目是 CommonJS + JavaScript,迁移时需改语法:去掉类型注解、接口改为 JSDoc、默认导出改为 `module.exports`。
+2. **敏感信息不落盘到 outputs 明文文件**。`secret`、`token`、`privateKey` 必须脱敏;参考 `fmode-wecom-gateway.js` 的 `redactSecret()`。
+3. **遵循 `docs/OUTPUT-STANDARD.md`**。所有运行时数据进 `outputs/`,新增类别先注册 `OUTPUT_CATEGORIES`。
+4. **不要阻塞 MCP stdio 主进程**。HTTP server 可运行在主进程(`127.0.0.1`),但 Relay 长轮询建议拆到子进程/dashboard。
+5. **错误处理用 `safeResult` 包装**。新增工具函数都要通过 `shared-gateway.js` 的 `safeResult()` 或类似方式捕获异常。
+6. **优先复用已有工具**。`qiwei_auto_create_group`、`qiwei_sync_group_messages`、`qiwei_update_customer_portrait`、`qiwei_transcribe_voice` 已存在,不要重写。
+7. **保持通用化**。源项目有「经纪人/客户/房产」术语,目标项目已改为「顾问/客户」,迁移时不要把业务术语改回去。
+8. **事件文件命名用 kebab-case + UTC 时间戳**。例如 `event-20260716-080000-abc123.json`。
+
+## 十二、测试验证清单
+
+迁移完成后,按以下顺序验证:
+
+1. `qiwei_webhook_server_start` 启动本地 server。
+2. `qiwei_webhook_auto_setup` 配置回调地址到 Fmode 网关。
+3. 在 Fmode 平台手动触发一条好友通过事件,或等待真实事件。
+4. 检查 `outputs/webhook/events/` 下事件文件是否生成,且 `status` 正确。
+5. 检查签名验证:用错误 secret POST 一条事件,应返回 401。
+6. 检查自动建群:准备一条 2357 事件 payload,事件文件最终状态应为 `AUTO_GROUP_CREATED`。
+7. 检查群消息:发送一条群消息,确认 `outputs/messages/<roomId>/` 出现对应文件。
+8. 检查 Relay:配置 relay 后启动独立客户端,确认能取回并处理事件。
+
+## 十三、常见坑
+
+1. **签名验证失败最常见原因**:目标项目 `readBody` 用 `JSON.parse` 后再 `JSON.stringify` 验证。必须保存原始字符串用于 HMAC。
+2. **guid 为空**:2131 事件有时没有 `guid`,需要遍历在线设备或从 Relay payload 里取 `deviceGuid`。
+3. **`\n` 私钥问题**:Relay 私钥在 `.env` 或 JSON 中按单行 `\n` 存储,读取后必须 `.replace(/\\n/g, '\n')`。
+4. **多设备冲突**:源项目优先用 `broker.storeId` 找在线设备,目标项目没有 `Store` 概念,可简化为优先用事件 `guid`,其次用任意在线 `guid`。
+5. **v2 事件**:未来 Fmode 网关可能推送 v2 格式,必须保留 `parseWebhookEnvelope` 的 v2 分支。
+6. **MCP server 退出**:stdio MCP server 退出时本地 webhook server 也会关闭。若需要持久接收回调,应使用 dashboard 进程或独立进程。
+
+## 十四、后续迭代建议
+
+- 把 webhook 处理进度暴露为 dashboard 页面(`mcp/src/dashboard/`)。
+- 增加 webhook 事件检索工具 `qiwei_webhook_search`(按日期、类型、状态过滤)。
+- 把「好友通过自动建群」做成可开关配置,写入 `outputs/webhook/webhook-config.json`。
+- 增加 webhook 事件统计(每小时/每天接收量、成功率)。

+ 19 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js

@@ -186,7 +186,7 @@ function readQiweiApiBase(input = {}) {
   ]) || DEFAULT_API_BASE).replace(/\/$/, '');
 }
 
-function saveQiweiClientConfig({ uid, apiBase } = {}) {
+function saveQiweiClientConfig({ uid, apiBase, guid } = {}) {
   const saved = [];
   try {
     const dir = path.dirname(CREDENTIALS_FILE);
@@ -195,6 +195,7 @@ function saveQiweiClientConfig({ uid, apiBase } = {}) {
     const next = { ...current };
     if (uid) next.uid = uid;
     if (apiBase) next.apiBase = apiBase;
+    if (guid) next.guid = guid;
     next.updatedAt = new Date().toISOString();
     fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(next, null, 2), 'utf8');
     saved.push(CREDENTIALS_FILE);
@@ -206,6 +207,7 @@ function saveQiweiClientConfig({ uid, apiBase } = {}) {
     const pairs = [];
     if (uid) pairs.push(['QIWEI_UID', uid]);
     if (apiBase) pairs.push(['QIWEI_API_BASE', apiBase]);
+    if (guid) pairs.push(['QIWEI_GUID', guid]);
     if (pairs.length) {
       let content = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '';
       for (const [key, value] of pairs) {
@@ -224,6 +226,21 @@ function saveQiweiClientConfig({ uid, apiBase } = {}) {
   return saved;
 }
 
+function readQiweiGuid(input = {}) {
+  const fileEnv = readEnvFiles();
+  const creds = readCredentialsFile();
+  return firstNonEmpty([
+    input.guid,
+    input.qiweiGuid,
+    input.deviceGuid,
+    fileEnv.QIWEI_GUID,
+    fileEnv.QIWE_GUID,
+    process.env.QIWEI_GUID,
+    process.env.QIWE_GUID,
+    creds.guid
+  ]);
+}
+
 function ensureQiweiUid(input = {}) {
   const existing = readQiweiUid(input);
   if (existing) return existing;
@@ -241,6 +258,7 @@ module.exports = {
   CREDENTIALS_FILE,
   readQiweiAuthToken,
   readQiweiUid,
+  readQiweiGuid,
   ensureQiweiUid,
   readQiweiApiBase,
   saveQiweiClientConfig,

+ 118 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-fallback-server.js

@@ -0,0 +1,118 @@
+const http = require('http');
+const { callFmodeWecomGateway } = require('../providers/fmode-wecom-gateway');
+const { FLOW_PAGE_STYLE, json, readBody, buildFallbackPageHtml } = require('./login-flow-server');
+
+const DEFAULT_FALLBACK_PORT = 0;
+const FALLBACK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
+
+let currentFallbackServer = null;
+let currentFallbackContext = null;
+let fallbackIdleTimer = null;
+
+function resetIdleTimer() {
+  if (fallbackIdleTimer) clearTimeout(fallbackIdleTimer);
+  fallbackIdleTimer = setTimeout(() => {
+    stopLoginFallbackServer();
+  }, FALLBACK_IDLE_TIMEOUT_MS);
+}
+
+function createFallbackHandler(ctx) {
+  return async (req, res) => {
+    const url = new URL(req.url, 'http://localhost');
+    resetIdleTimer();
+    try {
+      if (url.pathname === '/' || url.pathname === '/index.html') {
+        res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+        res.end(buildFallbackPageHtml({ qrcodeUrl: '/flow/qrcode' }));
+        return;
+      }
+      if (url.pathname === '/flow/qrcode') {
+        if (!ctx.qrcodeBuffer) {
+          json(res, 404, { message: '二维码尚未生成' });
+          return;
+        }
+        res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
+        res.end(ctx.qrcodeBuffer);
+        return;
+      }
+      if (url.pathname === '/flow/check') {
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/check', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const data = result.data || {};
+        json(res, 200, { status: data.status, detail: data.detail || {} });
+        return;
+      }
+      if (url.pathname === '/flow/verify' && req.method === 'POST') {
+        const body = await readBody(req);
+        const code = String(body.code || '').trim();
+        if (!/^\d{6}$/.test(code)) {
+          json(res, 400, { errorCode: 'QW-UP-502', message: '请输入 6 位数字验证码' });
+          return;
+        }
+        await callFmodeWecomGateway({ gatewayPath: '/login/verify', body: { uid: ctx.uid, code }, token: ctx.token, apiBase: ctx.apiBase });
+        json(res, 200, { ok: true });
+        return;
+      }
+      if (url.pathname === '/flow/refresh' && req.method === 'POST') {
+        const result = await callFmodeWecomGateway({ gatewayPath: '/login/start', body: { uid: ctx.uid }, token: ctx.token, apiBase: ctx.apiBase });
+        const base64 = String((result.data && result.data.loginQrcodeBase64Data) || '').replace(/^data:image\/\w+;base64,/, '');
+        if (!base64) {
+          json(res, 502, { errorCode: 'QW-UP-502', message: '网关未返回二维码' });
+          return;
+        }
+        ctx.qrcodeBuffer = Buffer.from(base64, 'base64');
+        if (typeof ctx.onQrcode === 'function') ctx.onQrcode(ctx.qrcodeBuffer);
+        json(res, 200, { ok: true });
+        return;
+      }
+      json(res, 404, { message: 'not found' });
+    } catch (error) {
+      const httpStatus = Number((error && error.httpStatus) || 502);
+      const safeStatus = httpStatus >= 400 && httpStatus < 600 ? httpStatus : 502;
+      json(res, safeStatus, {
+        errorCode: 'QW-UP-' + safeStatus,
+        message: String((error && error.message) || '请求失败')
+      });
+    }
+  };
+}
+
+function startLoginFallbackServer({ token, apiBase, uid, qrcodeBuffer, port, onQrcode } = {}) {
+  const listenPort = Number(port || process.env.QIWEI_FALLBACK_PORT) || DEFAULT_FALLBACK_PORT;
+  if (currentFallbackServer && currentFallbackContext) {
+    Object.assign(currentFallbackContext, { token, apiBase, uid, qrcodeBuffer, onQrcode });
+    resetIdleTimer();
+    return Promise.resolve({ url: `http://127.0.0.1:${currentFallbackContext.port}/`, alreadyRunning: true });
+  }
+  const ctx = { token, apiBase, uid, qrcodeBuffer, onQrcode };
+  const server = http.createServer(createFallbackHandler(ctx));
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(listenPort, '127.0.0.1', () => {
+      const address = server.address();
+      const assignedPort = address && address.port;
+      ctx.port = assignedPort;
+      currentFallbackServer = server;
+      currentFallbackContext = ctx;
+      resetIdleTimer();
+      resolve({ url: `http://127.0.0.1:${assignedPort}/`, port: assignedPort, alreadyRunning: false });
+    });
+  });
+}
+
+function stopLoginFallbackServer() {
+  if (fallbackIdleTimer) {
+    clearTimeout(fallbackIdleTimer);
+    fallbackIdleTimer = null;
+  }
+  if (currentFallbackServer) {
+    currentFallbackServer.close();
+    currentFallbackServer = null;
+    currentFallbackContext = null;
+  }
+}
+
+module.exports = {
+  DEFAULT_FALLBACK_PORT,
+  startLoginFallbackServer,
+  stopLoginFallbackServer
+};

+ 140 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/core/login-flow-server.js

@@ -4,16 +4,7 @@ const { SEAT_PLANS, DURATION_PLANS, QIWEI_MONTHLY_PRICE, classifySubscribeError,
 
 const DEFAULT_FLOW_PORT = 4310;
 
-let currentServer = null;
-let currentContext = null;
-
-function buildFlowPageHtml({ monthlyPrice }) {
-  return `<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8">
-<title>企微助手 · 开通与登录</title>
-<style>
+const FLOW_PAGE_STYLE = `
   :root { --brand:#fa8c16; --brand-bg:#fff7e6; --border:#f0f0f0; }
   * { box-sizing:border-box; }
   body { font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif; margin:0; background:#f7f8fa; color:#333; }
@@ -45,7 +36,18 @@ function buildFlowPageHtml({ monthlyPrice }) {
   .hint { color:#999; font-size:13px; margin-top:10px; line-height:1.8; }
   input#code { font-size:22px; letter-spacing:8px; text-align:center; width:220px; padding:8px; border:1px solid #ccc; border-radius:8px; }
   .success { font-size:18px; color:#237804; }
-</style>
+`;
+
+let currentServer = null;
+let currentContext = null;
+
+function buildFlowPageHtml({ monthlyPrice }) {
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<title>企微助手 · 开通与登录</title>
+<style>${FLOW_PAGE_STYLE}</style>
 </head>
 <body>
 <div class="wrap">
@@ -241,6 +243,129 @@ boot();
 </html>`;
 }
 
+function buildFallbackPageHtml({ qrcodeUrl }) {
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<title>企业微信扫码登录</title>
+<style>${FLOW_PAGE_STYLE}</style>
+</head>
+<body>
+<div class="wrap">
+  <h1>企业微信扫码登录</h1>
+  <div class="panel" id="panel">
+    <div class="center">
+      <div class="section-title">请用手机企业微信扫描下方二维码</div>
+      <img id="qr" src="${qrcodeUrl}?t=${Date.now()}" alt="登录二维码">
+      <div class="hint" id="poll-status">正在检测扫码状态…</div>
+    </div>
+  </div>
+</div>
+<script>
+let pollTimer = null, codeSubmitted = false, verifyWaits = 0, checkErrors = 0;
+const panel = document.getElementById('panel');
+
+function showMsg(type, html) {
+  let el = document.getElementById('msg');
+  if (!el) { el = document.createElement('div'); el.id = 'msg'; panel.appendChild(el); }
+  el.className = 'msg ' + type;
+  el.innerHTML = html;
+}
+
+async function api(path, opts) {
+  const res = await fetch(path, opts);
+  const data = await res.json().catch(() => ({}));
+  return { ok: res.ok, status: res.status, data };
+}
+
+function renderVerify() {
+  panel.innerHTML = '<div class="center"><div class="section-title">已扫码确认,请输入手机企业微信上显示的 6 位验证码</div>' +
+    '<input id="code" maxlength="6" placeholder="······" inputmode="numeric" autocomplete="one-time-code">' +
+    '<br><button class="main" id="verify">提交验证码</button>' +
+    '<div class="hint">验证码在手机企业微信确认登录后显示,输完 6 位自动提交</div></div>';
+  const input = document.getElementById('code');
+  input.focus();
+  const submit = async () => {
+    const code = input.value.trim();
+    if (!/^\\d{6}$/.test(code)) { showMsg('err', '请输入 6 位数字验证码。'); return; }
+    const btn = document.getElementById('verify');
+    btn.disabled = true;
+    showMsg('ok', '正在校验…');
+    const r = await api('/flow/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) });
+    if (!r.ok) { btn.disabled = false; showMsg('err', '<b>[' + (r.data.errorCode || 'QW-UP-502') + ']</b> ' + (r.data.message || '验证码校验失败') + ',请重试。'); return; }
+    codeSubmitted = true;
+    verifyWaits = 0;
+    panel.innerHTML = '<div class="center"><div class="section-title">验证码已提交,正在确认登录…</div><div class="hint" id="poll-status">确认中,每 3 秒自动检测…</div></div>';
+    poll();
+  };
+  document.getElementById('verify').onclick = submit;
+  input.onkeydown = (e) => { if (e.key === 'Enter') submit(); };
+  input.oninput = () => { if (/^\\d{6}$/.test(input.value.trim())) submit(); };
+}
+
+function renderSuccess(detail) {
+  panel.innerHTML = '<div class="center"><div class="success">✔ 企业微信登录成功</div>' +
+    '<div class="hint">账号:' + (detail.nickname || detail.userId || '未知') + (detail.corpName ? '(' + detail.corpName + ')' : '') + '<br>现在可以回到对话中使用企微接口能力。</div></div>';
+}
+
+async function refreshQr() {
+  showMsg('ok', '正在重新生成二维码…');
+  const r = await api('/flow/refresh', { method: 'POST' });
+  if (!r.ok) {
+    showMsg('err', '<b>重新生成失败</b><br>' + (r.data.message || '') + '<br><button class="main" onclick="refreshQr()">重试</button>');
+    return;
+  }
+  codeSubmitted = false;
+  verifyWaits = 0;
+  checkErrors = 0;
+  document.getElementById('qr').src = '/flow/qrcode?t=' + Date.now();
+  panel.innerHTML = '<div class="center"><div class="section-title">请用手机企业微信扫描下方二维码</div>' +
+    '<img id="qr" src="/flow/qrcode?t=' + Date.now() + '" alt="登录二维码">' +
+    '<div class="hint" id="poll-status">正在检测扫码状态…</div></div>';
+  poll();
+}
+
+function poll() {
+  clearInterval(pollTimer);
+  pollTimer = setInterval(async () => {
+    const r = await api('/flow/check');
+    const s = String(r.data.status);
+    const el = document.getElementById('poll-status');
+    if (!r.ok || r.data.status === undefined) {
+      checkErrors++;
+      if (!codeSubmitted && checkErrors >= 2) { clearInterval(pollTimer); refreshQr(); return; }
+      if (codeSubmitted && checkErrors >= 10) {
+        clearInterval(pollTimer);
+        showMsg('err', '登录确认异常。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="refreshQr()">重新生成二维码</button>');
+        return;
+      }
+      if (el) el.textContent = '状态检测异常,重试中…';
+      return;
+    }
+    checkErrors = 0;
+    if (s === '2') { clearInterval(pollTimer); renderSuccess(r.data.detail || {}); return; }
+    if (s === '10') {
+      if (!codeSubmitted) { clearInterval(pollTimer); renderVerify(); return; }
+      verifyWaits++;
+      if (el) el.textContent = '验证码已提交,正在确认登录…';
+      if (verifyWaits >= 10) {
+        clearInterval(pollTimer);
+        showMsg('err', '登录确认超时。可能验证码错误或已失效。<br><button class="main" onclick="codeSubmitted=false;verifyWaits=0;renderVerify()">重新输入验证码</button> <button class="main" onclick="refreshQr()">重新生成二维码</button>');
+      }
+      return;
+    }
+    if (s === '4') { clearInterval(pollTimer); showMsg('err', '手机端取消了登录或二维码会话已失效。<br><button class="main" onclick="refreshQr()">重新生成二维码</button>'); return; }
+    if (el) el.textContent = s === '1' ? '已扫码,请在手机上确认…' : '等待扫码…(状态 ' + s + ')';
+  }, 3000);
+}
+
+poll();
+</script>
+</body>
+</html>`;
+}
+
 function json(res, status, body) {
   res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
   res.end(JSON.stringify(body));
@@ -389,6 +514,10 @@ function stopLoginFlowServer() {
 
 module.exports = {
   DEFAULT_FLOW_PORT,
+  FLOW_PAGE_STYLE,
+  json,
+  readBody,
+  buildFallbackPageHtml,
   startLoginFlowServer,
   stopLoginFlowServer
 };

+ 8 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js

@@ -10,6 +10,14 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'meetings',
   'docs',
   'messages',
+  'groups',
+  'portraits',
+  'broker-playbooks',
+  'tags',
+  'transfers',
+  'voice',
+  'webhook',
+  'relay',
   'smoke',
   'tmp'
 ]);

+ 224 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js

@@ -0,0 +1,224 @@
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const { outputsRoot } = require('./output-paths');
+
+const RELAY_CONFIG_FILE = path.join(outputsRoot(), 'webhook', 'relay-config.json');
+const CROSS_PROJECT_CREDENTIALS_FILE = path.join(os.homedir(), '.qiwe-skill', 'relay-credentials.json');
+
+function ensureDir(filePath) {
+  const dir = path.dirname(filePath);
+  if (!fs.existsSync(dir)) {
+    fs.mkdirSync(dir, { recursive: true });
+  }
+}
+
+function readJsonMaybe(filePath) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return {};
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^/, ''));
+  } catch {
+    return {};
+  }
+}
+
+function readEnvLocal() {
+  const envPath = path.resolve(process.cwd(), '.env.local');
+  if (!fs.existsSync(envPath)) return {};
+  const env = {};
+  const content = fs.readFileSync(envPath, 'utf8').replace(/^/, '');
+  for (const rawLine of content.split(/\r?\n/)) {
+    const line = rawLine.trim();
+    if (!line || line.startsWith('#')) continue;
+    const match = line.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 readRelayConfigFile() {
+  return readJsonMaybe(RELAY_CONFIG_FILE);
+}
+
+function writeRelayConfigFile(config) {
+  ensureDir(RELAY_CONFIG_FILE);
+  const current = readRelayConfigFile();
+  fs.writeFileSync(
+    RELAY_CONFIG_FILE,
+    JSON.stringify({ ...current, ...config, updatedAt: new Date().toISOString() }, null, 2),
+    'utf8'
+  );
+}
+
+function isRelayEnabled() {
+  const env = readEnvLocal();
+  const baseUrl = process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || '';
+  const apiKey = process.env.TENANT_API_KEY || env.TENANT_API_KEY || '';
+  const apiSecret = process.env.TENANT_API_SECRET || env.TENANT_API_SECRET || '';
+  return !!(baseUrl && apiKey && apiSecret);
+}
+
+function getRelayBaseUrl() {
+  const env = readEnvLocal();
+  return (process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || 'http://8.138.37.248:4000').replace(/\/$/, '');
+}
+
+function getTenantApiKey() {
+  const env = readEnvLocal();
+  return process.env.TENANT_API_KEY || env.TENANT_API_KEY || '';
+}
+
+function getTenantApiSecret() {
+  const env = readEnvLocal();
+  return process.env.TENANT_API_SECRET || env.TENANT_API_SECRET || '';
+}
+
+function getTenantId() {
+  const env = readEnvLocal();
+  return process.env.TENANT_ID || env.TENANT_ID || readRelayConfigFile().tenantId || '';
+}
+
+function getRelayPrivateKey() {
+  const env = readEnvLocal();
+  return (process.env.RELAY_PRIVATE_KEY || env.RELAY_PRIVATE_KEY || '').replace(/\\n/g, '\n');
+}
+
+function getRelayPublicKey() {
+  return readRelayConfigFile().publicKey || '';
+}
+
+function getRelayDeviceGuid() {
+  return readRelayConfigFile().deviceGuid || '';
+}
+
+function getRelayCredentials() {
+  return {
+    relayBaseUrl: getRelayBaseUrl(),
+    tenantId: getTenantId(),
+    tenantApiKey: getTenantApiKey(),
+    tenantApiSecret: getTenantApiSecret(),
+    privateKey: getRelayPrivateKey(),
+    publicKey: getRelayPublicKey(),
+    deviceGuid: getRelayDeviceGuid()
+  };
+}
+
+function normalizePrivateKey(privateKey) {
+  return privateKey
+    .replace(/\r\n/g, '\n')
+    .replace(/\n/g, '\\n');
+}
+
+function saveRelayCredentialsToEnv(creds, envPath) {
+  const targetPath = envPath || path.resolve(process.cwd(), '.env.local');
+  ensureDir(targetPath);
+  let content = '';
+  if (fs.existsSync(targetPath)) {
+    content = fs.readFileSync(targetPath, 'utf8');
+  }
+
+  const normalizedPrivateKey = creds.privateKey ? normalizePrivateKey(creds.privateKey) : '';
+
+  const entries = {
+    RELAY_BASE_URL: creds.relayBaseUrl || getRelayBaseUrl(),
+    TENANT_ID: creds.tenantId || '',
+    TENANT_API_KEY: creds.tenantApiKey || '',
+    TENANT_API_SECRET: creds.tenantApiSecret || '',
+    RELAY_PRIVATE_KEY: normalizedPrivateKey
+  };
+
+  for (const [key, value] of Object.entries(entries)) {
+    if (!value) continue;
+    const regex = new RegExp(`^${key}\s*=.*$`, 'm');
+    const line = `${key}=${value}`;
+    if (regex.test(content)) {
+      content = content.replace(regex, line);
+    } else {
+      content = `${content.replace(/\n*$/, '')}${content ? '\n' : ''}${line}\n`;
+    }
+  }
+
+  fs.writeFileSync(targetPath, content.trim() + '\n', 'utf8');
+
+  // 同时更新当前进程环境变量,使后续调用立即生效
+  for (const [key, value] of Object.entries(entries)) {
+    if (value) process.env[key] = value;
+  }
+
+  return targetPath;
+}
+
+function saveRelayCredentials(creds) {
+  const saved = [];
+
+  // 保存到 .env.local
+  try {
+    saved.push(saveRelayCredentialsToEnv(creds));
+  } catch (err) {
+    console.warn('[RelayConfig] 保存到 .env.local 失败:', err.message);
+  }
+
+  // 保存公钥/租户信息到 relay-config.json
+  try {
+    writeRelayConfigFile({
+      relayBaseUrl: creds.relayBaseUrl || getRelayBaseUrl(),
+      tenantId: creds.tenantId || '',
+      publicKey: creds.publicKey || '',
+      deviceGuid: creds.deviceGuid || ''
+    });
+    saved.push(RELAY_CONFIG_FILE);
+  } catch (err) {
+    console.warn('[RelayConfig] 保存到 relay-config.json 失败:', err.message);
+  }
+
+  return saved;
+}
+
+function saveRelayCredentialsToFile(creds) {
+  try {
+    ensureDir(CROSS_PROJECT_CREDENTIALS_FILE);
+    fs.writeFileSync(
+      CROSS_PROJECT_CREDENTIALS_FILE,
+      JSON.stringify({ ...creds, updatedAt: new Date().toISOString() }, null, 2),
+      'utf8'
+    );
+    return CROSS_PROJECT_CREDENTIALS_FILE;
+  } catch (err) {
+    console.warn('[RelayConfig] 保存跨项目凭证失败:', err.message);
+    return null;
+  }
+}
+
+function loadRelayCredentialsFromFile() {
+  return readJsonMaybe(CROSS_PROJECT_CREDENTIALS_FILE);
+}
+
+function ensureRelayConfigDir() {
+  ensureDir(RELAY_CONFIG_FILE);
+}
+
+module.exports = {
+  RELAY_CONFIG_FILE,
+  isRelayEnabled,
+  getRelayBaseUrl,
+  getTenantApiKey,
+  getTenantApiSecret,
+  getTenantId,
+  getRelayPrivateKey,
+  getRelayPublicKey,
+  getRelayDeviceGuid,
+  getRelayCredentials,
+  saveRelayCredentials,
+  saveRelayCredentialsToEnv,
+  saveRelayCredentialsToFile,
+  loadRelayCredentialsFromFile,
+  writeRelayConfigFile,
+  readRelayConfigFile,
+  ensureRelayConfigDir,
+  normalizePrivateKey
+};

+ 88 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/shared-gateway.js

@@ -0,0 +1,88 @@
+const { findEndpoint } = require('./api-catalog');
+const { readQiweiAuthToken, ensureQiweiUid, readQiweiApiBase } = require('./credentials');
+const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
+
+function buildContext(input = {}) {
+  const token = readQiweiAuthToken(input);
+  if (!token) {
+    const err = new Error('缺少 Fmode 鉴权 token');
+    err.kind = 'auth';
+    throw err;
+  }
+  return {
+    token,
+    uid: ensureQiweiUid(input),
+    apiBase: readQiweiApiBase(input),
+    guid: String(input.guid || '').trim()
+  };
+}
+
+async function gatewayCall(ctx, method, params, timeoutMs = 60000) {
+  const result = await callFmodeWecomGateway({
+    gatewayPath: '/doApi',
+    body: { uid: ctx.uid, method, params },
+    token: ctx.token,
+    apiBase: ctx.apiBase,
+    timeoutMs
+  });
+  return result.data && result.data.data !== undefined ? result.data.data : result.data;
+}
+
+function requireGuid(ctx) {
+  if (!ctx.guid) {
+    const err = new Error('缺少 guid。请先用 qiwei_login_status/qiwei_login_start 完成设备登录,或显式传入 guid。');
+    err.kind = 'request';
+    throw err;
+  }
+}
+
+function assertMethodsInCatalog(methods) {
+  const missing = Object.values(methods).filter(method => {
+    const endpoint = findEndpoint(method);
+    return !endpoint || endpoint.method !== method;
+  });
+  if (missing.length) {
+    const err = new Error(`qiwei-endpoints.json 缺少业务迁移所需接口: ${missing.join(', ')}`);
+    err.kind = 'catalog';
+    throw err;
+  }
+}
+
+function classifyErrorStatus(error) {
+  if (error && error.kind === 'auth') return 'needs_auth';
+  return 'error';
+}
+
+function buildErrorNextActions(status) {
+  if (status === 'needs_auth') return ['配置 QIWEI_AUTH_TOKEN / FMODE_API_KEY 后重试'];
+  return [];
+}
+
+function safeResult(handler) {
+  return async function (input = {}) {
+    try {
+      return await handler(input);
+    } catch (error) {
+      const message = redactSecret(error && error.message ? error.message : String(error));
+      const status = classifyErrorStatus(error);
+      return {
+        status,
+        assistantMessage: message,
+        summary: { errorKind: error && error.kind ? error.kind : 'runtime' },
+        data: {},
+        files: [],
+        nextActions: buildErrorNextActions(status),
+        warnings: [],
+        errors: [{ message, kind: error && error.kind ? error.kind : 'runtime' }]
+      };
+    }
+  };
+}
+
+module.exports = {
+  buildContext,
+  gatewayCall,
+  requireGuid,
+  assertMethodsInCatalog,
+  safeResult
+};

+ 157 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-server.js

@@ -0,0 +1,157 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot, createRunDir } = require('./output-paths');
+
+let activeServer = null;
+let activePort = 0;
+
+function webhookDir() {
+  return path.join(outputsRoot(), 'webhook');
+}
+
+function ensureWebhookDir() {
+  fs.mkdirSync(webhookDir(), { recursive: true });
+}
+
+function configPath() {
+  ensureWebhookDir();
+  return path.join(webhookDir(), 'webhook-config.json');
+}
+
+function readConfig() {
+  const filePath = configPath();
+  if (!fs.existsSync(filePath)) return { callbackUrl: null, secret: null };
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return { callbackUrl: null, secret: null };
+  }
+}
+
+function writeConfig(config) {
+  ensureWebhookDir();
+  fs.writeFileSync(configPath(), JSON.stringify({ ...readConfig(), ...config, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+}
+
+function relayConfigPath() {
+  ensureWebhookDir();
+  return path.join(webhookDir(), 'relay-config.json');
+}
+
+function readRelayConfig() {
+  const filePath = relayConfigPath();
+  if (!fs.existsSync(filePath)) return {};
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return {};
+  }
+}
+
+function writeRelayConfig(config) {
+  ensureWebhookDir();
+  fs.writeFileSync(relayConfigPath(), JSON.stringify({ ...readRelayConfig(), ...config, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+}
+
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    let body = '';
+    req.setEncoding('utf8');
+    req.on('data', chunk => { body += chunk; });
+    req.on('end', () => {
+      try {
+        resolve(body ? JSON.parse(body) : {});
+      } catch {
+        resolve({ raw: body });
+      }
+    });
+    req.on('error', reject);
+  });
+}
+
+function sanitizeEvent(event) {
+  if (!event || typeof event !== 'object') return event;
+  const output = {};
+  for (const [key, value] of Object.entries(event)) {
+    if (/^(token|tokenId|secret|signature)$/i.test(key)) continue;
+    output[key] = value;
+  }
+  return output;
+}
+
+function saveWebhookEvent(event, source = 'callback') {
+  const record = {
+    receivedAt: new Date().toISOString(),
+    source,
+    event: sanitizeEvent(event)
+  };
+  ensureWebhookDir();
+  const runDir = createRunDir('webhook', source);
+  const fileName = `event-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+  const filePath = path.join(runDir, fileName);
+  fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
+  return filePath;
+}
+
+function startWebhookServer(port = 0) {
+  if (activeServer) {
+    return { port: activePort, alreadyRunning: true };
+  }
+
+  const server = http.createServer(async (req, res) => {
+    if (req.method !== 'POST') {
+      res.writeHead(405);
+      res.end('method not allowed');
+      return;
+    }
+    try {
+      const body = await readBody(req);
+      const filePath = saveWebhookEvent(body, 'callback');
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify({ code: 200, message: 'ok', file: path.basename(filePath) }));
+    } catch (error) {
+      res.writeHead(500);
+      res.end(JSON.stringify({ code: 500, message: String(error && error.message ? error.message : error) }));
+    }
+  });
+
+  return new Promise((resolve, reject) => {
+    server.listen(port, '127.0.0.1', (err) => {
+      if (err) return reject(err);
+      activeServer = server;
+      activePort = server.address().port;
+      resolve({ port: activePort });
+    });
+  });
+}
+
+function stopWebhookServer() {
+  if (!activeServer) return { stopped: false, reason: 'not running' };
+  return new Promise((resolve) => {
+    activeServer.close(() => {
+      activeServer = null;
+      activePort = 0;
+      resolve({ stopped: true });
+    });
+  });
+}
+
+function getWebhookServerStatus() {
+  return {
+    running: !!activeServer,
+    port: activePort,
+    localUrl: activePort ? `http://127.0.0.1:${activePort}/callback` : null
+  };
+}
+
+module.exports = {
+  startWebhookServer,
+  stopWebhookServer,
+  getWebhookServerStatus,
+  readConfig,
+  writeConfig,
+  readRelayConfig,
+  writeRelayConfig,
+  saveWebhookEvent
+};

+ 2457 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js

@@ -0,0 +1,2457 @@
+(function () {
+  'use strict';
+
+  const API_BASE = '';
+  const ACCOUNTS_KEY = 'qiwei-accounts';
+  const CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
+
+  const SYNCED_GROUPS_KEY = 'qiwei-synced-groups';
+
+  const state = {
+    page: 'groups',
+    accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
+    currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
+    status: null,
+    groups: [],
+    groupFilters: { keyword: '', status: '', showCustomerOnly: false },
+    groupKeywords: null,
+    jobs: {},
+    toastId: 0,
+    modal: null,
+    loginTimer: null,
+    loginPhase: 'idle',
+    autoRecoverAttempted: false,
+    preRecoveryPage: null,
+    selectedGroupIds: new Set(),
+    syncedGroupIds: JSON.parse(localStorage.getItem(SYNCED_GROUPS_KEY) || '{}'),
+    customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null },
+    portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null }
+  };
+
+  const els = {
+    content: document.getElementById('content'),
+    pageTitle: document.getElementById('page-title'),
+    nav: document.getElementById('nav'),
+    sidebar: document.getElementById('sidebar'),
+    menuBtn: document.getElementById('menu-btn'),
+    toastContainer: document.getElementById('toast-container'),
+    accountBtn: document.getElementById('account-btn'),
+    accountDropdown: document.getElementById('account-dropdown'),
+    accountName: document.getElementById('account-name'),
+    accountDot: document.getElementById('account-dot'),
+    accountList: document.getElementById('account-list')
+  };
+
+  function api(method, path, body) {
+    const opts = { method, headers: {} };
+    if (body !== undefined) {
+      opts.headers['Content-Type'] = 'application/json';
+      opts.body = JSON.stringify(body);
+    }
+    return fetch(`${API_BASE}${path}`, opts).then(async res => {
+      const data = await res.json().catch(() => ({}));
+      if (!res.ok) return Promise.reject(data);
+      return data;
+    });
+  }
+
+  function currentAccount() {
+    return state.accounts.find(a => a.id === state.currentAccountId) || null;
+  }
+
+  function getGuid() {
+    const acc = currentAccount();
+    return acc ? (acc.guid || acc.userId || '') : '';
+  }
+
+  function saveAccounts() {
+    localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(state.accounts));
+    localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
+  }
+
+  function saveSyncedGroups() {
+    localStorage.setItem(SYNCED_GROUPS_KEY, JSON.stringify(state.syncedGroupIds));
+  }
+
+  function markGroupsSynced(roomIds) {
+    const now = Date.now();
+    for (const roomId of roomIds) {
+      state.syncedGroupIds[roomId] = now;
+    }
+    saveSyncedGroups();
+  }
+
+  function resetSyncedGroups() {
+    state.syncedGroupIds = {};
+    saveSyncedGroups();
+  }
+
+  function isGroupSynced(roomId) {
+    return !!state.syncedGroupIds[roomId];
+  }
+
+  function addAccount(account) {
+    const existing = state.accounts.find(a => a.guid === account.guid && a.userId === account.userId);
+    if (existing) {
+      Object.assign(existing, account, { id: existing.id });
+      state.currentAccountId = existing.id;
+    } else {
+      account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
+      state.accounts.push(account);
+      state.currentAccountId = account.id;
+    }
+    saveAccounts();
+    updateAccountSwitcher();
+  }
+
+  function switchAccount(id) {
+    state.currentAccountId = id;
+    saveAccounts();
+    updateAccountSwitcher();
+    renderPage();
+    toast('已切换账号');
+  }
+
+  function removeAccount(id) {
+    state.accounts = state.accounts.filter(a => a.id !== id);
+    if (state.currentAccountId === id) {
+      state.currentAccountId = state.accounts.length ? state.accounts[0].id : null;
+    }
+    saveAccounts();
+    updateAccountSwitcher();
+    renderPage();
+  }
+
+  function updateAccountSwitcher() {
+    const acc = currentAccount();
+    if (acc) {
+      els.accountName.textContent = acc.nickname || acc.userId || '已登录账号';
+      els.accountDot.className = 'account-dot ' + (acc.online ? 'online' : 'offline');
+    } else {
+      els.accountName.textContent = '未登录';
+      els.accountDot.className = 'account-dot offline';
+    }
+
+    els.accountList.innerHTML = state.accounts.map(a => `
+      <button class="account-option ${a.id === state.currentAccountId ? 'active' : ''}" data-id="${escapeHtml(a.id)}">
+        <span class="status-dot ${a.online ? 'online' : 'offline'}"></span>
+        <span class="account-info">
+          <strong>${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</strong>
+          <span>${escapeHtml(a.corpName || '企业微信账号')}</span>
+        </span>
+      </button>
+    `).join('');
+
+    els.accountList.querySelectorAll('.account-option').forEach(btn => {
+      btn.addEventListener('click', () => {
+        switchAccount(btn.dataset.id);
+        els.accountDropdown.classList.remove('open');
+      });
+    });
+  }
+
+  function toast(message, type = 'success') {
+    const id = ++state.toastId;
+    const el = document.createElement('div');
+    el.className = `toast ${type}`;
+    el.innerHTML = `<span>${escapeHtml(message)}</span>`;
+    els.toastContainer.appendChild(el);
+    setTimeout(() => {
+      el.style.opacity = '0';
+      el.style.transform = 'translateX(100%)';
+      setTimeout(() => el.remove(), 300);
+    }, 4000);
+  }
+
+  function escapeHtml(str) {
+    return String(str || '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+  }
+
+  function formatNumber(n) {
+    return Number(n || 0).toLocaleString();
+  }
+
+  function badge(status) {
+    const map = {
+      'AUTO_CONFIRMED': ['badge-success', '自动确认'],
+      'CONFIRMED': ['badge-success', '已确认'],
+      'SUGGESTED': ['badge-warning', '建议确认'],
+      'IMPORTED': ['badge-default', '普通群'],
+      'REJECTED': ['badge-error', '已忽略']
+    };
+    const [cls, text] = map[status] || ['badge-default', status || '未知'];
+    return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
+  }
+
+  function openModal(title, bodyHtml, actionsHtml) {
+    const overlay = document.createElement('div');
+    overlay.className = 'modal-overlay';
+    overlay.innerHTML = `<div class="modal"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
+    document.body.appendChild(overlay);
+    state.modal = overlay;
+    overlay.addEventListener('click', e => {
+      if (e.target === overlay) closeModal();
+    });
+  }
+
+  function closeModal() {
+    if (state.modal) {
+      state.modal.remove();
+      state.modal = null;
+    }
+  }
+
+  window.dashboardCloseModal = closeModal;
+
+  function startJob(name, path, body, onDone) {
+    const btn = document.querySelector(`[data-job="${name}"]`);
+    if (btn) {
+      btn.disabled = true;
+      btn.dataset.original = btn.innerHTML;
+      btn.innerHTML = `<span class="spinner"></span> 执行中…`;
+    }
+    api('POST', path, body)
+      .then(res => {
+        const id = res.data?.jobId;
+        if (!id) {
+          if (btn) {
+            btn.disabled = false;
+            btn.innerHTML = btn.dataset.original;
+          }
+          return;
+        }
+        state.jobs[id] = { name, onDone };
+        pollJob(id);
+      })
+      .catch(err => {
+        if (btn) {
+          btn.disabled = false;
+          btn.innerHTML = btn.dataset.original;
+        }
+        toast(err.message || '启动任务失败', 'error');
+      });
+  }
+
+  function pollJob(id) {
+    const interval = setInterval(() => {
+      api('GET', `/api/jobs/${id}`)
+        .then(res => {
+          const job = res.data;
+          updateJobUI(id, job);
+          if (job.status === 'done' || job.status === 'error') {
+            clearInterval(interval);
+            finishJob(id, job);
+          }
+        })
+        .catch(() => {
+          clearInterval(interval);
+        });
+    }, 1500);
+  }
+
+  function updateJobUI(id, job) {
+    const wrap = document.getElementById(`job-${id}`);
+    if (!wrap) return;
+    const fill = wrap.querySelector('.progress-bar-fill');
+    if (fill) fill.style.width = `${job.progress}%`;
+    const text = wrap.querySelector('.job-status-text');
+    if (text) text.textContent = job.status === 'running' ? '执行中…' : (job.status === 'done' ? '已完成' : '执行失败');
+  }
+
+  function finishJob(id, job) {
+    const record = state.jobs[id];
+    if (record) {
+      const btn = document.querySelector(`[data-job="${record.name}"]`);
+      if (btn) {
+        btn.disabled = false;
+        btn.innerHTML = btn.dataset.original;
+      }
+      if (job.status === 'done') {
+        toast(job.result?.assistantMessage || '任务完成');
+        if (record.onDone) record.onDone(job.result);
+      } else {
+        toast(job.error || '任务失败', 'error');
+      }
+      delete state.jobs[id];
+    }
+  }
+
+  function renderJobTracker(name, id) {
+    return `<div class="progress-wrap" id="job-${id}">
+      <div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>
+      <div class="job-status-text" style="font-size:12px;color:var(--text-muted);margin-top:6px">执行中…</div>
+    </div>`;
+  }
+
+  function setActiveNav(page) {
+    state.page = page;
+    document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
+    const active = document.querySelector(`.nav-item[data-page="${page}"]`);
+    if (active) active.classList.add('active');
+  }
+
+  function renderPage() {
+    const page = location.hash.slice(1) || 'groups';
+    setActiveNav(page);
+    els.content.innerHTML = '';
+
+    const acc = currentAccount();
+    if (!acc) {
+      renderLoginPage();
+      return;
+    }
+
+    if (page === 'status') renderStatusPage();
+    else if (page === 'customer-ops') renderCustomerOpsPage();
+    else if (page === 'portraits') renderPortraitsPage();
+    else if (page === 'transfers') renderTransfersPage();
+    else renderGroupsPage();
+  }
+
+  // ==================== Login ====================
+  function renderLoginPage() {
+    els.pageTitle.textContent = '登录企微账号';
+    const page = document.createElement('div');
+    page.className = 'login-page';
+    page.innerHTML = `
+      <div class="login-card">
+        <div class="brand-icon" style="margin:0 auto 16px;width:48px;height:48px">
+          <svg viewBox="0 0 24 24" style="width:28px;height:28px" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-6h2v6zm-2-8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
+        </div>
+        <h2>请先登录企业微信</h2>
+        <p>登录后才能扫描和管理客户群。如需管理多个账号,可重复登录并切换。</p>
+        <div id="login-qr-wrap">
+          <button class="btn" id="btn-start-login" style="width:100%">生成登录二维码</button>
+        </div>
+      </div>
+    `;
+    els.content.appendChild(page);
+
+    page.querySelector('#btn-start-login').addEventListener('click', () => startLoginFlow());
+  }
+
+  async function startLoginFlow() {
+    const wrap = document.getElementById('login-qr-wrap');
+    wrap.innerHTML = `<div class="qr-wrap">
+      <div class="skeleton" style="width:220px;height:220px;margin:0 auto"></div>
+    </div>
+    <div class="qr-status" id="login-status-text">正在生成二维码…</div>`;
+
+    try {
+      const res = await api('POST', '/api/login/start', {});
+      if (res.status === 'needs_subscription') {
+        wrap.innerHTML = `<div class="hint-box" style="text-align:left">
+            <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+            <div>${escapeHtml(res.assistantMessage || '需要先开通订阅')}</div>
+          </div>
+          <button class="btn" id="btn-retry-login">重试</button>`;
+        document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
+        return;
+      }
+
+      const qrcodeFile = res.data?.qrcodeFile || res.summary?.qrcodeFile;
+      const flowUrl = res.data?.flowUrl;
+
+      if (flowUrl && !qrcodeFile) {
+        wrap.innerHTML = `<div class="hint-box" style="text-align:left">
+            <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+            <div>已打开登录流程页,请在新窗口中完成扫码。</div>
+          </div>
+          <a class="btn" href="${escapeHtml(flowUrl)}" target="_blank" style="width:100%;text-decoration:none">打开登录页面</a>`;
+        return;
+      }
+
+      if (!qrcodeFile) {
+        wrap.innerHTML = `<div class="hint-box" style="text-align:left">
+            <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+            <div>未获取到二维码,请重试。</div>
+          </div>
+          <button class="btn" id="btn-retry-login">重试</button>`;
+        document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
+        return;
+      }
+
+      wrap.innerHTML = `<div class="qr-wrap">
+          <img src="/api/outputs?path=${encodeURIComponent(qrcodeFile)}" alt="登录二维码" id="login-qr">
+        </div>
+        <div class="qr-status" id="login-status-text">请用企业微信扫码</div>
+        <button class="btn btn-secondary" id="btn-retry-login" style="margin-top:12px">重新生成二维码</button>`;
+
+      document.getElementById('btn-retry-login').addEventListener('click', () => {
+        clearInterval(state.loginTimer);
+        startLoginFlow();
+      });
+
+      state.loginPhase = 'polling';
+      pollLoginStatus();
+    } catch (err) {
+      wrap.innerHTML = `<div class="hint-box" style="text-align:left;background:var(--error-bg);color:var(--error);border-color:var(--error-border)">
+          <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+          <div>${escapeHtml(err.message || '登录启动失败')}</div>
+        </div>
+        <button class="btn" id="btn-retry-login">重试</button>`;
+      document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
+    }
+  }
+
+  function pollLoginStatus() {
+    clearInterval(state.loginTimer);
+    let checkCount = 0;
+    const statusText = document.getElementById('login-status-text');
+
+    state.loginTimer = setInterval(async () => {
+      checkCount++;
+      try {
+        const res = await api('POST', '/api/login/check', {});
+        const statusCode = res.summary?.statusCode;
+
+        if (statusCode === 2) {
+          clearInterval(state.loginTimer);
+          if (statusText) statusText.textContent = '登录成功,正在获取账号信息…';
+          await finalizeLogin(res.data?.status || {});
+          return;
+        }
+
+        if (res.status === 'needs_verify_code') {
+          clearInterval(state.loginTimer);
+          if (statusText) statusText.textContent = '请输入手机端验证码';
+          showVerifyCodeInput();
+          return;
+        }
+
+        if (statusCode === 4) {
+          clearInterval(state.loginTimer);
+          if (statusText) statusText.textContent = '登录已取消,请重新生成二维码';
+          return;
+        }
+
+        if (statusText) {
+          if (statusCode === 1) statusText.textContent = '已扫码,请在手机上确认';
+          else if (statusCode === 0 && checkCount > 3) statusText.textContent = '等待扫码…(可尝试免扫码登录)';
+          else statusText.textContent = '请用企业微信扫码';
+        }
+      } catch (err) {
+        if (statusText) statusText.textContent = '状态检测异常,重试中…';
+      }
+    }, 2500);
+  }
+
+  function showVerifyCodeInput() {
+    const wrap = document.getElementById('login-qr-wrap');
+    wrap.innerHTML = `
+      <div class="form-group" style="text-align:left">
+        <label>手机端显示的 6 位验证码</label>
+        <input id="verify-code" type="text" maxlength="6" placeholder="000000" style="font-size:22px;letter-spacing:12px;text-align:center" />
+      </div>
+      <button class="btn" id="btn-submit-verify" style="width:100%;margin-top:8px">提交验证码</button>
+      <button class="btn btn-secondary" id="btn-back-qr" style="width:100%;margin-top:10px">返回二维码</button>
+    `;
+
+    const input = document.getElementById('verify-code');
+    input.focus();
+    input.addEventListener('input', () => {
+      if (/^\d{6}$/.test(input.value.trim())) submitVerifyCode(input.value.trim());
+    });
+
+    document.getElementById('btn-submit-verify').addEventListener('click', () => {
+      submitVerifyCode(input.value.trim());
+    });
+
+    document.getElementById('btn-back-qr').addEventListener('click', () => {
+      clearInterval(state.loginTimer);
+      startLoginFlow();
+    });
+  }
+
+  async function submitVerifyCode(code) {
+    if (!/^\d{6}$/.test(code)) {
+      toast('请输入 6 位数字验证码', 'warning');
+      return;
+    }
+    try {
+      await api('POST', '/api/login/verify', { code });
+      toast('验证码已提交,正在确认登录…');
+      pollLoginStatus();
+      const statusText = document.getElementById('login-status-text');
+      if (statusText) statusText.textContent = '验证码已提交,正在确认…';
+    } catch (err) {
+      toast(err.message || '验证码提交失败', 'error');
+    }
+  }
+
+  async function finalizeLogin(detail) {
+    try {
+      const statusRes = await api('GET', '/api/status');
+      const loginDetail = statusRes.data?.login?.data?.detail || detail || {};
+      const account = {
+        guid: loginDetail.guid || loginDetail.userId || '',
+        userId: loginDetail.userId || '',
+        nickname: loginDetail.nickname || loginDetail.userId || '',
+        corpName: loginDetail.corpName || '',
+        corpId: loginDetail.corpId || '',
+        online: true
+      };
+      if (!account.guid) {
+        toast('登录成功,但未获取到账号标识,请重试', 'error');
+        return;
+      }
+      addAccount(account);
+      toast(`登录成功:${account.nickname || account.userId}`);
+      if (state.preRecoveryPage) {
+        location.hash = `#${state.preRecoveryPage}`;
+        state.preRecoveryPage = null;
+      }
+      renderPage();
+    } catch (err) {
+      toast('登录成功,但获取账号信息失败', 'error');
+    }
+  }
+
+  // ==================== Login Recovery ====================
+  function renderRecoverButton(container, options = {}) {
+    const { loading = false, message = '' } = options;
+    if (!container) return;
+    container.innerHTML = '';
+
+    if (message) {
+      const msg = document.createElement('div');
+      msg.className = 'recover-message';
+      msg.style.cssText = 'font-size:13px;color:var(--text-secondary);margin-bottom:10px;display:flex;align-items:center;gap:8px';
+      msg.innerHTML = `${loading ? '<span class="spinner" style="width:14px;height:14px;border-color:rgba(250,140,22,0.3);border-top-color:var(--brand)"></span>' : ''}${escapeHtml(message)}`;
+      container.appendChild(msg);
+    }
+
+    if (!loading) {
+      const btn = document.createElement('button');
+      btn.className = 'btn btn-sm';
+      btn.id = 'btn-recover-login';
+      btn.innerHTML = '恢复登录';
+      btn.addEventListener('click', () => recoverLogin());
+      container.appendChild(btn);
+    }
+  }
+
+  function updateRecoverMessage(message, loading = false) {
+    const wrap = document.getElementById('status-recover-wrap');
+    if (wrap) renderRecoverButton(wrap, { loading, message });
+  }
+
+  async function attemptAutoRecover(auto = false) {
+    if (state.loginPhase === 'recovering') return false;
+    state.loginPhase = 'recovering';
+    updateRecoverMessage('正在尝试免扫码恢复登录…', true);
+
+    try {
+      const res = await api('POST', '/api/login/check', { manual: true });
+      const statusCode = res.summary?.statusCode;
+
+      if (statusCode === 2) {
+        await finalizeLogin(res.data?.status || {});
+        state.loginPhase = 'idle';
+        return true;
+      }
+
+      if (res.status === 'needs_verify_code') {
+        state.loginPhase = 'verify';
+        state.preRecoveryPage = location.hash.slice(1) || 'groups';
+        state.currentAccountId = null;
+        renderPage();
+        setTimeout(() => showVerifyCodeInput(), 100);
+        return false;
+      }
+
+      state.loginPhase = 'idle';
+      if (auto) {
+        toast('账号离线,无法自动恢复,请进入「账号状态」手动登录', 'warning');
+      } else {
+        updateRecoverMessage('无法自动恢复,请手动登录');
+      }
+      return false;
+    } catch (err) {
+      state.loginPhase = 'idle';
+      if (auto) {
+        toast('账号离线,自动恢复失败,请进入「账号状态」手动登录', 'warning');
+      } else {
+        updateRecoverMessage('自动恢复失败,请手动登录');
+      }
+      return false;
+    }
+  }
+
+  async function recoverLogin() {
+    state.autoRecoverAttempted = true;
+    const recovered = await attemptAutoRecover(false);
+    if (!recovered) {
+      state.preRecoveryPage = location.hash.slice(1) || 'groups';
+      state.currentAccountId = null;
+      renderPage();
+      setTimeout(() => startLoginFlow(), 100);
+    }
+  }
+
+  function syncAccountOnlineStatus(online, detail = {}) {
+    const current = currentAccount();
+    const currentGuid = current?.guid || detail?.guid || detail?.userId || '';
+    const currentUserId = current?.userId || detail?.userId || '';
+    let changed = false;
+    state.accounts.forEach(a => {
+      const match = (currentGuid && (a.guid === currentGuid || a.userId === currentGuid)) ||
+                    (currentUserId && (a.userId === currentUserId || a.guid === currentUserId));
+      if (match) {
+        if (a.online !== online) {
+          a.online = online;
+          changed = true;
+        }
+        if (detail?.nickname && a.nickname !== detail.nickname) {
+          a.nickname = detail.nickname;
+          changed = true;
+        }
+        if (detail?.corpName && a.corpName !== detail.corpName) {
+          a.corpName = detail.corpName;
+          changed = true;
+        }
+      }
+    });
+    if (changed) {
+      saveAccounts();
+      updateAccountSwitcher();
+    }
+  }
+
+  // ==================== Status Page ====================
+  async function renderStatusPage() {
+    els.pageTitle.textContent = '账号状态';
+    const page = document.createElement('div');
+    page.className = 'page';
+    page.innerHTML = `
+      <div class="card">
+        <div class="section-title">正在加载状态…</div>
+        <div class="skeleton" style="height:100px"></div>
+      </div>`;
+    els.content.appendChild(page);
+
+    try {
+      const res = await api('GET', '/api/status');
+      const login = res.data?.login || {};
+      const sub = res.data?.subscription || {};
+      const online = !!login.summary?.online;
+      const subscribed = !!sub.summary?.subscribed;
+      const acc = currentAccount() || {};
+
+      // 同步已保存账号的在线状态,确保表格显示与当前状态一致
+      syncAccountOnlineStatus(online, login.data?.detail);
+
+      const currentAcc = {
+        nickname: acc.nickname || login.summary?.nickname || '当前账号',
+        corpName: acc.corpName || login.data?.detail?.corpName || '',
+        userId: acc.userId || login.data?.detail?.userId || ''
+      };
+
+      page.innerHTML = `
+        <div class="status-cards">
+          <div class="status-card">
+            <div class="status-icon ${online ? 'green' : 'red'}">
+              <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
+            </div>
+            <div class="status-content">
+              <h3>${escapeHtml(currentAcc.nickname)}</h3>
+              <p>${escapeHtml(currentAcc.corpName || '企业微信账号')}</p>
+              <div class="status-detail">
+                <span class="status-dot ${online ? 'online' : 'offline'}"></span>
+                ${online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
+              </div>
+              ${!online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
+            </div>
+          </div>
+
+          <div class="status-card">
+            <div class="status-icon ${subscribed ? 'green' : 'orange'}">
+              <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
+            </div>
+            <div class="status-content">
+              <h3>${subscribed ? '订阅有效' : '未开通订阅'}</h3>
+              <p>${escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
+              ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
+            </div>
+          </div>
+
+          <div class="status-card">
+            <div class="status-icon ${res.summary?.authConfigured ? 'green' : 'red'}">
+              <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
+            </div>
+            <div class="status-content">
+              <h3>${res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
+              <p>${res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}</p>
+            </div>
+          </div>
+        </div>
+
+        <div class="card">
+          <div class="card-header">
+            <h3 class="card-title">已保存账号</h3>
+            <button class="btn btn-sm" id="btn-add-account-page">添加账号</button>
+          </div>
+          ${state.accounts.length ? `
+            <div class="table-wrap">
+              <table>
+                <thead><tr><th>账号</th><th>企业</th><th>状态</th><th style="width:120px">操作</th></tr></thead>
+                <tbody>
+                  ${state.accounts.map(a => `
+                    <tr class="${a.id === state.currentAccountId ? 'active-row' : ''}">
+                      <td>${escapeHtml(a.nickname || a.userId || a.guid)}</td>
+                      <td>${escapeHtml(a.corpName || '-')}</td>
+                      <td><span class="status-dot ${a.online ? 'online' : 'offline'}"></span>${a.online ? '在线' : '离线'}</td>
+                      <td>
+                        <div style="display:flex;gap:6px">
+                          ${a.id !== state.currentAccountId ? `<button class="btn btn-sm btn-secondary" data-action="switch" data-id="${escapeHtml(a.id)}">切换</button>` : ''}
+                          <button class="btn btn-sm btn-danger" data-action="remove" data-id="${escapeHtml(a.id)}">删除</button>
+                        </div>
+                      </td>
+                    </tr>
+                  `).join('')}
+                </tbody>
+              </table>
+            </div>
+          ` : `
+            <div class="empty-state" style="padding:32px">
+              <div class="empty-state-title">还没有保存的账号</div>
+              <div class="empty-state-desc">点击右上角「添加账号」开始登录</div>
+            </div>
+          `}
+        </div>
+      `;
+
+      page.querySelector('#btn-add-account-page').addEventListener('click', () => {
+        state.currentAccountId = null;
+        renderPage();
+      });
+
+      page.querySelectorAll('[data-action="switch"]').forEach(btn => {
+        btn.addEventListener('click', () => switchAccount(btn.dataset.id));
+      });
+      page.querySelectorAll('[data-action="remove"]').forEach(btn => {
+        btn.addEventListener('click', () => {
+          if (confirm('确定删除这个账号吗?')) removeAccount(btn.dataset.id);
+        });
+      });
+
+      const recoverWrap = page.querySelector('#status-recover-wrap');
+      if (recoverWrap) {
+        renderRecoverButton(recoverWrap);
+      }
+    } catch (err) {
+      page.innerHTML = `<div class="card">
+        <div class="hint-box" style="background:var(--error-bg);color:var(--error);border-color:var(--error-border)">
+          <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+          <div>加载状态失败:${escapeHtml(err.message || '未知错误')}</div>
+        </div>
+      </div>`;
+    }
+  }
+
+  // ==================== Groups Page ====================
+  async function renderGroupsPage() {
+    els.pageTitle.textContent = '客户群管理';
+    state.selectedGroupIds = state.selectedGroupIds || new Set();
+    const page = document.createElement('div');
+    page.className = 'page';
+    page.innerHTML = `
+      <div class="hint-box">
+        <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+        <div>点击「扫描客户群」从企业微信拉取群列表,系统会自动识别客户群。勾选群后点击「同步消息」可批量保存历史消息到本地。</div>
+      </div>
+
+      ${renderGroupSyncCard()}
+      ${renderGroupStatsCard(null)}
+      ${renderGroupFilters()}
+      <div id="groups-table-card"></div>
+    `;
+    els.content.appendChild(page);
+
+    bindGroupSync(page);
+    bindGroupFilters(page);
+
+    await loadGroups();
+  }
+
+  function renderGroupSyncCard() {
+    return `
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
+              扫描客户群
+            </h3>
+            <div class="card-subtitle">从企业微信拉取群数据,并自动判断哪些可能是客户群</div>
+          </div>
+        </div>
+
+        <div class="form-row">
+          <div class="form-group">
+            <label>扫描范围</label>
+            <select id="sync-scope">
+              <option value="all">全部群</option>
+              <option value="self">我创建的群</option>
+              <option value="session">最近聊天里的群</option>
+              <option value="messages">从消息记录里找群</option>
+            </select>
+          </div>
+          <div class="form-group">
+            <label>扫描深度</label>
+            <input id="sync-max-pages" type="number" value="20" min="1" max="300" />
+            <span style="font-size:12px;color:var(--text-muted);margin-top:4px">分页拉取的最大页数,数值越大扫描越完整、耗时越长</span>
+          </div>
+        </div>
+
+        <div class="form-row">
+          <div class="form-group" style="flex:2;min-width:260px">
+            <label>客户群关键词(用逗号隔开)</label>
+            <input id="sync-keywords" type="text" value="客户群,服务群,售后群,咨询群,VIP群,专属群,业主群,项目群,订单群,用户群" />
+          </div>
+          <div class="form-group" style="flex:1;min-width:160px">
+            <label>高置信度词</label>
+            <input id="sync-high-confidence" type="text" value="客户群,服务群" />
+          </div>
+        </div>
+
+        <div class="checkbox-row" style="margin-bottom:14px">
+          <input id="sync-auto-classify" type="checkbox" checked />
+          <label for="sync-auto-classify">自动识别客户群</label>
+        </div>
+
+        <button class="btn" id="btn-sync-groups" data-job="sync-groups">开始扫描</button>
+        <div id="sync-job-tracker"></div>
+      </div>
+    `;
+  }
+
+  function renderGroupStatsCard(summary) {
+    const s = summary || {};
+    return `
+      <div class="stats-grid" id="group-stats">
+        <div class="stat-card"><div class="stat-value" data-count="${s.total || 0}">${formatNumber(s.total)}</div><div class="stat-label">扫描群数</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--success)" data-count="${s.autoConfirmed || 0}">${formatNumber(s.autoConfirmed)}</div><div class="stat-label">自动确认</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--warning)" data-count="${s.suggested || 0}">${formatNumber(s.suggested)}</div><div class="stat-label">建议确认</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--text-muted)" data-count="${s.imported || 0}">${formatNumber(s.imported)}</div><div class="stat-label">普通群</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--success)" data-count="${s.confirmed || 0}">${formatNumber(s.confirmed)}</div><div class="stat-label">已确认</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--error)" data-count="${s.rejected || 0}">${formatNumber(s.rejected)}</div><div class="stat-label">已忽略</div></div>
+      </div>
+    `;
+  }
+
+  function renderGroupFilters() {
+    return `
+      <div class="card">
+        <div class="card-header">
+          <h3 class="card-title">群列表</h3>
+          <div class="filter-bar">
+            <input id="filter-keyword" type="text" placeholder="搜索群名" value="${escapeHtml(state.groupFilters.keyword)}" />
+            <select id="filter-status">
+              <option value="">全部状态</option>
+              <option value="AUTO_CONFIRMED">自动确认</option>
+              <option value="SUGGESTED">建议确认</option>
+              <option value="CONFIRMED">已确认</option>
+              <option value="REJECTED">已忽略</option>
+            </select>
+            <button class="btn btn-sm btn-secondary ${state.groupFilters.showCustomerOnly ? 'active' : ''}" id="btn-filter-customer-only">${state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群'}</button>
+            <button class="btn btn-sm btn-secondary" id="btn-refresh-groups">刷新</button>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  function renderGroupTable(groups) {
+    const customerStatuses = new Set(['AUTO_CONFIRMED', 'SUGGESTED', 'CONFIRMED', 'REJECTED']);
+    const filtered = groups.filter(g => {
+      // 过滤无效群记录:无名称且无成员数通常是单聊或系统通知
+      if (!g.roomName && !g.memberCount) return false;
+      const kw = state.groupFilters.keyword.trim();
+      if (kw && !(g.roomName || '').includes(kw) && !(g.roomId || '').includes(kw)) return false;
+      if (state.groupFilters.status && g.reviewStatus !== state.groupFilters.status) return false;
+      if (state.groupFilters.showCustomerOnly && !customerStatuses.has(g.reviewStatus)) return false;
+      return true;
+    });
+
+    const card = document.getElementById('groups-table-card');
+    if (!filtered.length) {
+      card.innerHTML = `
+        <div class="card">
+          <div class="empty-state">
+            <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
+            <div class="empty-state-title">还没有群数据</div>
+            <div class="empty-state-desc">点击上方「开始扫描」从企业微信拉取</div>
+          </div>
+        </div>
+      `;
+      return;
+    }
+
+    const allSelected = filtered.length > 0 && filtered.every(g => state.selectedGroupIds.has(g.roomId));
+
+    card.innerHTML = `
+      <div class="card" style="padding:0;overflow:hidden">
+        <div style="padding:14px 16px;border-bottom:1px solid var(--border);background:var(--page-bg);display:flex;align-items:center;gap:12px;flex-wrap:wrap">
+          <label class="checkbox-row" style="margin:0">
+            <input type="checkbox" id="select-all-groups" ${allSelected ? 'checked' : ''} />
+            <span>全选本页</span>
+          </label>
+          <span style="color:var(--text-muted);font-size:13px">已选 ${formatNumber(state.selectedGroupIds.size)} 个</span>
+          <button class="btn btn-sm" id="btn-batch-confirm" ${state.selectedGroupIds.size ? '' : 'disabled'}>
+            确认选中为客户群
+          </button>
+          <button class="btn btn-sm" id="btn-batch-sync-msg" ${state.selectedGroupIds.size ? '' : 'disabled'}>
+            同步选中群消息
+          </button>
+        </div>
+        <div class="table-wrap">
+          <table>
+            <thead>
+              <tr>
+                <th style="width:40px"></th>
+                <th>群名</th>
+                <th style="width:70px">人数</th>
+                <th style="width:100px">识别结果</th>
+                <th style="width:100px">置信度</th>
+                <th style="width:100px">消息同步</th>
+                <th>命中关键词</th>
+                <th style="width:120px">操作</th>
+              </tr>
+            </thead>
+            <tbody>
+              ${filtered.map((g, i) => {
+                const synced = isGroupSynced(g.roomId);
+                return `
+                <tr style="animation:fadeInUp 0.3s ease ${i * 0.03}s both">
+                  <td>
+                    <input type="checkbox" class="group-checkbox" data-room="${escapeHtml(g.roomId)}" ${state.selectedGroupIds.has(g.roomId) ? 'checked' : ''} />
+                  </td>
+                  <td>
+                    <div style="font-weight:600">${escapeHtml(g.roomName || '未命名')}</div>
+                    <div style="font-size:12px;color:var(--text-muted);margin-top:2px">${escapeHtml(g.roomId || '')}</div>
+                  </td>
+                  <td>${formatNumber(g.memberCount)}</td>
+                  <td>${badge(g.reviewStatus)}</td>
+                  <td>
+                    <div style="display:flex;align-items:center;gap:8px">
+                      <div class="confidence-bar"><div class="confidence-bar-fill" style="width:${Math.round((g.confidence || 0) * 100)}%"></div></div>
+                      <span style="font-size:12px;color:var(--text-muted)">${Math.round((g.confidence || 0) * 100)}%</span>
+                    </div>
+                  </td>
+                  <td>${synced ? `<span class="badge badge-success">已同步</span>` : `<span class="badge badge-default">未同步</span>`}</td>
+                  <td>${(g.matchedKeywords || []).map(k => `<span class="tag" style="margin-bottom:4px">${escapeHtml(k)}</span>`).join(' ') || '-'}</td>
+                  <td>
+                    <div style="display:flex;gap:6px;flex-wrap:wrap">
+                      ${!['CONFIRMED', 'AUTO_CONFIRMED'].includes(g.reviewStatus) ? `<button class="btn btn-sm" data-action="confirm" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">确认</button>` : ''}
+                      ${g.reviewStatus === 'CONFIRMED' ? `<button class="btn btn-sm" data-action="detail" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">群详情</button>` : ''}
+                      ${g.reviewStatus !== 'REJECTED' ? `<button class="btn btn-sm btn-secondary" data-action="reject" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">忽略</button>` : ''}
+                      <button class="btn btn-sm btn-secondary" data-action="sync-msg" data-room="${escapeHtml(g.roomId)}">${synced ? '重新同步' : '同步消息'}</button>
+                    </div>
+                  </td>
+                </tr>`;
+              }).join('')}
+            </tbody>
+          </table>
+        </div>
+      </div>
+    `;
+
+    bindGroupTableActions(card, filtered);
+  }
+
+  function bindGroupTableActions(card, filtered) {
+    const selectAll = card.querySelector('#select-all-groups');
+    if (selectAll) {
+      selectAll.addEventListener('change', () => {
+        filtered.forEach(g => {
+          if (selectAll.checked) state.selectedGroupIds.add(g.roomId);
+          else state.selectedGroupIds.delete(g.roomId);
+        });
+        renderGroupTable(state.groups);
+      });
+    }
+
+    card.querySelectorAll('.group-checkbox').forEach(cb => {
+      cb.addEventListener('change', () => {
+        const roomId = cb.dataset.room;
+        if (cb.checked) state.selectedGroupIds.add(roomId);
+        else state.selectedGroupIds.delete(roomId);
+        renderGroupTable(state.groups);
+      });
+    });
+
+    const batchConfirmBtn = card.querySelector('#btn-batch-confirm');
+    if (batchConfirmBtn) {
+      batchConfirmBtn.addEventListener('click', () => {
+        const roomIds = Array.from(state.selectedGroupIds);
+        if (!roomIds.length) return;
+        batchConfirmGroups(roomIds);
+      });
+    }
+
+    const batchSyncBtn = card.querySelector('#btn-batch-sync-msg');
+    if (batchSyncBtn) {
+      batchSyncBtn.addEventListener('click', () => {
+        const roomIds = Array.from(state.selectedGroupIds);
+        if (!roomIds.length) return;
+        syncGroupMessages(roomIds);
+      });
+    }
+
+    card.querySelectorAll('[data-action]').forEach(btn => {
+      btn.addEventListener('click', () => {
+        const roomId = btn.dataset.room;
+        const roomName = btn.dataset.name;
+        if (btn.dataset.action === 'confirm') confirmGroup(roomId, roomName);
+        if (btn.dataset.action === 'reject') openRejectModal(roomId, roomName);
+        if (btn.dataset.action === 'sync-msg') syncGroupMessages([roomId]);
+        if (btn.dataset.action === 'detail') openGroupDetailModal(roomId, roomName);
+      });
+    });
+  }
+
+  async function confirmGroup(roomId, roomName) {
+    const guid = getGuid();
+    if (!guid) {
+      toast('请先登录企业微信账号', 'warning');
+      state.currentAccountId = null;
+      renderPage();
+      return;
+    }
+    const acc = currentAccount();
+    if (!acc || !acc.online) {
+      toast('账号当前离线,请先恢复登录后再确认客户群', 'warning');
+      location.hash = '#status';
+      return;
+    }
+    try {
+      const res = await api('POST', '/api/groups/confirm', { roomId });
+      toast(res.assistantMessage || '已确认为客户群');
+      loadGroups();
+    } catch (err) {
+      toast(err.message || '确认失败', 'error');
+    }
+  }
+  async function batchConfirmGroups(roomIds) {
+    let success = 0;
+    let failed = 0;
+    for (const roomId of roomIds) {
+      try {
+        await api('POST', '/api/groups/confirm', { roomId });
+        success++;
+      } catch (err) {
+        failed++;
+        console.error('确认客户群失败', roomId, err);
+      }
+    }
+    toast(`批量确认完成:成功 ${success} 个${failed ? `,失败 ${failed} 个` : ''}`);
+    loadGroups();
+  }
+
+  async function loadGroups() {
+    try {
+      const res = await api('GET', '/api/groups/list');
+      state.groups = res.data?.groups || [];
+      updateGroupStats(res.summary);
+      renderGroupTable(state.groups);
+    } catch (err) {
+      toast(err.message || '加载群列表失败', 'error');
+    }
+  }
+
+  function updateGroupStats(summary) {
+    if (!summary) {
+      const counts = state.groups.reduce((acc, g) => {
+        acc[g.reviewStatus] = (acc[g.reviewStatus] || 0) + 1;
+        return acc;
+      }, {});
+      summary = {
+        total: state.groups.length,
+        autoConfirmed: counts.AUTO_CONFIRMED || 0,
+        suggested: counts.SUGGESTED || 0,
+        imported: counts.IMPORTED || 0,
+        confirmed: counts.CONFIRMED || 0,
+        rejected: counts.REJECTED || 0
+      };
+    }
+    const stats = document.getElementById('group-stats');
+    if (stats) stats.outerHTML = renderGroupStatsCard(summary);
+  }
+
+  function bindGroupSync(page) {
+    page.querySelector('#btn-sync-groups').addEventListener('click', () => {
+      const guid = getGuid();
+      if (!guid) {
+        toast('请先登录企业微信账号', 'warning');
+        state.currentAccountId = null;
+        renderPage();
+        return;
+      }
+      const acc = currentAccount();
+      if (!acc || !acc.online) {
+        toast('账号当前离线,请先恢复登录后再扫描群聊', 'warning');
+        location.hash = '#status';
+        return;
+      }
+      const keywords = document.getElementById('sync-keywords').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      const highConfidence = document.getElementById('sync-high-confidence').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      const body = {
+        guid,
+        scope: document.getElementById('sync-scope').value,
+        maxPages: Number(document.getElementById('sync-max-pages').value) || 20,
+        customerKeywords: keywords,
+        highConfidenceTerms: highConfidence,
+        autoClassify: document.getElementById('sync-auto-classify').checked,
+        scanFromMessages: document.getElementById('sync-scope').value === 'messages'
+      };
+      const tracker = document.getElementById('sync-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('sync-groups', id);
+      startJob('sync-groups', '/api/groups/sync', body, result => {
+        updateGroupStats(result.summary);
+        loadGroups();
+      });
+    });
+  }
+
+  function bindGroupFilters(page) {
+    const refresh = () => {
+      state.groupFilters.keyword = document.getElementById('filter-keyword').value;
+      state.groupFilters.status = document.getElementById('filter-status').value;
+      renderGroupTable(state.groups);
+    };
+    page.querySelector('#filter-keyword').addEventListener('input', refresh);
+    page.querySelector('#filter-status').addEventListener('change', refresh);
+    page.querySelector('#btn-refresh-groups').addEventListener('click', loadGroups);
+
+    const customerOnlyBtn = page.querySelector('#btn-filter-customer-only');
+    customerOnlyBtn.addEventListener('click', () => {
+      state.groupFilters.showCustomerOnly = !state.groupFilters.showCustomerOnly;
+      customerOnlyBtn.classList.toggle('active', state.groupFilters.showCustomerOnly);
+      customerOnlyBtn.textContent = state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群';
+      renderGroupTable(state.groups);
+    });
+  }
+
+  function openGroupDetailModal(roomId, roomName) {
+    const group = state.groups.find(g => g.roomId === roomId) || {};
+    const members = group.members || [];
+
+    // 自动识别客户:优先选择外部联系人(type === 2)
+    let suggestedCustomer = null;
+    const externalMembers = members.filter(m => m.type === 2 || String(m.type) === '2');
+    if (externalMembers.length) {
+      suggestedCustomer = externalMembers[0];
+    } else if (members.length) {
+      // 没有明确外部标识时,排除常见内部角色后取第一个
+      suggestedCustomer = members.find(m => {
+        const name = String(m.userName || '').trim();
+        return name && !/^(企业微信|WeChat|系统|客服|顾问|销售|管理员)/.test(name);
+      }) || members[0];
+    }
+
+    const defaultCustomerName = group.customerName || (suggestedCustomer?.userName) || '';
+    const defaultExternalUserId = group.externalUserId || (suggestedCustomer?.userId) || '';
+    const defaultCustomerId = group.customerId || '';
+
+    openModal('群详情 / 客户信息', `
+      <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
+      <div class="form-group" style="margin-bottom:12px">
+        <label>客户编号(可选)</label>
+        <input id="detail-customer-id" type="text" value="${escapeHtml(defaultCustomerId)}" />
+      </div>
+      <div class="form-group" style="margin-bottom:12px">
+        <label>客户企微 ID(可选)</label>
+        <input id="detail-external-user-id" type="text" value="${escapeHtml(defaultExternalUserId)}" />
+      </div>
+      <div class="form-group" style="margin-bottom:12px">
+        <label>客户姓名(可选)</label>
+        <input id="detail-customer-name" type="text" value="${escapeHtml(defaultCustomerName)}" />
+      </div>
+      ${members.length ? `
+      <div class="form-group">
+        <label>群成员(自动识别参考)</label>
+        <div style="max-height:120px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;font-size:13px;color:var(--text-secondary)">
+          ${members.map(m => `<div style="padding:4px 0">${escapeHtml(m.userName || m.userId || '未知成员')} ${m.type === 2 ? '<span class="badge badge-info" style="margin-left:6px">外部联系人</span>' : ''}</div>`).join('')}
+        </div>
+      </div>
+      ` : ''}
+    `, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn" id="btn-save-group-detail">保存</button>
+    `);
+
+    document.getElementById('btn-save-group-detail').addEventListener('click', async () => {
+      const body = {
+        roomId,
+        customerId: document.getElementById('detail-customer-id').value.trim() || undefined,
+        externalUserId: document.getElementById('detail-external-user-id').value.trim() || undefined,
+        customerName: document.getElementById('detail-customer-name').value.trim() || undefined
+      };
+      try {
+        const res = await api('POST', '/api/groups/confirm', body);
+        closeModal();
+        toast(res.assistantMessage || '已保存');
+        loadGroups();
+      } catch (err) {
+        toast(err.message || '保存失败', 'error');
+      }
+    });
+  }
+
+  function openRejectModal(roomId, roomName) {
+    openModal('忽略这个群', `
+      <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
+      <div class="form-group">
+        <label>原因(可选)</label>
+        <input id="reject-reason" type="text" value="不是客户群" />
+      </div>
+    `, `
+      <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
+      <button class="btn btn-danger" id="btn-do-reject">忽略</button>
+    `);
+    document.getElementById('btn-do-reject').addEventListener('click', async () => {
+      const body = { roomId, reason: document.getElementById('reject-reason').value.trim() };
+      try {
+        const res = await api('POST', '/api/groups/reject', body);
+        closeModal();
+        toast(res.assistantMessage || '已忽略');
+        loadGroups();
+      } catch (err) {
+        toast(err.message || '忽略失败', 'error');
+      }
+    });
+  }
+
+  async function syncGroupMessages(roomIds) {
+    const guid = getGuid();
+    if (!guid) {
+      toast('请先登录企业微信账号', 'warning');
+      return;
+    }
+    const acc = currentAccount();
+    if (!acc || !acc.online) {
+      toast('账号当前离线,请先恢复登录后再同步消息', 'warning');
+      location.hash = '#status';
+      return;
+    }
+    const body = { guid, roomIds, maxPages: 20, maxMessagesPerRoom: 500 };
+    const name = 'sync-messages-' + roomIds[0];
+    startJob(name, '/api/groups/messages/sync', body, result => {
+      toast(`共同步 ${result.summary?.totalNew || 0} 条消息`);
+      markGroupsSynced(roomIds);
+      renderGroupTable(state.groups);
+    });
+  }
+
+  // ==================== Helpers ====================
+  function requireOnlineAccount() {
+    const acc = currentAccount();
+    if (!acc) {
+      toast('请先登录企业微信账号', 'warning');
+      state.currentAccountId = null;
+      renderPage();
+      return false;
+    }
+    const guid = getGuid();
+    if (!guid) {
+      toast('当前账号缺少 guid,请重新登录', 'warning');
+      return false;
+    }
+    if (!acc.online) {
+      toast('账号当前离线,请先恢复登录', 'warning');
+      location.hash = '#status';
+      return false;
+    }
+    return true;
+  }
+
+  function renderFileLink(filePath, label = '下载文件') {
+    if (!filePath) return '';
+    return `<a class="btn btn-sm btn-secondary" href="/api/outputs?path=${encodeURIComponent(filePath)}" target="_blank">${escapeHtml(label)}</a>`;
+  }
+
+  function readFileBase64(file) {
+    return new Promise((resolve, reject) => {
+      const reader = new FileReader();
+      reader.onload = () => resolve(String(reader.result).split(',')[1]);
+      reader.onerror = reject;
+      reader.readAsDataURL(file);
+    });
+  }
+
+  // ==================== Customer Operations ====================
+  function parseCustomerText(text) {
+    return text.split(/\n|\r/).map(line => line.trim()).filter(Boolean).map(line => {
+      const parts = line.split(/\s+/);
+      const phone = parts.find(p => /^1[3-9]\d{9}$/.test(p)) || '';
+      const name = parts.find(p => !/^1[3-9]\d{9}$/.test(p)) || '';
+      return { phone, name };
+    }).filter(c => c.phone);
+  }
+
+  async function renderCustomerOpsPage() {
+    els.pageTitle.textContent = '客户运营';
+    const page = document.createElement('div');
+    page.className = 'page';
+    page.innerHTML = `
+      <div class="hint-box">
+        <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+        <div>批量加好友、检查好友状态、查询客户档案、自动建群。所有操作都会通过企业微信网关实时执行。</div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
+              批量加好友
+            </h3>
+            <div class="card-subtitle">每行输入「手机号 姓名」,支持粘贴或上传 Excel</div>
+          </div>
+        </div>
+
+        <div class="form-row">
+          <div class="form-group" style="flex:2;min-width:260px">
+            <label>客户列表</label>
+            <textarea id="co-customer-text" rows="6" placeholder="13800138000 张三\n13800138001 李四"></textarea>
+            <input type="file" id="co-customer-file" accept=".xlsx,.xls" style="margin-top:8px" />
+          </div>
+          <div class="form-group" style="flex:1;min-width:220px">
+            <label>默认验证消息</label>
+            <textarea id="co-default-greeting" rows="3">${escapeHtml(state.customerOps.defaultGreeting)}</textarea>
+            <span style="font-size:12px;color:var(--text-muted)">支持 {{name}}、{{phone}} 变量</span>
+          </div>
+        </div>
+
+        <div class="form-row">
+          <div class="form-group">
+            <label>速率限制(个/分钟)</label>
+            <input id="co-rate-limit" type="number" min="1" max="60" value="${state.customerOps.rateLimit}" />
+          </div>
+          <div class="form-group">
+            <label>最大重试次数</label>
+            <input id="co-max-attempts" type="number" min="1" max="5" value="${state.customerOps.maxAttempts}" />
+          </div>
+        </div>
+
+        <div id="co-customer-table-wrap"></div>
+
+        <div style="display:flex;gap:12px;align-items:center;margin-top:16px">
+          <button class="btn" id="btn-batch-add" data-job="batch-add-friends">开始批量添加</button>
+          <span id="co-preview-text" style="font-size:13px;color:var(--text-muted)"></span>
+        </div>
+        <div id="co-add-job-tracker"></div>
+        <div id="co-add-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08C16.71 17.92 14.5 19.2 12 19.2z"/></svg>
+              好友状态检查
+            </h3>
+            <div class="card-subtitle">输入手机号查询是否已是好友或已发送申请</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group" style="flex:1">
+            <label>手机号列表(每行一个)</label>
+            <textarea id="co-check-phones" rows="4" placeholder="13800138000\n13800138001"></textarea>
+          </div>
+        </div>
+        <button class="btn" id="btn-check-friends" data-job="check-friend-status">检查好友状态</button>
+        <div id="co-check-job-tracker"></div>
+        <div id="co-check-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
+              客户档案
+            </h3>
+            <div class="card-subtitle">通过手机号或 externalUserId 查询联系人、群、画像</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label>手机号</label>
+            <input id="co-profile-phone" type="text" placeholder="13800138000" />
+          </div>
+          <div class="form-group">
+            <label>或 externalUserId</label>
+            <input id="co-profile-external" type="text" placeholder="企业微信外部联系人 ID" />
+          </div>
+        </div>
+        <button class="btn" id="btn-customer-profile">查询客户档案</button>
+        <div id="co-profile-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
+              自动建群
+            </h3>
+            <div class="card-subtitle">输入成员 externalUserId,自动创建客户群并设置群名、协作成员、欢迎语</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group" style="flex:1">
+            <label>成员 externalUserId(每行一个,第一个为默认客户)</label>
+            <textarea id="co-auto-members" rows="3" placeholder="externalUserId1\nexternalUserId2"></textarea>
+          </div>
+          <div class="form-group">
+            <label>群名称</label>
+            <input id="co-auto-group-name" type="text" placeholder="专属服务群" />
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group" style="flex:1">
+            <label>协作成员 externalUserId(逗号分隔,可选)</label>
+            <input id="co-auto-support" type="text" placeholder="member1,member2" />
+          </div>
+          <div class="form-group" style="flex:1">
+            <label>欢迎语(可选)</label>
+            <input id="co-auto-welcome" type="text" placeholder="您好,欢迎加入专属服务群" />
+          </div>
+        </div>
+        <button class="btn" id="btn-auto-create-group" data-job="auto-create-group">创建客户群</button>
+        <div id="co-auto-job-tracker"></div>
+        <div id="co-auto-result"></div>
+      </div>
+    `;
+    els.content.appendChild(page);
+    bindCustomerOpsPage(page);
+  }
+
+  function bindCustomerOpsPage(page) {
+    const textArea = page.querySelector('#co-customer-text');
+    const fileInput = page.querySelector('#co-customer-file');
+    const greetingInput = page.querySelector('#co-default-greeting');
+    const previewText = page.querySelector('#co-preview-text');
+    const tableWrap = page.querySelector('#co-customer-table-wrap');
+
+    function renderCustomerTable() {
+      const customers = state.customerOps.customers;
+      if (!customers.length) {
+        tableWrap.innerHTML = '';
+        previewText.textContent = '';
+        return;
+      }
+      const greeting = greetingInput.value.trim() || state.customerOps.defaultGreeting;
+      const first = customers[0];
+      const preview = greeting.replace(/\{\{name\}\}/g, first.name || '客户').replace(/\{\{phone\}\}/g, first.phone || '');
+      previewText.textContent = `预览:${preview}`;
+
+      tableWrap.innerHTML = `
+        <div class="table-wrap" style="margin-top:16px">
+          <table>
+            <thead><tr><th>手机号</th><th>姓名</th><th>自定义验证消息(留空使用默认模板)</th></tr></thead>
+            <tbody>
+              ${customers.map((c, i) => `
+                <tr>
+                  <td>${escapeHtml(c.phone)}</td>
+                  <td><input type="text" data-idx="${i}" data-field="name" value="${escapeHtml(c.name || '')}" style="min-width:80px" /></td>
+                  <td><input type="text" data-idx="${i}" data-field="greeting" value="${escapeHtml(c.greeting || '')}" placeholder="使用默认模板" style="width:100%" /></td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      `;
+      tableWrap.querySelectorAll('input[data-idx]').forEach(input => {
+        input.addEventListener('change', () => {
+          const idx = Number(input.dataset.idx);
+          const field = input.dataset.field;
+          state.customerOps.customers[idx][field] = input.value.trim();
+        });
+      });
+    }
+
+    function updateCustomersFromText() {
+      state.customerOps.customers = parseCustomerText(textArea.value);
+      renderCustomerTable();
+    }
+
+    textArea.addEventListener('input', updateCustomersFromText);
+
+    greetingInput.addEventListener('input', renderCustomerTable);
+
+    fileInput.addEventListener('change', async () => {
+      const file = fileInput.files[0];
+      if (!file) return;
+      try {
+        const base64 = await readFileBase64(file);
+        const res = await api('POST', '/api/upload', { name: file.name, data: base64 });
+        const filePath = res.data?.path;
+        const result = await api('POST', '/api/customer-ops/batch-add-friends', { guid: getGuid(), filePath });
+        const uploaded = result.data?.customers || [];
+        if (uploaded.length) {
+          const existing = new Set(state.customerOps.customers.map(c => c.phone));
+          for (const c of uploaded) {
+            if (!existing.has(c.phone)) {
+              state.customerOps.customers.push(c);
+              existing.add(c.phone);
+            }
+          }
+          textArea.value = state.customerOps.customers.map(c => `${c.phone} ${c.name || ''}`).join('\n');
+          renderCustomerTable();
+          toast(`已导入 ${uploaded.length} 位客户`);
+        }
+      } catch (err) {
+        toast(err.message || '导入 Excel 失败', 'error');
+      }
+      fileInput.value = '';
+    });
+
+    page.querySelector('#btn-batch-add').addEventListener('click', () => {
+      if (!requireOnlineAccount()) return;
+      if (!state.customerOps.customers.length) {
+        toast('请先输入客户列表', 'warning');
+        return;
+      }
+      const defaultGreeting = greetingInput.value.trim() || state.customerOps.defaultGreeting;
+      const customers = state.customerOps.customers.map(c => ({
+        phone: c.phone,
+        name: c.name || undefined,
+        greeting: c.greeting || undefined
+      }));
+      const body = {
+        guid: getGuid(),
+        customers,
+        defaultGreeting,
+        rateLimitPerMinute: Number(page.querySelector('#co-rate-limit').value) || 12,
+        maxAttempts: Number(page.querySelector('#co-max-attempts').value) || 2
+      };
+      const tracker = page.querySelector('#co-add-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('batch-add-friends', id);
+      const resultWrap = page.querySelector('#co-add-result');
+      resultWrap.innerHTML = '';
+      startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, result => {
+        state.customerOps.lastResult = result;
+        resultWrap.innerHTML = renderBatchAddResult(result);
+      });
+    });
+
+    page.querySelector('#btn-check-friends').addEventListener('click', () => {
+      if (!requireOnlineAccount()) return;
+      const phones = page.querySelector('#co-check-phones').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
+      if (!phones.length) {
+        toast('请输入手机号', 'warning');
+        return;
+      }
+      const body = { guid: getGuid(), phones };
+      const tracker = page.querySelector('#co-check-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('check-friend-status', id);
+      const resultWrap = page.querySelector('#co-check-result');
+      resultWrap.innerHTML = '';
+      startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, result => {
+        resultWrap.innerHTML = renderCheckFriendResult(result);
+      });
+    });
+
+    page.querySelector('#btn-customer-profile').addEventListener('click', async () => {
+      if (!requireOnlineAccount()) return;
+      const phone = page.querySelector('#co-profile-phone').value.trim();
+      const externalUserId = page.querySelector('#co-profile-external').value.trim();
+      if (!phone && !externalUserId) {
+        toast('请输入手机号或 externalUserId', 'warning');
+        return;
+      }
+      const resultWrap = page.querySelector('#co-profile-result');
+      resultWrap.innerHTML = '<div class="skeleton" style="height:120px;margin-top:16px"></div>';
+      try {
+        const res = await api('POST', '/api/customer-ops/customer-profile', { guid: getGuid(), phone, externalUserId });
+        resultWrap.innerHTML = renderCustomerProfileResult(res);
+      } catch (err) {
+        resultWrap.innerHTML = `<div class="hint-box" style="background:var(--error-bg);color:var(--error);border-color:var(--error-border);margin-top:16px"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg><div>${escapeHtml(err.message || '查询失败')}</div></div>`;
+      }
+    });
+
+    page.querySelector('#btn-auto-create-group').addEventListener('click', () => {
+      if (!requireOnlineAccount()) return;
+      const members = page.querySelector('#co-auto-members').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
+      if (!members.length) {
+        toast('请输入至少一个成员 externalUserId', 'warning');
+        return;
+      }
+      const support = page.querySelector('#co-auto-support').value.split(',').map(s => s.trim()).filter(Boolean);
+      const body = {
+        guid: getGuid(),
+        memberList: members,
+        groupName: page.querySelector('#co-auto-group-name').value.trim() || undefined,
+        supportMemberIds: support.length ? support : undefined,
+        welcomeText: page.querySelector('#co-auto-welcome').value.trim() || undefined
+      };
+      const tracker = page.querySelector('#co-auto-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('auto-create-group', id);
+      const resultWrap = page.querySelector('#co-auto-result');
+      resultWrap.innerHTML = '';
+      startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, result => {
+        resultWrap.innerHTML = renderAutoCreateGroupResult(result);
+      });
+    });
+
+    updateCustomersFromText();
+  }
+
+  function renderBatchAddResult(result) {
+    const summary = result.summary || {};
+    const details = result.data?.details || [];
+    return `
+      <div class="stats-grid" style="margin-top:16px">
+        <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.success)}</div><div class="stat-label">成功</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.failed)}</div><div class="stat-label">失败</div></div>
+        <div class="stat-card"><div class="stat-value">${formatNumber(summary.invalidRemoved)}</div><div class="stat-label">无效</div></div>
+      </div>
+      ${details.length ? `
+        <div class="table-wrap" style="margin-top:16px">
+          <table>
+            <thead><tr><th>手机号</th><th>姓名</th><th>状态</th><th>尝试次数</th><th>说明</th></tr></thead>
+            <tbody>
+              ${details.map(d => `
+                <tr>
+                  <td>${escapeHtml(d.phone)}</td>
+                  <td>${escapeHtml(d.name || '-')}</td>
+                  <td>${d.status === 'SUCCESS' ? '<span class="badge badge-success">成功</span>' : '<span class="badge badge-error">失败</span>'}</td>
+                  <td>${formatNumber(d.attempts)}</td>
+                  <td style="color:var(--text-secondary);font-size:12px">${escapeHtml(d.reason || d.status || '-')}</td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      ` : ''}
+    `;
+  }
+
+  function renderCheckFriendResult(result) {
+    const summary = result.summary || {};
+    const details = result.data?.details || [];
+    return `
+      <div class="stats-grid" style="margin-top:16px">
+        <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.confirmed)}</div><div class="stat-label">已确认</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--warning)">${formatNumber(summary.pending)}</div><div class="stat-label">待通过</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.notFound)}</div><div class="stat-label">未找到</div></div>
+      </div>
+      ${details.length ? `
+        <div class="table-wrap" style="margin-top:16px">
+          <table>
+            <thead><tr><th>手机号/ID</th><th>姓名</th><th>状态</th><th>说明</th></tr></thead>
+            <tbody>
+              ${details.map(d => `
+                <tr>
+                  <td>${escapeHtml(d.phone || d.externalUserId || '-')}</td>
+                  <td>${escapeHtml(d.name || '-')}</td>
+                  <td>${friendStatusBadge(d.statusText)}</td>
+                  <td style="color:var(--text-secondary);font-size:12px">${escapeHtml(d.message || '-')}</td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      ` : ''}
+    `;
+  }
+
+  function friendStatusBadge(status) {
+    const map = {
+      'already_friend': ['badge-success', '已是好友'],
+      'already_added_by_other': ['badge-info', '已被其他人添加'],
+      'not_added': ['badge-warning', '未添加'],
+      'not_found': ['badge-error', '未找到'],
+      'provided_external_user_id': ['badge-default', '仅提供 ID']
+    };
+    const [cls, text] = map[status] || ['badge-default', status || '未知'];
+    return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
+  }
+
+  function renderCustomerProfileResult(res) {
+    const data = res.data || {};
+    const contact = data.contact || {};
+    const contactList = data.contactList || [];
+    const rooms = data.rooms || [];
+    const portrait = data.portrait || null;
+    return `
+      <div style="margin-top:16px">
+        <div class="form-row">
+          <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${escapeHtml(contact.externalUserId || '未解析')}</div><div class="stat-label">externalUserId</div></div>
+          <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${formatNumber(contactList.length)}</div><div class="stat-label">匹配联系人</div></div>
+          <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${formatNumber(rooms.length)}</div><div class="stat-label">相关群</div></div>
+          <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${portrait ? '有' : '无'}</div><div class="stat-label">本地画像</div></div>
+        </div>
+        ${contactList.length ? `
+          <h4 style="font-size:14px;color:var(--text-secondary);margin:16px 0 8px">联系人列表</h4>
+          <div class="table-wrap"><table><thead><tr><th>userId</th><th>昵称</th><th>备注</th></tr></thead><tbody>
+            ${contactList.map(c => `<tr><td>${escapeHtml(c.userId || c.externalUserId || '-')}</td><td>${escapeHtml(c.nickName || c.userName || '-')}</td><td>${escapeHtml(c.remark || '-')}</td></tr>`).join('')}
+          </tbody></table></div>
+        ` : ''}
+        ${rooms.length ? `
+          <h4 style="font-size:14px;color:var(--text-secondary);margin:16px 0 8px">相关群</h4>
+          <div class="table-wrap"><table><thead><tr><th>群名</th><th>roomId</th></tr></thead><tbody>
+            ${rooms.map(r => `<tr><td>${escapeHtml(r.roomName || '未命名')}</td><td>${escapeHtml(r.roomId || '-')}</td></tr>`).join('')}
+          </tbody></table></div>
+        ` : ''}
+        ${portrait ? `
+          <h4 style="font-size:14px;color:var(--text-secondary);margin:16px 0 8px">客户画像</h4>
+          <div class="job-log">${escapeHtml(JSON.stringify(portrait.portrait || portrait, null, 2))}</div>
+        ` : ''}
+      </div>
+    `;
+  }
+
+  function renderAutoCreateGroupResult(result) {
+    const summary = result.summary || {};
+    const warnings = result.warnings || [];
+    return `
+      <div class="hint-box" style="margin-top:16px">
+        <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
+        <div>
+          <div style="font-weight:600">${escapeHtml(result.assistantMessage || '建群完成')}</div>
+          <div style="margin-top:6px;font-size:13px">roomId:${escapeHtml(summary.roomId || '-')}</div>
+          <div style="font-size:13px">群名:${escapeHtml(summary.groupName || '-')}</div>
+          <div style="font-size:13px">成员数:${formatNumber(summary.memberCount)}</div>
+          ${summary.supportInvited ? '<div style="font-size:13px">协作成员:已邀请</div>' : ''}
+          ${summary.welcomeSent ? '<div style="font-size:13px">欢迎语:已发送</div>' : ''}
+        </div>
+      </div>
+      ${warnings.length ? `<div class="hint-box" style="background:var(--warning-bg);color:#d48806;border-color:#ffe58f"><svg viewBox="0 0 24 24"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg><div>${warnings.map(w => escapeHtml(w)).join('<br>')}</div></div>` : ''}
+    `;
+  }
+
+  // ==================== Portraits & Tags ====================
+  async function renderPortraitsPage() {
+    els.pageTitle.textContent = '画像与标签';
+    const page = document.createElement('div');
+    page.className = 'page';
+    page.innerHTML = `
+      <div class="hint-box">
+        <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+        <div>基于群消息生成客户画像、管理本地标签、同步企微个人标签并应用到客户。</div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08C16.71 17.92 14.5 19.2 12 19.2z"/></svg>
+              客户画像
+            </h3>
+            <div class="card-subtitle">关键词模式直接生成保存;AI 模式生成 context 文件供进一步分析</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group" style="flex:1">
+            <label>externalUserId</label>
+            <input id="pt-external-user-id" type="text" placeholder="外部联系人 ID" />
+          </div>
+          <div class="form-group">
+            <label>模式</label>
+            <select id="pt-portrait-mode">
+              <option value="keyword">关键词模式</option>
+              <option value="ai">AI 分析模式</option>
+            </select>
+          </div>
+        </div>
+        <div style="display:flex;gap:12px;flex-wrap:wrap">
+          <button class="btn" id="btn-update-portrait" data-job="update-portrait">生成/更新画像</button>
+          <button class="btn btn-secondary" id="btn-save-portrait">手动保存画像 JSON</button>
+        </div>
+        <div id="pt-portrait-job-tracker"></div>
+        <div id="pt-portrait-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
+              批量画像
+            </h3>
+            <div class="card-subtitle">输入多个 externalUserId,逐一生成关键词画像</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group" style="flex:1">
+            <label>externalUserId 列表(每行一个)</label>
+            <textarea id="pt-batch-ids" rows="4" placeholder="externalUserId1\nexternalUserId2"></textarea>
+          </div>
+        </div>
+        <div style="display:flex;gap:12px;flex-wrap:wrap">
+          <button class="btn" id="btn-batch-portraits" data-job="batch-portraits">批量生成画像</button>
+          <button class="btn btn-secondary" id="btn-export-portraits">导出全部画像 Excel</button>
+        </div>
+        <div id="pt-batch-job-tracker"></div>
+        <div id="pt-batch-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/></svg>
+              本地标签
+            </h3>
+            <div class="card-subtitle">按客户增删标签、查看全部去重标签</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label>externalUserId</label>
+            <input id="pt-tag-external" type="text" placeholder="外部联系人 ID" />
+          </div>
+          <div class="form-group" style="flex:1">
+            <label>标签(逗号分隔)</label>
+            <input id="pt-tag-input" type="text" placeholder="高意向, 学区房, 急售" />
+          </div>
+        </div>
+        <div style="display:flex;gap:12px;flex-wrap:wrap">
+          <button class="btn" id="btn-add-tags">添加标签</button>
+          <button class="btn btn-secondary" id="btn-remove-tags">移除标签</button>
+          <button class="btn btn-secondary" id="btn-list-tags">查询客户标签</button>
+          <button class="btn btn-secondary" id="btn-list-all-tags">全部去重标签</button>
+        </div>
+        <div id="pt-tags-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
+              企微个人标签
+            </h3>
+            <div class="card-subtitle">同步标签列表、创建/编辑/删除、应用到客户</div>
+          </div>
+        </div>
+        <div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">
+          <button class="btn btn-secondary" id="btn-sync-personal-labels">同步标签列表</button>
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label>标签名称</label>
+            <input id="pl-name" type="text" placeholder="标签名" />
+          </div>
+          <div class="form-group">
+            <label>父标签 ID(可选)</label>
+            <input id="pl-super" type="text" placeholder="labelSuperId" />
+          </div>
+          <div class="form-group">
+            <label>标签 ID(编辑/删除用)</label>
+            <input id="pl-id" type="text" placeholder="labelId" />
+          </div>
+        </div>
+        <div style="display:flex;gap:12px;flex-wrap:wrap">
+          <button class="btn" id="btn-create-personal-label">创建</button>
+          <button class="btn btn-secondary" id="btn-update-personal-label">更新</button>
+          <button class="btn btn-danger" id="btn-delete-personal-label">删除</button>
+        </div>
+        <div id="pl-list-result" style="margin-top:16px"></div>
+        <div class="form-row" style="margin-top:20px">
+          <div class="form-group">
+            <label>客户 externalUserId</label>
+            <input id="pl-apply-external" type="text" placeholder="外部联系人 ID" />
+          </div>
+          <div class="form-group" style="flex:1">
+            <label>要应用的标签 ID(逗号分隔)</label>
+            <input id="pl-apply-ids" type="text" placeholder="labelId1,labelId2" />
+          </div>
+        </div>
+        <button class="btn" id="btn-apply-personal-labels">应用标签到客户</button>
+        <div id="pl-apply-result"></div>
+      </div>
+    `;
+    els.content.appendChild(page);
+    bindPortraitsPage(page);
+  }
+
+  function bindPortraitsPage(page) {
+    page.querySelector('#btn-update-portrait').addEventListener('click', () => {
+      const externalUserId = page.querySelector('#pt-external-user-id').value.trim();
+      if (!externalUserId) {
+        toast('请输入 externalUserId', 'warning');
+        return;
+      }
+      const mode = page.querySelector('#pt-portrait-mode').value;
+      const body = { externalUserId, aiMode: mode === 'ai' ? 'ai' : 'keyword' };
+      const tracker = page.querySelector('#pt-portrait-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('update-portrait', id);
+      const resultWrap = page.querySelector('#pt-portrait-result');
+      resultWrap.innerHTML = '';
+      startJob('update-portrait', '/api/portraits/update', body, result => {
+        state.portraits.lastPortrait = result;
+        resultWrap.innerHTML = renderPortraitResult(result);
+      });
+    });
+
+    page.querySelector('#btn-save-portrait').addEventListener('click', () => {
+      const externalUserId = page.querySelector('#pt-external-user-id').value.trim();
+      if (!externalUserId) {
+        toast('请输入 externalUserId', 'warning');
+        return;
+      }
+      const jsonText = prompt('请输入画像 JSON 内容:', state.portraits.lastPortrait ? JSON.stringify(state.portraits.lastPortrait.data?.portrait || {}) : '{}');
+      if (!jsonText) return;
+      try {
+        const portrait = JSON.parse(jsonText);
+        api('POST', '/api/portraits/save', { externalUserId, portrait })
+          .then(res => {
+            toast(res.assistantMessage || '保存成功');
+            page.querySelector('#pt-portrait-result').innerHTML = renderPortraitResult(res);
+          })
+          .catch(err => toast(err.message || '保存失败', 'error'));
+      } catch (e) {
+        toast('JSON 格式错误', 'error');
+      }
+    });
+
+    page.querySelector('#btn-batch-portraits').addEventListener('click', () => {
+      const ids = page.querySelector('#pt-batch-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
+      if (!ids.length) {
+        toast('请输入 externalUserId 列表', 'warning');
+        return;
+      }
+      const tracker = page.querySelector('#pt-batch-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('batch-portraits', id);
+      const resultWrap = page.querySelector('#pt-batch-result');
+      resultWrap.innerHTML = '';
+      startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: 'keyword' }, result => {
+        resultWrap.innerHTML = renderBatchPortraitResult(result);
+      });
+    });
+
+    page.querySelector('#btn-export-portraits').addEventListener('click', () => {
+      const tracker = page.querySelector('#pt-batch-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('export-portraits', id);
+      const resultWrap = page.querySelector('#pt-batch-result');
+      resultWrap.innerHTML = '';
+      startJob('export-portraits', '/api/portraits/export', {}, result => {
+        resultWrap.innerHTML = renderExportPortraitResult(result);
+      });
+    });
+
+    page.querySelector('#btn-add-tags').addEventListener('click', async () => {
+      const externalUserId = page.querySelector('#pt-tag-external').value.trim();
+      const tags = page.querySelector('#pt-tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      if (!externalUserId || !tags.length) {
+        toast('请输入 externalUserId 和标签', 'warning');
+        return;
+      }
+      const resultWrap = page.querySelector('#pt-tags-result');
+      try {
+        const res = await api('POST', '/api/tags/add', { externalUserId, tags });
+        resultWrap.innerHTML = renderTagsResult(res);
+        toast(res.assistantMessage || '添加成功');
+      } catch (err) {
+        toast(err.message || '添加失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-remove-tags').addEventListener('click', async () => {
+      const externalUserId = page.querySelector('#pt-tag-external').value.trim();
+      const tags = page.querySelector('#pt-tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      if (!externalUserId || !tags.length) {
+        toast('请输入 externalUserId 和标签', 'warning');
+        return;
+      }
+      const resultWrap = page.querySelector('#pt-tags-result');
+      try {
+        const res = await api('POST', '/api/tags/remove', { externalUserId, tags });
+        resultWrap.innerHTML = renderTagsResult(res);
+        toast(res.assistantMessage || '移除成功');
+      } catch (err) {
+        toast(err.message || '移除失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-list-tags').addEventListener('click', async () => {
+      const externalUserId = page.querySelector('#pt-tag-external').value.trim();
+      if (!externalUserId) {
+        toast('请输入 externalUserId', 'warning');
+        return;
+      }
+      const resultWrap = page.querySelector('#pt-tags-result');
+      try {
+        const res = await api('POST', '/api/tags/list', { externalUserId });
+        resultWrap.innerHTML = renderTagsResult(res);
+      } catch (err) {
+        toast(err.message || '查询失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-list-all-tags').addEventListener('click', async () => {
+      const resultWrap = page.querySelector('#pt-tags-result');
+      try {
+        const res = await api('POST', '/api/tags/all', {});
+        resultWrap.innerHTML = renderTagsResult(res, true);
+      } catch (err) {
+        toast(err.message || '查询失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-sync-personal-labels').addEventListener('click', async () => {
+      if (!requireOnlineAccount()) return;
+      const resultWrap = page.querySelector('#pl-list-result');
+      try {
+        const res = await api('POST', '/api/personal-labels/sync', { guid: getGuid() });
+        state.portraits.personalLabels = res.data?.labels || [];
+        resultWrap.innerHTML = renderPersonalLabelsResult(res);
+      } catch (err) {
+        toast(err.message || '同步失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-create-personal-label').addEventListener('click', async () => {
+      if (!requireOnlineAccount()) return;
+      const labelName = page.querySelector('#pl-name').value.trim();
+      if (!labelName) {
+        toast('请输入标签名称', 'warning');
+        return;
+      }
+      try {
+        const res = await api('POST', '/api/personal-labels/create', { guid: getGuid(), labelName, labelSuperId: page.querySelector('#pl-super').value.trim() || undefined });
+        toast(res.assistantMessage || '创建成功');
+      } catch (err) {
+        toast(err.message || '创建失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-update-personal-label').addEventListener('click', async () => {
+      if (!requireOnlineAccount()) return;
+      const labelId = page.querySelector('#pl-id').value.trim();
+      const labelName = page.querySelector('#pl-name').value.trim();
+      if (!labelId || !labelName) {
+        toast('请输入标签 ID 和名称', 'warning');
+        return;
+      }
+      try {
+        const res = await api('POST', '/api/personal-labels/update', { guid: getGuid(), labelId, labelName, labelSuperId: page.querySelector('#pl-super').value.trim() || undefined });
+        toast(res.assistantMessage || '更新成功');
+      } catch (err) {
+        toast(err.message || '更新失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-delete-personal-label').addEventListener('click', async () => {
+      if (!requireOnlineAccount()) return;
+      const labelId = page.querySelector('#pl-id').value.trim();
+      if (!labelId) {
+        toast('请输入标签 ID', 'warning');
+        return;
+      }
+      if (!confirm(`确定删除标签 ${labelId} 吗?`)) return;
+      try {
+        const res = await api('POST', '/api/personal-labels/delete', { guid: getGuid(), labelId });
+        toast(res.assistantMessage || '删除成功');
+      } catch (err) {
+        toast(err.message || '删除失败', 'error');
+      }
+    });
+
+    page.querySelector('#btn-apply-personal-labels').addEventListener('click', async () => {
+      if (!requireOnlineAccount()) return;
+      const externalUserId = page.querySelector('#pl-apply-external').value.trim();
+      const addLabels = page.querySelector('#pl-apply-ids').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      if (!externalUserId || !addLabels.length) {
+        toast('请输入 externalUserId 和标签 ID', 'warning');
+        return;
+      }
+      const resultWrap = page.querySelector('#pl-apply-result');
+      try {
+        const res = await api('POST', '/api/personal-labels/apply', { guid: getGuid(), externalUserId, addLabels });
+        resultWrap.innerHTML = `<div class="hint-box" style="margin-top:16px"><svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg><div>${escapeHtml(res.assistantMessage || '应用成功')}</div></div>`;
+        toast(res.assistantMessage || '应用成功');
+      } catch (err) {
+        toast(err.message || '应用失败', 'error');
+      }
+    });
+  }
+
+  function renderPortraitResult(result) {
+    const summary = result.summary || {};
+    const data = result.data || {};
+    const files = result.files || [];
+    return `
+      <div class="hint-box" style="margin-top:16px">
+        <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
+        <div>
+          <div style="font-weight:600">${escapeHtml(result.assistantMessage || '画像生成完成')}</div>
+          <div style="margin-top:6px;font-size:13px">消息数:${formatNumber(summary.messageCount || 0)}</div>
+          <div style="font-size:13px">字段:${escapeHtml((summary.fields || []).join('、') || '-')}</div>
+          ${files.length ? `<div style="margin-top:8px">${renderFileLink(files[0], '下载 context 文件')}</div>` : ''}
+        </div>
+      </div>
+      ${data.portrait ? `<div class="job-log" style="margin-top:12px">${escapeHtml(JSON.stringify(data.portrait, null, 2))}</div>` : ''}
+    `;
+  }
+
+  function renderBatchPortraitResult(result) {
+    const summary = result.summary || {};
+    const results = result.data?.results || [];
+    return `
+      <div class="stats-grid" style="margin-top:16px">
+        <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.succeeded)}</div><div class="stat-label">成功</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.failed)}</div><div class="stat-label">失败</div></div>
+      </div>
+      ${results.length ? `
+        <div class="table-wrap" style="margin-top:16px">
+          <table>
+            <thead><tr><th>externalUserId</th><th>状态</th><th>消息数</th></tr></thead>
+            <tbody>
+              ${results.map(r => `
+                <tr>
+                  <td>${escapeHtml(r.externalUserId)}</td>
+                  <td>${r.status === 'ok' ? '<span class="badge badge-success">成功</span>' : '<span class="badge badge-error">失败</span>'}</td>
+                  <td>${formatNumber(r.summary?.messageCount || 0)}</td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      ` : ''}
+    `;
+  }
+
+  function renderExportPortraitResult(result) {
+    const summary = result.summary || {};
+    const files = result.files || [];
+    const filePath = result.data?.filePath || files[0];
+    return `
+      <div class="hint-box" style="margin-top:16px">
+        <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
+        <div>
+          <div style="font-weight:600">${escapeHtml(result.assistantMessage || '导出完成')}</div>
+          <div style="margin-top:6px;font-size:13px">行数:${formatNumber(summary.rowCount || 0)}</div>
+          ${filePath ? `<div style="margin-top:8px">${renderFileLink(filePath, '下载 Excel')}</div>` : ''}
+        </div>
+      </div>
+    `;
+  }
+
+  function renderTagsResult(res, all = false) {
+    const tags = res.data?.tags || [];
+    return `
+      <div style="margin-top:16px">
+        <div style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">${all ? '全部去重标签' : `客户 ${escapeHtml(res.summary?.externalUserId || '')} 的标签`}(${tags.length} 个)</div>
+        <div class="tag-list">
+          ${tags.length ? tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '<span style="color:var(--text-muted);font-size:13px">暂无标签</span>'}
+        </div>
+      </div>
+    `;
+  }
+
+  function renderPersonalLabelsResult(res) {
+    const labels = res.data?.labels || [];
+    return `
+      <div style="margin-top:16px">
+        <div style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">共 ${labels.length} 个标签</div>
+        ${labels.length ? `
+          <div class="table-wrap">
+            <table>
+              <thead><tr><th>标签 ID</th><th>标签名</th><th>父标签</th></tr></thead>
+              <tbody>
+                ${labels.map(l => `<tr><td>${escapeHtml(l.labelId || '-')}</td><td>${escapeHtml(l.labelName || '-')}</td><td>${escapeHtml(l.labelSuperId || '-')}</td></tr>`).join('')}
+              </tbody>
+            </table>
+          </div>
+        ` : '<span style="color:var(--text-muted);font-size:13px">暂无标签</span>'}
+      </div>
+    `;
+  }
+
+  // ==================== Customer Transfers ====================
+  async function renderTransfersPage() {
+    els.pageTitle.textContent = '客户交接';
+    const page = document.createElement('div');
+    page.className = 'page';
+    page.innerHTML = `
+      <div class="hint-box">
+        <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+        <div>选择原顾问和新顾问,预览交接包后执行。支持通过已确认群或 externalUserIds 指定客户。</div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
+              交接包预览
+            </h3>
+            <div class="card-subtitle">生成预览文件,确认后再执行交接</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label>原顾问 userId(fromUserId)</label>
+            <input id="tf-from" type="text" placeholder="原顾问 userId" />
+          </div>
+          <div class="form-group">
+            <label>新顾问 userId(toUserId)</label>
+            <input id="tf-to" type="text" placeholder="新顾问 userId" />
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group" style="flex:1">
+            <label>externalUserIds(每行一个,可选)</label>
+            <textarea id="tf-external-ids" rows="3" placeholder="externalUserId1\nexternalUserId2"></textarea>
+          </div>
+          <div class="form-group" style="flex:1">
+            <label>已确认群 roomIds(逗号分隔,可选)</label>
+            <input id="tf-room-ids" type="text" placeholder="roomId1,roomId2" />
+          </div>
+        </div>
+        <button class="btn" id="btn-preview-transfer">生成预览</button>
+        <div id="tf-preview-result"></div>
+      </div>
+
+      <div class="card">
+        <div class="card-header">
+          <div>
+            <h3 class="card-title">
+              <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
+              执行交接
+            </h3>
+            <div class="card-subtitle">基于最新预览包执行,可移除原顾问并发送欢迎语</div>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label>预览文件路径(可选,留空使用最新预览)</label>
+            <input id="tf-preview-file" type="text" placeholder="outputs/transfers/..." />
+          </div>
+          <div class="form-group" style="flex:1">
+            <label>欢迎语</label>
+            <input id="tf-welcome" type="text" value="您好,我是您的新服务顾问,后续由我为您服务。" />
+          </div>
+        </div>
+        <div class="checkbox-row" style="margin-bottom:16px">
+          <input id="tf-remove-old" type="checkbox" />
+          <label for="tf-remove-old">交接后移除原顾问</label>
+        </div>
+        <button class="btn" id="btn-execute-transfer" data-job="execute-transfer">执行交接</button>
+        <div id="tf-execute-job-tracker"></div>
+        <div id="tf-execute-result"></div>
+      </div>
+    `;
+    els.content.appendChild(page);
+    bindTransfersPage(page);
+  }
+
+  function bindTransfersPage(page) {
+    page.querySelector('#btn-preview-transfer').addEventListener('click', async () => {
+      const fromUserId = page.querySelector('#tf-from').value.trim();
+      const toUserId = page.querySelector('#tf-to').value.trim();
+      if (!fromUserId || !toUserId) {
+        toast('请输入原顾问和新顾问 userId', 'warning');
+        return;
+      }
+      const externalUserIds = page.querySelector('#tf-external-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
+      const roomIds = page.querySelector('#tf-room-ids').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
+      if (!externalUserIds.length && !roomIds.length) {
+        toast('请输入 externalUserIds 或已确认群 roomIds', 'warning');
+        return;
+      }
+      const resultWrap = page.querySelector('#tf-preview-result');
+      resultWrap.innerHTML = '<div class="skeleton" style="height:80px;margin-top:16px"></div>';
+      try {
+        const res = await api('POST', '/api/transfers/preview', { fromUserId, toUserId, externalUserIds, roomIds });
+        state.portraits.lastTransferPreview = res.data?.preview || null;
+        resultWrap.innerHTML = renderTransferPreviewResult(res);
+        if (res.data?.filePath) {
+          page.querySelector('#tf-preview-file').value = res.data.filePath;
+        }
+      } catch (err) {
+        resultWrap.innerHTML = `<div class="hint-box" style="background:var(--error-bg);color:var(--error);border-color:var(--error-border);margin-top:16px"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg><div>${escapeHtml(err.message || '预览失败')}</div></div>`;
+      }
+    });
+
+    page.querySelector('#btn-execute-transfer').addEventListener('click', () => {
+      if (!requireOnlineAccount()) return;
+      const previewFile = page.querySelector('#tf-preview-file').value.trim() || undefined;
+      const welcomeText = page.querySelector('#tf-welcome').value.trim();
+      const removeOldBroker = page.querySelector('#tf-remove-old').checked;
+      const body = { guid: getGuid(), previewFile, welcomeText, removeOldBroker };
+      const tracker = page.querySelector('#tf-execute-job-tracker');
+      const id = Date.now().toString(36);
+      tracker.innerHTML = renderJobTracker('execute-transfer', id);
+      const resultWrap = page.querySelector('#tf-execute-result');
+      resultWrap.innerHTML = '';
+      startJob('execute-transfer', '/api/transfers/execute', body, result => {
+        resultWrap.innerHTML = renderTransferExecuteResult(result);
+      });
+    });
+  }
+
+  function renderTransferPreviewResult(res) {
+    const data = res.data || {};
+    const preview = data.preview || {};
+    const items = preview.items || [];
+    const files = res.files || [];
+    return `
+      <div class="hint-box" style="margin-top:16px">
+        <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
+        <div>
+          <div style="font-weight:600">${escapeHtml(res.assistantMessage || '预览已生成')}</div>
+          <div style="margin-top:6px;font-size:13px">原顾问:${escapeHtml(preview.fromUserId || '-')}</div>
+          <div style="font-size:13px">新顾问:${escapeHtml(preview.toUserId || '-')}</div>
+          <div style="font-size:13px">记录数:${formatNumber(items.length)}</div>
+          ${files.length ? `<div style="margin-top:8px">${renderFileLink(files[0], '下载预览文件')}</div>` : ''}
+        </div>
+      </div>
+      ${items.length ? `
+        <div class="table-wrap" style="margin-top:16px">
+          <table>
+            <thead><tr><th>类型</th><th>roomId</th><th>externalUserId</th><th>客户名</th></tr></thead>
+            <tbody>
+              ${items.map(item => `
+                <tr>
+                  <td>${item.action === 'transfer_group_ownership' ? '群交接' : '客户交接'}</td>
+                  <td>${escapeHtml(item.roomId || '-')}</td>
+                  <td>${escapeHtml(item.externalUserId || '-')}</td>
+                  <td>${escapeHtml(item.customerName || '-')}</td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      ` : ''}
+    `;
+  }
+
+  function renderTransferExecuteResult(result) {
+    const summary = result.summary || {};
+    const data = result.data || {};
+    const results = data.results || [];
+    const files = result.files || [];
+    return `
+      <div class="stats-grid" style="margin-top:16px">
+        <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.success)}</div><div class="stat-label">成功</div></div>
+        <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.failed)}</div><div class="stat-label">失败</div></div>
+      </div>
+      ${files.length ? `<div style="margin-top:12px">${renderFileLink(files[0], '下载执行结果')}</div>` : ''}
+      ${results.length ? `
+        <div class="table-wrap" style="margin-top:16px">
+          <table>
+            <thead><tr><th>类型</th><th>roomId</th><th>externalUserId</th><th>状态</th><th>说明</th></tr></thead>
+            <tbody>
+              ${results.map(item => `
+                <tr>
+                  <td>${item.action === 'transfer_group_ownership' ? '群交接' : '客户交接'}</td>
+                  <td>${escapeHtml(item.roomId || '-')}</td>
+                  <td>${escapeHtml(item.externalUserId || '-')}</td>
+                  <td>${item.status === 'SUCCESS' ? '<span class="badge badge-success">成功</span>' : item.status === 'SKIPPED' ? '<span class="badge badge-warning">跳过</span>' : '<span class="badge badge-error">失败</span>'}</td>
+                  <td style="color:var(--text-secondary);font-size:12px">${escapeHtml(item.reason || '-')}</td>
+                </tr>
+              `).join('')}
+            </tbody>
+          </table>
+        </div>
+      ` : ''}
+    `;
+  }
+
+  // ==================== Init ====================
+  async function checkExistingLogin() {
+    try {
+      const res = await api('GET', '/api/status');
+      const login = res.data?.login;
+      if (login?.summary?.online && login?.data?.detail) {
+        const detail = login.data.detail;
+        const guid = detail.guid || detail.userId;
+        const existing = state.accounts.find(a => a.guid === guid || a.userId === detail.userId);
+        if (!existing) {
+          addAccount({
+            guid,
+            userId: detail.userId || '',
+            nickname: detail.nickname || detail.userId || '',
+            corpName: detail.corpName || '',
+            corpId: detail.corpId || '',
+            online: true
+          });
+        } else {
+          existing.online = true;
+          existing.nickname = detail.nickname || existing.nickname;
+          existing.corpName = detail.corpName || existing.corpName;
+          saveAccounts();
+        }
+        if (!state.currentAccountId && state.accounts.length) {
+          state.currentAccountId = state.accounts[0].id;
+          saveAccounts();
+        }
+        updateAccountSwitcher();
+      } else {
+        // 账号不在线:更新账号状态,但不清空已同步记录
+        const acc = currentAccount();
+        const statusCode = login?.data?.statusCode;
+        if (acc) {
+          acc.online = false;
+          saveAccounts();
+          updateAccountSwitcher();
+        }
+        // 如果设备已配置且状态为 0(可免扫码),自动尝试恢复登录
+        if (acc && statusCode === 0 && !state.autoRecoverAttempted) {
+          state.autoRecoverAttempted = true;
+          const recovered = await attemptAutoRecover(true);
+          if (recovered) {
+            toast('账号已自动恢复登录');
+          }
+        }
+      }
+    } catch (err) {
+      console.error('检测已有登录失败', err);
+    }
+  }
+
+  async function init() {
+    updateAccountSwitcher();
+    await checkExistingLogin();
+
+    els.accountBtn.addEventListener('click', e => {
+      e.stopPropagation();
+      els.accountDropdown.classList.toggle('open');
+    });
+
+    document.addEventListener('click', e => {
+      if (!els.accountDropdown.contains(e.target) && e.target !== els.accountBtn) {
+        els.accountDropdown.classList.remove('open');
+      }
+    });
+
+    document.getElementById('btn-add-account').addEventListener('click', () => {
+      state.currentAccountId = null;
+      location.hash = '#groups';
+      renderPage();
+    });
+
+    els.menuBtn.addEventListener('click', () => {
+      els.sidebar.classList.toggle('open');
+    });
+
+    window.addEventListener('hashchange', renderPage);
+
+    document.addEventListener('click', e => {
+      const link = e.target.closest('.nav-item');
+      if (link) els.sidebar.classList.remove('open');
+    });
+
+    renderPage();
+  }
+
+  init();
+})();

+ 73 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html

@@ -0,0 +1,73 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>企微助手 · 客户群管理</title>
+<link rel="stylesheet" href="/dashboard/styles.css">
+</head>
+<body>
+<div class="app" id="app">
+  <aside class="sidebar" id="sidebar">
+    <div class="brand">
+      <div class="brand-icon">
+        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-6h2v6zm-2-8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
+      </div>
+      <div class="brand-text">企微助手</div>
+    </div>
+    <nav class="nav" id="nav">
+      <div class="nav-group">
+        <div class="nav-label">功能</div>
+        <a class="nav-item active" href="#groups" data-page="groups">
+          <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
+          <span>客户群管理</span>
+        </a>
+        <a class="nav-item" href="#customer-ops" data-page="customer-ops">
+          <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
+          <span>客户运营</span>
+        </a>
+        <a class="nav-item" href="#portraits" data-page="portraits">
+          <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4zM9 10c0-1.1.9-2 2-2s2 .9 2 2-.9 2-2 2-2-.9-2-2z"/></svg>
+          <span>画像与标签</span>
+        </a>
+        <a class="nav-item" href="#transfers" data-page="transfers">
+          <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
+          <span>客户交接</span>
+        </a>
+        <a class="nav-item" href="#status" data-page="status">
+          <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
+          <span>账号状态</span>
+        </a>
+      </div>
+    </nav>
+  </aside>
+  <main class="main">
+    <header class="topbar">
+      <button class="menu-btn" id="menu-btn" aria-label="切换菜单">
+        <svg viewBox="0 0 24 24"><path d="M3 18h18v-2H3v2zm0-5h18v-2H3v2zm0-7v2h18V6H3z"/></svg>
+      </button>
+      <h1 class="page-title" id="page-title">客户群管理</h1>
+      <div class="account-switcher" id="account-switcher">
+        <button class="account-btn" id="account-btn">
+          <span class="account-dot offline" id="account-dot"></span>
+          <span class="account-name" id="account-name">未登录</span>
+          <svg viewBox="0 0 24 24" class="account-chevron"><path d="M7 10l5 5 5-5z"/></svg>
+        </button>
+        <div class="account-dropdown" id="account-dropdown">
+          <div class="dropdown-header">已登录账号</div>
+          <div class="account-list" id="account-list"></div>
+          <div class="dropdown-divider"></div>
+          <button class="dropdown-item add-account" id="btn-add-account">
+            <svg viewBox="0 0 24 24"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
+            <span>添加账号</span>
+          </button>
+        </div>
+      </div>
+    </header>
+    <div class="content" id="content"></div>
+  </main>
+  <div class="toast-container" id="toast-container" aria-live="polite"></div>
+</div>
+<script src="/dashboard/app.js"></script>
+</body>
+</html>

+ 435 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -0,0 +1,435 @@
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const { URL } = require('url');
+const { outputsRoot } = require('../core/output-paths');
+const {
+  qiweiSyncExternalGroups,
+  qiweiListExternalGroups,
+  qiweiAnalyzeGroupMembers,
+  qiweiConfirmExternalGroup,
+  qiweiAddExternalGroup,
+  qiweiConfigureGroupKeywords,
+  qiweiRejectExternalGroup,
+  qiweiSyncGroupMessages
+} = require('../tools/qiwei-group-management-run');
+const {
+  qiweiLoginStatus,
+  qiweiLoginStart,
+  qiweiLoginCheck,
+  qiweiLoginVerify
+} = require('../tools/qiwei-login-run');
+const { qiweiSubscriptionStatus } = require('../tools/qiwei-subscription-run');
+const {
+  qiweiBatchAddFriends,
+  qiweiCheckFriendStatus,
+  qiweiGetCustomerProfile,
+  qiweiAutoCreateGroup
+} = require('../tools/qiwei-customer-ops-run');
+const {
+  qiweiUpdateCustomerPortrait,
+  qiweiSaveCustomerPortrait,
+  qiweiBatchUpdateCustomerPortrait,
+  qiweiExportCustomerPortraits,
+  qiweiListCustomerTags,
+  qiweiListAllTags,
+  qiweiAddCustomerTags,
+  qiweiRemoveCustomerTags,
+  qiweiSyncPersonalLabels,
+  qiweiCreatePersonalLabel,
+  qiweiUpdatePersonalLabel,
+  qiweiDeletePersonalLabel,
+  qiweiApplyPersonalLabels
+} = require('../tools/qiwei-portrait-tags-run');
+const {
+  qiweiPreviewTransferPackage,
+  qiweiExecuteTransfer
+} = require('../tools/qiwei-customer-transfer-run');
+
+const DASHBOARD_PORT = process.env.QIWEI_DASHBOARD_PORT || 4320;
+const STATIC_DIR = path.join(__dirname);
+const TMP_DIR = path.join(outputsRoot(), 'tmp');
+
+const jobs = new Map();
+
+function ensureTmpDir() {
+  fs.mkdirSync(TMP_DIR, { recursive: true });
+}
+
+function readBody(req) {
+  return new Promise((resolve, reject) => {
+    const chunks = [];
+    req.on('data', chunk => chunks.push(chunk));
+    req.on('end', () => {
+      try {
+        resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
+      } catch {
+        resolve({});
+      }
+    });
+    req.on('error', reject);
+  });
+}
+
+function json(res, status, body) {
+  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
+  res.end(JSON.stringify(body));
+}
+
+function createJob(runFn) {
+  const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+  const job = { id, status: 'running', progress: 0, result: null, error: null, startedAt: Date.now() };
+  jobs.set(id, job);
+  runFn()
+    .then(r => { job.status = 'done'; job.progress = 100; job.result = r; })
+    .catch(e => { job.status = 'error'; job.error = e.message || String(e); });
+  return id;
+}
+
+function getJob(id) {
+  const job = jobs.get(id);
+  if (!job) return null;
+  if (job.status === 'running') {
+    const elapsed = Date.now() - job.startedAt;
+    job.progress = Math.min(95, Math.floor((elapsed / 30000) * 100));
+  }
+  return job;
+}
+
+function serveStatic(req, res, filePath, contentType) {
+  fs.readFile(filePath, (err, data) => {
+    if (err) {
+      res.writeHead(404);
+      res.end('not found');
+      return;
+    }
+    res.writeHead(200, { 'Content-Type': contentType });
+    res.end(data);
+  });
+}
+
+async function handleUpload(req) {
+  const body = await readBody(req);
+  const base64 = String(body.data || '');
+  const name = String(body.name || `upload-${Date.now()}`).replace(/[\\/]/g, '_');
+  if (!base64) throw new Error('缺少文件数据');
+  ensureTmpDir();
+  const buffer = Buffer.from(base64, 'base64');
+  const filePath = path.join(TMP_DIR, name);
+  fs.writeFileSync(filePath, buffer);
+  return { path: filePath };
+}
+
+function handleOutputsDownload(req, res, query) {
+  const requested = String(query.path || '');
+  if (!requested) {
+    json(res, 400, { status: 'error', message: '缺少 path 参数' });
+    return;
+  }
+  const resolved = path.resolve(requested);
+  const root = path.resolve(outputsRoot());
+  if (!resolved.startsWith(root)) {
+    json(res, 403, { status: 'error', message: '禁止访问 outputs 目录之外的文件' });
+    return;
+  }
+  if (!fs.existsSync(resolved)) {
+    json(res, 404, { status: 'error', message: '文件不存在' });
+    return;
+  }
+  const data = fs.readFileSync(resolved);
+  const ext = path.extname(resolved).toLowerCase();
+  const contentType = {
+    '.json': 'application/json',
+    '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+    '.xls': 'application/vnd.ms-excel',
+    '.png': 'image/png',
+    '.jpg': 'image/jpeg',
+    '.jpeg': 'image/jpeg',
+    '.mp3': 'audio/mpeg',
+    '.mp4': 'video/mp4'
+  }[ext] || 'application/octet-stream';
+  res.writeHead(200, {
+    'Content-Type': contentType,
+    'Content-Disposition': `attachment; filename="${path.basename(resolved)}"`
+  });
+  res.end(data);
+}
+
+function getContentType(filePath) {
+  const ext = path.extname(filePath).toLowerCase();
+  const map = {
+    '.html': 'text/html; charset=utf-8',
+    '.css': 'text/css; charset=utf-8',
+    '.js': 'application/javascript; charset=utf-8',
+    '.png': 'image/png',
+    '.jpg': 'image/jpeg',
+    '.jpeg': 'image/jpeg',
+    '.svg': 'image/svg+xml'
+  };
+  return map[ext] || 'application/octet-stream';
+}
+
+async function combinedStatus() {
+  const login = await qiweiLoginStatus({});
+  const subscription = await qiweiSubscriptionStatus({});
+  return {
+    status: 'ok',
+    summary: {
+      authConfigured: login.status !== 'needs_auth',
+      online: !!login.summary?.online,
+      subscribed: !!subscription.summary?.subscribed
+    },
+    data: { login, subscription }
+  };
+}
+
+async function handleRequest(req, res) {
+  const url = new URL(req.url, 'http://localhost');
+  const pathname = url.pathname;
+
+  try {
+    if (pathname === '/' || pathname === '/index.html') {
+      serveStatic(req, res, path.join(STATIC_DIR, 'index.html'), 'text/html; charset=utf-8');
+      return;
+    }
+    if (pathname.startsWith('/dashboard/')) {
+      const fileName = pathname.slice('/dashboard/'.length).replace(/\.{2,}/g, '');
+      const filePath = path.join(STATIC_DIR, fileName);
+      if (!filePath.startsWith(STATIC_DIR)) {
+        json(res, 403, { status: 'error', message: '禁止访问' });
+        return;
+      }
+      serveStatic(req, res, filePath, getContentType(filePath));
+      return;
+    }
+    if (pathname === '/api/health') {
+      json(res, 200, { status: 'ok' });
+      return;
+    }
+    if (pathname === '/api/status' && req.method === 'GET') {
+      json(res, 200, await combinedStatus());
+      return;
+    }
+    if (pathname === '/api/login/start' && req.method === 'POST') {
+      const body = await readBody(req);
+      const result = await qiweiLoginStart({
+        flowUi: false,
+        openBrowser: false,
+        ...body
+      });
+      json(res, 200, result);
+      return;
+    }
+    if (pathname === '/api/login/check' && req.method === 'POST') {
+      const body = await readBody(req);
+      const result = await qiweiLoginCheck(body);
+      json(res, 200, result);
+      return;
+    }
+    if (pathname === '/api/login/verify' && req.method === 'POST') {
+      const body = await readBody(req);
+      const result = await qiweiLoginVerify(body);
+      json(res, 200, result);
+      return;
+    }
+    if (pathname === '/api/groups/sync' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiSyncExternalGroups(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/groups/list' && req.method === 'GET') {
+      const params = {};
+      if (url.searchParams.has('keyword')) params.keyword = url.searchParams.get('keyword');
+      if (url.searchParams.has('status')) params.status = url.searchParams.get('status');
+      if (url.searchParams.has('source')) params.source = url.searchParams.get('source');
+      if (url.searchParams.has('includeRejected')) params.includeRejected = url.searchParams.get('includeRejected') === 'true';
+      json(res, 200, await qiweiListExternalGroups(params));
+      return;
+    }
+    if (pathname === '/api/groups/analyze' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiAnalyzeGroupMembers(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/groups/confirm' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiConfirmExternalGroup(body));
+      return;
+    }
+    if (pathname === '/api/groups/add' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiAddExternalGroup(body));
+      return;
+    }
+    if (pathname === '/api/groups/reject' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiRejectExternalGroup(body));
+      return;
+    }
+    if (pathname === '/api/groups/keywords' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiConfigureGroupKeywords(body));
+      return;
+    }
+    if (pathname === '/api/groups/messages/sync' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiSyncGroupMessages(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+
+    // Customer Operations
+    if (pathname === '/api/customer-ops/batch-add-friends' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiBatchAddFriends(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/customer-ops/check-friend-status' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiCheckFriendStatus(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/customer-ops/customer-profile' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiGetCustomerProfile(body));
+      return;
+    }
+    if (pathname === '/api/customer-ops/auto-create-group' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiAutoCreateGroup(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+
+    // Portraits & Tags
+    if (pathname === '/api/portraits/update' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiUpdateCustomerPortrait(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/portraits/save' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiSaveCustomerPortrait(body));
+      return;
+    }
+    if (pathname === '/api/portraits/batch' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiBatchUpdateCustomerPortrait(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/portraits/export' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiExportCustomerPortraits(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+    if (pathname === '/api/tags/list' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiListCustomerTags(body));
+      return;
+    }
+    if (pathname === '/api/tags/all' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiListAllTags(body));
+      return;
+    }
+    if (pathname === '/api/tags/add' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiAddCustomerTags(body));
+      return;
+    }
+    if (pathname === '/api/tags/remove' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiRemoveCustomerTags(body));
+      return;
+    }
+    if (pathname === '/api/personal-labels/sync' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiSyncPersonalLabels(body));
+      return;
+    }
+    if (pathname === '/api/personal-labels/create' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiCreatePersonalLabel(body));
+      return;
+    }
+    if (pathname === '/api/personal-labels/update' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiUpdatePersonalLabel(body));
+      return;
+    }
+    if (pathname === '/api/personal-labels/delete' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiDeletePersonalLabel(body));
+      return;
+    }
+    if (pathname === '/api/personal-labels/apply' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiApplyPersonalLabels(body));
+      return;
+    }
+
+    // Customer Transfers
+    if (pathname === '/api/transfers/preview' && req.method === 'POST') {
+      const body = await readBody(req);
+      json(res, 200, await qiweiPreviewTransferPackage(body));
+      return;
+    }
+    if (pathname === '/api/transfers/execute' && req.method === 'POST') {
+      const body = await readBody(req);
+      const id = createJob(() => qiweiExecuteTransfer(body));
+      json(res, 200, { status: 'ok', data: { jobId: id } });
+      return;
+    }
+
+    if (pathname === '/api/upload' && req.method === 'POST') {
+      const result = await handleUpload(req);
+      json(res, 200, { status: 'ok', data: result });
+      return;
+    }
+    if (pathname === '/api/outputs' && req.method === 'GET') {
+      handleOutputsDownload(req, res, url.searchParams);
+      return;
+    }
+    if (pathname.startsWith('/api/jobs/') && req.method === 'GET') {
+      const id = pathname.slice('/api/jobs/'.length);
+      const job = getJob(id);
+      if (!job) {
+        json(res, 404, { status: 'error', message: '任务不存在' });
+        return;
+      }
+      json(res, 200, { status: 'ok', data: job });
+      return;
+    }
+
+    json(res, 404, { status: 'error', message: 'not found' });
+  } catch (error) {
+    json(res, 500, { status: 'error', message: error.message || String(error) });
+  }
+}
+
+function startServer(port = DASHBOARD_PORT) {
+  const server = http.createServer(handleRequest);
+  return new Promise((resolve, reject) => {
+    server.once('error', reject);
+    server.listen(port, '127.0.0.1', () => {
+      console.log(`Qiwei Dashboard 已启动:http://127.0.0.1:${port}/`);
+      resolve({ server, url: `http://127.0.0.1:${port}/`, port });
+    });
+  });
+}
+
+if (require.main === module) {
+  startServer().catch(error => {
+    console.error('Dashboard 启动失败:', error);
+    process.exit(1);
+  });
+}
+
+module.exports = { startServer };

+ 899 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -0,0 +1,899 @@
+:root {
+  --brand: #fa8c16;
+  --brand-dark: #d46b08;
+  --brand-bg: #fff7e6;
+  --border: #e8e8e8;
+  --page-bg: #f0f2f5;
+  --panel-bg: #ffffff;
+  --sidebar-bg: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
+  --text-primary: #1f1f1f;
+  --text-secondary: #5a5a5a;
+  --text-muted: #8c8c8c;
+  --success: #52c41a;
+  --success-bg: #f6ffed;
+  --success-border: #b7eb8f;
+  --error: #ff4d4f;
+  --error-bg: #fff2f0;
+  --error-border: #ffccc7;
+  --warning: #faad14;
+  --warning-bg: #fffbe6;
+  --info: #1890ff;
+  --info-bg: #e6f7ff;
+  --radius-sm: 10px;
+  --radius-md: 14px;
+  --radius-lg: 18px;
+  --shadow: 0 4px 20px rgba(0,0,0,0.06);
+  --shadow-hover: 0 8px 30px rgba(0,0,0,0.1);
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
+  background: var(--page-bg);
+  color: var(--text-primary);
+}
+
+.app {
+  display: flex;
+  min-height: 100vh;
+}
+
+.sidebar {
+  width: 220px;
+  background: var(--sidebar-bg);
+  display: flex;
+  flex-direction: column;
+  position: fixed;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  z-index: 100;
+  transition: transform 0.25s ease;
+  color: #fff;
+}
+
+.sidebar.hidden {
+  transform: translateX(-100%);
+}
+
+.brand {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 22px 18px;
+  border-bottom: 1px solid rgba(255,255,255,0.08);
+}
+
+.brand-icon {
+  width: 36px;
+  height: 36px;
+  background: rgba(250, 140, 22, 0.18);
+  color: var(--brand);
+  border-radius: var(--radius-sm);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.brand-icon svg { width: 22px; height: 22px; }
+
+.brand-text {
+  font-size: 17px;
+  font-weight: 700;
+  letter-spacing: 0.5px;
+}
+
+.nav {
+  padding: 16px 12px;
+  overflow-y: auto;
+  flex: 1;
+}
+
+.nav-group { margin-bottom: 24px; }
+
+.nav-label {
+  font-size: 11px;
+  color: rgba(255,255,255,0.45);
+  text-transform: uppercase;
+  letter-spacing: 1px;
+  padding: 0 12px;
+  margin-bottom: 8px;
+}
+
+.nav-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  padding: 11px 12px;
+  border-radius: var(--radius-sm);
+  color: rgba(255,255,255,0.75);
+  text-decoration: none;
+  font-size: 14px;
+  transition: all 0.2s;
+  margin-bottom: 4px;
+}
+
+.nav-item svg {
+  width: 20px;
+  height: 20px;
+  fill: currentColor;
+  flex-shrink: 0;
+}
+
+.nav-item:hover {
+  color: #fff;
+  background: rgba(255,255,255,0.08);
+}
+
+.nav-item.active {
+  color: #fff;
+  background: var(--brand);
+  box-shadow: 0 4px 14px rgba(250, 140, 22, 0.35);
+}
+
+.main {
+  flex: 1;
+  margin-left: 220px;
+  display: flex;
+  flex-direction: column;
+  min-height: 100vh;
+}
+
+.topbar {
+  height: 68px;
+  background: var(--panel-bg);
+  border-bottom: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  gap: 16px;
+  padding: 0 32px;
+  position: sticky;
+  top: 0;
+  z-index: 50;
+  box-shadow: var(--shadow);
+}
+
+.menu-btn {
+  display: none;
+  background: none;
+  border: none;
+  padding: 6px;
+  cursor: pointer;
+  color: var(--text-secondary);
+  border-radius: var(--radius-sm);
+}
+
+.menu-btn:hover { background: var(--brand-bg); color: var(--brand); }
+.menu-btn svg { width: 22px; height: 22px; fill: currentColor; }
+
+.page-title {
+  font-size: 20px;
+  font-weight: 700;
+  margin: 0;
+  flex: 1;
+}
+
+.account-switcher {
+  position: relative;
+}
+
+.account-btn {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  background: var(--page-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 8px 12px;
+  cursor: pointer;
+  font-size: 14px;
+  color: var(--text-primary);
+  transition: all 0.15s;
+}
+
+.account-btn:hover { border-color: var(--brand); background: var(--brand-bg); }
+
+.account-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+}
+
+.account-dot.online { background: var(--success); box-shadow: 0 0 0 3px rgba(82,196,26,0.15); }
+.account-dot.offline { background: var(--text-muted); }
+
+.account-chevron { width: 16px; height: 16px; fill: var(--text-muted); }
+
+.account-dropdown {
+  position: absolute;
+  top: calc(100% + 8px);
+  right: 0;
+  min-width: 220px;
+  background: var(--panel-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  box-shadow: var(--shadow-hover);
+  padding: 8px 0;
+  display: none;
+  z-index: 60;
+  animation: scaleIn 0.2s ease;
+}
+
+.account-dropdown.open { display: block; }
+
+.dropdown-header {
+  padding: 8px 16px;
+  font-size: 12px;
+  color: var(--text-muted);
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.account-list { max-height: 240px; overflow-y: auto; }
+
+.account-option {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  padding: 10px 16px;
+  border: none;
+  background: none;
+  cursor: pointer;
+  text-align: left;
+  font-size: 14px;
+  color: var(--text-primary);
+  transition: background 0.15s;
+}
+
+.account-option:hover { background: var(--brand-bg); }
+
+.account-option.active { color: var(--brand); font-weight: 600; }
+
+.account-option .account-info {
+  display: flex;
+  flex-direction: column;
+  flex: 1;
+  min-width: 0;
+}
+
+.account-option .account-info strong {
+  font-weight: 600;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.account-option .account-info span {
+  font-size: 12px;
+  color: var(--text-muted);
+}
+
+.dropdown-divider {
+  height: 1px;
+  background: var(--border);
+  margin: 8px 0;
+}
+
+.dropdown-item {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+  padding: 10px 16px;
+  border: none;
+  background: none;
+  cursor: pointer;
+  font-size: 14px;
+  color: var(--text-primary);
+  transition: background 0.15s;
+}
+
+.dropdown-item:hover { background: var(--brand-bg); }
+
+.dropdown-item svg { width: 18px; height: 18px; fill: currentColor; }
+
+.content {
+  flex: 1;
+  padding: 28px 32px 48px;
+  max-width: 1200px;
+  width: 100%;
+  margin: 0 auto;
+}
+
+.page {
+  animation: fadeInUp 0.4s ease both;
+}
+
+@keyframes fadeInUp {
+  from { opacity: 0; transform: translateY(16px); }
+  to { opacity: 1; transform: translateY(0); }
+}
+
+.section-title {
+  font-size: 14px;
+  color: var(--text-secondary);
+  margin: 0 0 14px;
+  font-weight: 600;
+}
+
+.card {
+  background: var(--panel-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  padding: 24px;
+  margin-bottom: 24px;
+  box-shadow: var(--shadow);
+  transition: box-shadow 0.2s, transform 0.2s;
+  animation: fadeInUp 0.4s ease both;
+}
+
+.card:hover { box-shadow: var(--shadow-hover); }
+
+.card-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 20px;
+  flex-wrap: wrap;
+  gap: 12px;
+}
+
+.card-title {
+  font-size: 16px;
+  font-weight: 700;
+  margin: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.card-title svg { width: 20px; height: 20px; fill: var(--brand); }
+
+.card-subtitle {
+  font-size: 13px;
+  color: var(--text-muted);
+  margin-top: 4px;
+}
+
+.form-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 20px;
+  margin-bottom: 20px;
+}
+
+.form-group {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  flex: 1;
+  min-width: 180px;
+}
+
+.form-group label {
+  font-size: 13px;
+  color: var(--text-secondary);
+  font-weight: 500;
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+  padding: 10px 12px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: 14px;
+  outline: none;
+  transition: border-color 0.15s, box-shadow 0.15s;
+  font-family: inherit;
+  background: var(--panel-bg);
+}
+
+.form-group input:focus,
+.form-group select:focus,
+.form-group textarea:focus {
+  border-color: var(--brand);
+  box-shadow: 0 0 0 3px rgba(250, 140, 22, 0.1);
+}
+
+.form-group input[type="checkbox"] {
+  width: auto;
+  margin: 0;
+}
+
+.checkbox-row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 14px;
+  color: var(--text-secondary);
+}
+
+.checkbox-row input { width: 16px; height: 16px; accent-color: var(--brand); }
+
+.btn {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  border: none;
+  background: var(--brand);
+  color: #fff;
+  font-size: 14px;
+  font-weight: 600;
+  padding: 10px 20px;
+  border-radius: var(--radius-sm);
+  cursor: pointer;
+  transition: all 0.15s;
+  font-family: inherit;
+  box-shadow: 0 4px 12px rgba(250, 140, 22, 0.25);
+}
+
+.btn:hover { background: var(--brand-dark); transform: translateY(-1px); }
+.btn:active { transform: translateY(0); }
+.btn:disabled { opacity: 0.6; cursor: not-allowed; transform: none; }
+
+.btn-sm { padding: 7px 14px; font-size: 13px; }
+
+.btn-secondary {
+  background: var(--panel-bg);
+  color: var(--text-secondary);
+  border: 1px solid var(--border);
+  box-shadow: none;
+}
+
+.btn-secondary:hover { border-color: var(--brand); color: var(--brand); background: var(--brand-bg); }
+
+.btn-danger { background: var(--error); box-shadow: 0 4px 12px rgba(255,77,79,0.25); }
+
+.btn svg { width: 16px; height: 16px; fill: currentColor; }
+
+.spinner {
+  width: 16px;
+  height: 16px;
+  border: 2px solid rgba(255,255,255,0.4);
+  border-top-color: #fff;
+  border-radius: 50%;
+  animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin { to { transform: rotate(360deg); } }
+
+.stats-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+  gap: 16px;
+  margin-bottom: 24px;
+}
+
+.stat-card {
+  background: var(--panel-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 20px 16px;
+  text-align: center;
+  transition: all 0.2s;
+  box-shadow: var(--shadow);
+}
+
+.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover); }
+
+.stat-value {
+  font-size: 32px;
+  font-weight: 800;
+  color: var(--brand);
+  line-height: 1.2;
+}
+
+.stat-label {
+  font-size: 13px;
+  color: var(--text-muted);
+  margin-top: 6px;
+}
+
+.table-wrap {
+  overflow-x: auto;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+}
+
+table {
+  width: 100%;
+  border-collapse: collapse;
+  font-size: 13px;
+}
+
+th, td {
+  padding: 14px 16px;
+  text-align: left;
+  border-bottom: 1px solid var(--border);
+}
+
+th {
+  background: var(--page-bg);
+  color: var(--text-secondary);
+  font-weight: 600;
+  font-size: 12px;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+tr:last-child td { border-bottom: none; }
+tr:hover td { background: var(--brand-bg); }
+
+.badge {
+  display: inline-flex;
+  align-items: center;
+  padding: 4px 10px;
+  border-radius: 999px;
+  font-size: 12px;
+  font-weight: 600;
+}
+
+.badge-success { background: var(--success-bg); color: var(--success); border: 1px solid var(--success-border); }
+.badge-error { background: var(--error-bg); color: var(--error); border: 1px solid var(--error-border); }
+.badge-warning { background: var(--warning-bg); color: var(--warning); border: 1px solid #ffe58f; }
+.badge-info { background: var(--info-bg); color: var(--info); border: 1px solid #91d5ff; }
+.badge-default { background: var(--page-bg); color: var(--text-muted); border: 1px solid var(--border); }
+
+.confidence-bar {
+  width: 80px;
+  height: 6px;
+  background: var(--border);
+  border-radius: 3px;
+  overflow: hidden;
+}
+
+.confidence-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, var(--brand) 0%, #ffc53d 100%);
+  border-radius: 3px;
+  transition: width 0.5s ease;
+}
+
+.filter-bar {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+  align-items: flex-end;
+}
+
+.filter-bar input,
+.filter-bar select {
+  padding: 8px 12px;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  font-size: 13px;
+  outline: none;
+  background: var(--panel-bg);
+}
+
+.filter-bar input:focus,
+.filter-bar select:focus { border-color: var(--brand); }
+
+.empty-state {
+  text-align: center;
+  padding: 56px 20px;
+  color: var(--text-muted);
+}
+
+.empty-state svg {
+  width: 56px;
+  height: 56px;
+  fill: var(--border);
+  margin-bottom: 16px;
+}
+
+.empty-state-title { font-size: 15px; color: var(--text-secondary); margin-bottom: 6px; }
+.empty-state-desc { font-size: 13px; }
+
+.progress-wrap { margin: 18px 0; }
+
+.progress-bar {
+  height: 8px;
+  background: var(--border);
+  border-radius: 4px;
+  overflow: hidden;
+}
+
+.progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, var(--brand) 0%, #ffc53d 100%);
+  border-radius: 4px;
+  background-image: linear-gradient(45deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent);
+  background-size: 20px 20px;
+  animation: progressStripes 1s linear infinite;
+  transition: width 0.3s ease;
+}
+
+@keyframes progressStripes {
+  from { background-position: 0 0; }
+  to { background-position: 20px 0; }
+}
+
+.job-log {
+  background: #fafafa;
+  border: 1px solid var(--border);
+  border-radius: var(--radius-sm);
+  padding: 14px;
+  font-size: 13px;
+  color: var(--text-secondary);
+  max-height: 180px;
+  overflow-y: auto;
+  white-space: pre-wrap;
+  margin-top: 14px;
+}
+
+.toast-container {
+  position: fixed;
+  top: 24px;
+  right: 24px;
+  z-index: 200;
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.toast {
+  min-width: 280px;
+  max-width: 400px;
+  padding: 14px 18px;
+  border-radius: var(--radius-md);
+  font-size: 14px;
+  box-shadow: 0 8px 24px rgba(0,0,0,0.12);
+  animation: slideIn 0.3s ease;
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+}
+
+@keyframes slideIn {
+  from { opacity: 0; transform: translateX(100%); }
+  to { opacity: 1; transform: translateX(0); }
+}
+
+.toast.success { background: var(--success-bg); color: var(--success); border: 1px solid var(--success-border); }
+.toast.error { background: var(--error-bg); color: var(--error); border: 1px solid var(--error-border); }
+.toast.warning { background: var(--warning-bg); color: #d48806; border: 1px solid #ffe58f; }
+
+.modal-overlay {
+  position: fixed;
+  inset: 0;
+  background: rgba(0,0,0,0.45);
+  backdrop-filter: blur(2px);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 150;
+  animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
+
+.modal {
+  background: var(--panel-bg);
+  border-radius: var(--radius-lg);
+  padding: 28px;
+  width: 90%;
+  max-width: 440px;
+  animation: scaleIn 0.25s ease;
+  box-shadow: var(--shadow-hover);
+}
+
+@keyframes scaleIn {
+  from { opacity: 0; transform: scale(0.96); }
+  to { opacity: 1; transform: scale(1); }
+}
+
+.modal-title {
+  font-size: 18px;
+  font-weight: 700;
+  margin: 0 0 18px;
+}
+
+.modal-actions {
+  display: flex;
+  justify-content: flex-end;
+  gap: 12px;
+  margin-top: 24px;
+}
+
+.tag-list {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  margin-top: 10px;
+}
+
+.tag {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  padding: 5px 12px;
+  background: var(--brand-bg);
+  color: var(--brand-dark);
+  border-radius: 999px;
+  font-size: 13px;
+  font-weight: 500;
+}
+
+.tag button {
+  background: none;
+  border: none;
+  color: inherit;
+  cursor: pointer;
+  font-size: 14px;
+  line-height: 1;
+  padding: 0;
+}
+
+.skeleton {
+  background: linear-gradient(90deg, var(--border) 25%, #f5f5f5 50%, var(--border) 75%);
+  background-size: 200% 100%;
+  animation: shimmer 1.2s infinite;
+  border-radius: var(--radius-sm);
+}
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+.status-dot {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  display: inline-block;
+  margin-right: 6px;
+}
+
+.status-dot.online { background: var(--success); box-shadow: 0 0 0 3px rgba(82,196,26,0.15); }
+.status-dot.offline { background: var(--text-muted); }
+
+.login-page {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: calc(100vh - 68px);
+  padding: 40px 20px;
+}
+
+.login-card {
+  background: var(--panel-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-lg);
+  padding: 40px;
+  width: 100%;
+  max-width: 420px;
+  text-align: center;
+  box-shadow: var(--shadow-hover);
+}
+
+.login-card h2 {
+  margin: 0 0 10px;
+  font-size: 22px;
+}
+
+.login-card p {
+  color: var(--text-muted);
+  font-size: 14px;
+  margin-bottom: 28px;
+}
+
+.qr-wrap {
+  background: var(--page-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 20px;
+  margin-bottom: 20px;
+}
+
+.qr-wrap img {
+  width: 220px;
+  height: 220px;
+  border-radius: var(--radius-sm);
+  background: #fff;
+  padding: 8px;
+}
+
+.qr-status {
+  font-size: 14px;
+  color: var(--text-secondary);
+  margin-top: 14px;
+}
+
+.status-cards {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+  gap: 20px;
+  margin-bottom: 24px;
+}
+
+.status-card {
+  background: var(--panel-bg);
+  border: 1px solid var(--border);
+  border-radius: var(--radius-md);
+  padding: 22px;
+  display: flex;
+  align-items: flex-start;
+  gap: 16px;
+  box-shadow: var(--shadow);
+  transition: all 0.2s;
+}
+
+.status-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-hover); }
+
+.status-icon {
+  width: 44px;
+  height: 44px;
+  border-radius: var(--radius-sm);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+}
+
+.status-icon.green { background: var(--success-bg); color: var(--success); }
+.status-icon.orange { background: var(--warning-bg); color: var(--warning); }
+.status-icon.red { background: var(--error-bg); color: var(--error); }
+.status-icon.blue { background: var(--info-bg); color: var(--info); }
+.status-icon svg { width: 24px; height: 24px; fill: currentColor; }
+
+.status-content { flex: 1; }
+
+.status-content h3 {
+  margin: 0 0 4px;
+  font-size: 15px;
+}
+
+.status-content p {
+  margin: 0;
+  font-size: 13px;
+  color: var(--text-muted);
+}
+
+.status-content .status-detail {
+  margin-top: 10px;
+  padding-top: 10px;
+  border-top: 1px solid var(--border);
+  font-size: 13px;
+  color: var(--text-secondary);
+}
+
+.hint-box {
+  background: var(--brand-bg);
+  border: 1px solid #ffd591;
+  border-radius: var(--radius-sm);
+  padding: 14px 16px;
+  font-size: 13px;
+  color: var(--brand-dark);
+  margin-bottom: 20px;
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+}
+
+.hint-box svg { width: 18px; height: 18px; fill: currentColor; flex-shrink: 0; margin-top: 1px; }
+
+@media (max-width: 768px) {
+  .sidebar { transform: translateX(-100%); }
+  .sidebar.open { transform: translateX(0); }
+  .main { margin-left: 0; }
+  .menu-btn { display: block; }
+  .content { padding: 20px 16px 40px; }
+  .stats-grid { grid-template-columns: repeat(2, 1fr); }
+  .account-name { display: none; }
+  .topbar { padding: 0 16px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  *, *::before, *::after {
+    animation-duration: 0.01ms !important;
+    transition-duration: 0.01ms !important;
+  }
+}

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

@@ -17,7 +17,7 @@ function sanitizePayload(value) {
     const output = {};
     for (const [key, item] of Object.entries(value)) {
       if (
-        /^(tokenId|guid|provider(?:Name|Host)?|vendor(?:Name|Host)?|upstream(?:Name|Host)?|source|supportUrl|docUrl|docsUrl|consoleUrl|apiHost|host|domain)$/i.test(
+        /^(tokenId|provider(?:Name|Host)?|vendor(?:Name|Host)?|upstream(?:Name|Host)?|source|supportUrl|docUrl|docsUrl|consoleUrl|apiHost|host|domain)$/i.test(
           key
         )
       ) {

+ 783 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/server.js

@@ -20,6 +20,65 @@ const {
   qiweiOfficialHelp,
   qiweiOfficialCall
 } = require('./tools/wecom-official-cli-run');
+const {
+  qiweiBatchAddFriends,
+  qiweiAutoCreateGroup,
+  qiweiCheckFriendStatus,
+  qiweiGetCustomerProfile
+} = require('./tools/qiwei-customer-ops-run');
+const {
+  qiweiSyncExternalGroups,
+  qiweiListExternalGroups,
+  qiweiAnalyzeGroupMembers,
+  qiweiConfirmExternalGroup,
+  qiweiAddExternalGroup,
+  qiweiConfigureGroupKeywords,
+  qiweiRejectExternalGroup,
+  qiweiSyncGroupMessages
+} = require('./tools/qiwei-group-management-run');
+const {
+  qiweiPrepareCustomerPortrait,
+  qiweiUpdateCustomerPortrait,
+  qiweiSaveCustomerPortrait,
+  qiweiBatchUpdateCustomerPortrait,
+  qiweiBatchSaveCustomerPortrait,
+  qiweiExportCustomerPortraits,
+  qiweiAddCustomerTags,
+  qiweiRemoveCustomerTags,
+  qiweiListCustomerTags,
+  qiweiListAllTags,
+  qiweiSyncPersonalLabels,
+  qiweiCreatePersonalLabel,
+  qiweiUpdatePersonalLabel,
+  qiweiDeletePersonalLabel,
+  qiweiApplyPersonalLabels
+} = require('./tools/qiwei-portrait-tags-run');
+const {
+  qiweiDistillBroker,
+  qiweiPrepareBrokerPlaybook,
+  qiweiSaveBrokerPlaybook,
+  qiweiBatchDistillBroker,
+  qiweiBatchSaveBrokerPlaybook,
+  qiweiExportBrokerPlaybooks,
+  qiweiGetBrokerPlaybook
+} = require('./tools/qiwei-broker-playbook-run');
+const {
+  qiweiPreviewTransferPackage,
+  qiweiExecuteTransfer
+} = require('./tools/qiwei-customer-transfer-run');
+const { qiweiTranscribeVoice } = require('./tools/qiwei-voice-run');
+const {
+  qiweiWebhookStatus,
+  qiweiWebhookDiscover,
+  qiweiWebhookServerStart,
+  qiweiWebhookServerStop,
+  qiweiWebhookAutoSetup,
+  qiweiWebhookSetup,
+  qiweiRelayConfig,
+  qiweiRelaySaveConfig,
+  qiweiRelayRegister,
+  qiweiRelayConnect
+} = require('./tools/qiwei-webhook-relay-run');
 
 function asToolResult(result) {
   const normalized = result && typeof result === 'object' ? result : { status: 'ok', assistantMessage: String(result) };
@@ -264,6 +323,730 @@ function createServer() {
     wrap(qiweiOfficialCall)
   );
 
+  server.registerTool(
+    'qiwei_batch_add_friends',
+    {
+      title: '批量搜索并添加企微好友',
+      description: [
+        '迁移自 Qiwei 项目的批量加好友能力。按手机号搜索联系人,依据 qiwei-endpoints.json 中的 /contact/searchContact、/contact/addSearchWxContact、/contact/addSearchWxWorkContact 调用 Fmode 网关。',
+        '不修改 qiwei_login_* 和 qiwei_api_* 既有流程;需要已登录设备 guid。支持 customers、phones,或安装 xlsx 后传 filePath 读取 Excel。'
+      ].join(' '),
+      inputSchema: {
+        guid: z.string().optional(),
+        customers: z.array(z.object({
+          phone: z.string(),
+          name: z.string().optional(),
+          greeting: z.string().optional(),
+          verifyText: z.string().optional(),
+          groupName: z.string().optional(),
+          supportMemberIds: z.array(z.string()).optional()
+        })).optional(),
+        phones: z.array(z.string()).optional(),
+        filePath: z.string().optional(),
+        greeting: z.string().optional(),
+        defaultGreeting: z.string().optional(),
+        rateLimitPerMinute: z.number().int().min(1).max(60).optional(),
+        maxAttempts: z.number().int().min(1).max(5).optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiBatchAddFriends)
+  );
+
+  server.registerTool(
+    'qiwei_auto_create_group',
+    {
+      title: '自动创建企微服务群',
+      description: [
+        '迁移自 Qiwei 项目的自动建群能力。基于 qiwei-endpoints.json 中的 /room/createRoom、/room/modifyRoomName、/room/inviteRoomMember、/msg/sendText 执行建群、改群名、邀请协作成员和发送欢迎语。',
+        '不修改目标包已有登录、订阅、通用 API 调用流程;需要已登录设备 guid。'
+      ].join(' '),
+      inputSchema: {
+        guid: z.string().optional(),
+        customerUserId: z.string().optional(),
+        externalUserId: z.string().optional(),
+        memberList: z.array(z.string()).optional(),
+        supportMemberIds: z.array(z.string()).optional(),
+        groupName: z.string().optional(),
+        welcomeText: z.string().optional(),
+        isOuterRoom: z.number().int().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiAutoCreateGroup)
+  );
+
+  server.registerTool(
+    'qiwei_check_friend_status',
+    {
+      title: '检查好友申请状态',
+      description: '按手机号批量检查联系人搜索状态,判断对方是否已是好友、已被其他人添加、未添加或未找到。需要已登录设备 guid。',
+      inputSchema: {
+        guid: z.string().optional(),
+        customers: z.array(z.object({
+          phone: z.string(),
+          name: z.string().optional()
+        })).optional(),
+        phones: z.array(z.string()).optional(),
+        externalUserIds: z.array(z.string()).optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiCheckFriendStatus)
+  );
+
+  server.registerTool(
+    'qiwei_get_customer_profile',
+    {
+      title: '查询客户档案',
+      description: '按手机号或 externalUserId 查询客户档案:搜索联系人、拉取外部联系人列表、拉取群列表,并读取本地已保存的画像文件。需要已登录设备 guid。',
+      inputSchema: {
+        guid: z.string().optional(),
+        phone: z.string().optional(),
+        externalUserId: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiGetCustomerProfile)
+  );
+
+  server.registerTool(
+    'qiwei_sync_external_groups',
+    {
+      title: '同步外部群列表',
+      description: '从多个来源扫描群列表:当前账号创建的群(self)、最近会话中的群(session)、消息流反推的群(messages)或全部(all)。支持按可配置关键词自动分类客户群,结果写入 outputs/groups/。',
+      inputSchema: {
+        guid: z.string().optional(),
+        scope: z.enum(['self', 'session', 'messages', 'all']).optional(),
+        scanFromMessages: z.boolean().optional(),
+        includeInternalGroups: z.boolean().optional(),
+        customerKeywords: z.array(z.string()).optional(),
+        matchMode: z.enum(['any', 'threshold']).optional(),
+        threshold: z.number().int().min(1).optional(),
+        highConfidenceTerms: z.array(z.string()).optional(),
+        autoClassify: z.boolean().optional(),
+        maxPages: z.number().int().min(1).max(300).optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional(),
+        fromMessages: z.boolean().optional().describe('已废弃,请使用 scope=messages 或 scanFromMessages')
+      }
+    },
+    wrap(qiweiSyncExternalGroups)
+  );
+
+  server.registerTool(
+    'qiwei_list_external_groups',
+    {
+      title: '列出外部群',
+      description: '读取最近一次同步的群列表和已确认/已拒绝映射,返回每个群的状态(IMPORTED / SUGGESTED / AUTO_CONFIRMED / CONFIRMED / REJECTED),支持按关键词、状态、来源过滤。',
+      inputSchema: {
+        keyword: z.string().optional(),
+        status: z.enum(['IMPORTED', 'SUGGESTED', 'AUTO_CONFIRMED', 'CONFIRMED', 'REJECTED']).optional(),
+        source: z.enum(['roomList', 'session', 'messages']).optional(),
+        includeRejected: z.boolean().optional(),
+        customerKeywords: z.array(z.string()).optional(),
+        matchMode: z.enum(['any', 'threshold']).optional(),
+        threshold: z.number().int().min(1).optional(),
+        highConfidenceTerms: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiListExternalGroups)
+  );
+
+  server.registerTool(
+    'qiwei_analyze_group_members',
+    {
+      title: '分析群详情',
+      description: '对指定的 roomId 列表调用 /room/batchGetRoomDetail,按群名关键词判断哪些可能是客户群;可拉取最近 N 条消息辅助识别。',
+      inputSchema: {
+        guid: z.string().optional(),
+        roomIds: z.array(z.string()),
+        customerKeywords: z.array(z.string()).optional(),
+        matchMode: z.enum(['any', 'threshold']).optional(),
+        threshold: z.number().int().min(1).optional(),
+        highConfidenceTerms: z.array(z.string()).optional(),
+        autoClassify: z.boolean().optional(),
+        previewMessages: z.number().int().min(0).max(100).optional(),
+        updateSnapshot: z.boolean().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiAnalyzeGroupMembers)
+  );
+
+  server.registerTool(
+    'qiwei_confirm_external_group',
+    {
+      title: '确认外部群为客户群',
+      description: '对最近一次同步列表中的某个 roomId 标记为 CONFIRMED,并关联 customerId / externalUserId。映射写入 outputs/groups/confirmed-mapping.json。',
+      inputSchema: {
+        roomId: z.string(),
+        customerId: z.string().optional(),
+        externalUserId: z.string().optional(),
+        customerName: z.string().optional()
+      }
+    },
+    wrap(qiweiConfirmExternalGroup)
+  );
+
+  server.registerTool(
+    'qiwei_add_external_group',
+    {
+      title: '手动添加外部群',
+      description: '直接按 roomId 添加外部群并标记为 CONFIRMED,关联 customerId / externalUserId。适用于同步列表之外的群。',
+      inputSchema: {
+        roomId: z.string(),
+        roomName: z.string().optional(),
+        customerId: z.string().optional(),
+        externalUserId: z.string().optional(),
+        customerName: z.string().optional()
+      }
+    },
+    wrap(qiweiAddExternalGroup)
+  );
+
+  server.registerTool(
+    'qiwei_configure_group_keywords',
+    {
+      title: '配置客户群关键词',
+      description: '持久化配置客户群识别关键词、匹配模式(any/threshold)、阈值和高置信度词,写入 outputs/groups/customer-keywords.json。传 reset=true 可恢复默认。',
+      inputSchema: {
+        keywords: z.array(z.string()).optional(),
+        highConfidenceTerms: z.array(z.string()).optional(),
+        matchMode: z.enum(['any', 'threshold']).optional(),
+        threshold: z.number().int().min(1).optional(),
+        reset: z.boolean().optional()
+      }
+    },
+    wrap(qiweiConfigureGroupKeywords)
+  );
+
+  server.registerTool(
+    'qiwei_reject_external_group',
+    {
+      title: '拒绝外部群',
+      description: '将最近一次同步列表中的某个 roomId 标记为 REJECTED,写入 outputs/groups/rejected-mapping.json。',
+      inputSchema: {
+        roomId: z.string(),
+        reason: z.string().optional()
+      }
+    },
+    wrap(qiweiRejectExternalGroup)
+  );
+
+  server.registerTool(
+    'qiwei_sync_group_messages',
+    {
+      title: '同步群消息',
+      description: '对指定 roomId 列表或所有已确认客户群调用 /msg/syncMsg 分页同步历史消息,按 room 写入 outputs/messages/。',
+      inputSchema: {
+        guid: z.string().optional(),
+        roomIds: z.array(z.string()).optional(),
+        maxPages: z.number().int().min(1).max(300).optional(),
+        maxMessagesPerRoom: z.number().int().min(1).max(5000).optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiSyncGroupMessages)
+  );
+
+  server.registerTool(
+    'qiwei_prepare_customer_portrait',
+    {
+      title: '准备客户画像分析上下文',
+      description: '从 outputs/messages/ 中收集指定 externalUserId 发送的消息,生成画像分析上下文并写入 outputs/portraits/,供 Agent 分析后保存。',
+      inputSchema: {
+        externalUserId: z.string(),
+        roomIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiPrepareCustomerPortrait)
+  );
+
+  server.registerTool(
+    'qiwei_update_customer_portrait',
+    {
+      title: '更新客户画像',
+      description: '默认 Agent 驱动:准备上下文并返回给调用方分析。传 aiMode=keyword 时直接基于关键词生成简版画像并保存。',
+      inputSchema: {
+        externalUserId: z.string(),
+        aiMode: z.enum(['keyword']).optional(),
+        roomIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiUpdateCustomerPortrait)
+  );
+
+  server.registerTool(
+    'qiwei_save_customer_portrait',
+    {
+      title: '保存客户画像',
+      description: '将 Agent 分析后的画像 JSON 保存到 outputs/portraits/<externalUserId>.json。',
+      inputSchema: {
+        externalUserId: z.string(),
+        portrait: z.record(z.any()),
+        source: z.string().optional(),
+        messageCount: z.number().int().optional()
+      }
+    },
+    wrap(qiweiSaveCustomerPortrait)
+  );
+
+  server.registerTool(
+    'qiwei_batch_update_customer_portrait',
+    {
+      title: '批量更新客户画像',
+      description: '对多个 externalUserId 调用 qiwei_update_customer_portrait。',
+      inputSchema: {
+        externalUserIds: z.array(z.string()),
+        aiMode: z.enum(['keyword']).optional(),
+        roomIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiBatchUpdateCustomerPortrait)
+  );
+
+  server.registerTool(
+    'qiwei_batch_save_customer_portrait',
+    {
+      title: '批量保存客户画像',
+      description: '批量保存多个客户的画像。',
+      inputSchema: {
+        items: z.array(z.object({ externalUserId: z.string(), portrait: z.record(z.any()), source: z.string().optional() }))
+      }
+    },
+    wrap(qiweiBatchSaveCustomerPortrait)
+  );
+
+  server.registerTool(
+    'qiwei_export_customer_portraits',
+    {
+      title: '导出客户画像',
+      description: '将 outputs/portraits/ 中保存的画像导出为 Excel。未传 externalUserIds 时导出全部。',
+      inputSchema: {
+        externalUserIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiExportCustomerPortraits)
+  );
+
+  server.registerTool(
+    'qiwei_add_customer_tags',
+    {
+      title: '添加客户标签',
+      description: '为 externalUserId 添加本地标签,去重保存到 outputs/tags/<externalUserId>.json。',
+      inputSchema: {
+        externalUserId: z.string(),
+        tags: z.array(z.string())
+      }
+    },
+    wrap(qiweiAddCustomerTags)
+  );
+
+  server.registerTool(
+    'qiwei_remove_customer_tags',
+    {
+      title: '移除客户标签',
+      description: '为 externalUserId 移除本地标签。',
+      inputSchema: {
+        externalUserId: z.string(),
+        tags: z.array(z.string())
+      }
+    },
+    wrap(qiweiRemoveCustomerTags)
+  );
+
+  server.registerTool(
+    'qiwei_list_customer_tags',
+    {
+      title: '列出客户标签',
+      description: '读取 outputs/tags/<externalUserId>.json 返回标签列表。',
+      inputSchema: {
+        externalUserId: z.string()
+      }
+    },
+    wrap(qiweiListCustomerTags)
+  );
+
+  server.registerTool(
+    'qiwei_list_all_tags',
+    {
+      title: '列出所有本地标签',
+      description: '汇总所有客户的本地标签并去重返回。',
+      inputSchema: {}
+    },
+    wrap(qiweiListAllTags)
+  );
+
+  server.registerTool(
+    'qiwei_sync_personal_labels',
+    {
+      title: '同步企微个人标签',
+      description: '调用 /label/syncLabelList 同步企微个人标签列表。',
+      inputSchema: {
+        guid: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiSyncPersonalLabels)
+  );
+
+  server.registerTool(
+    'qiwei_create_personal_label',
+    {
+      title: '创建企微个人标签',
+      description: '调用 /label/editLabel opType=1 创建个人标签。',
+      inputSchema: {
+        guid: z.string().optional(),
+        labelName: z.string(),
+        labelSuperId: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiCreatePersonalLabel)
+  );
+
+  server.registerTool(
+    'qiwei_update_personal_label',
+    {
+      title: '更新企微个人标签',
+      description: '调用 /label/editLabel opType=3 更新个人标签。',
+      inputSchema: {
+        guid: z.string().optional(),
+        labelId: z.string(),
+        labelName: z.string(),
+        labelSuperId: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiUpdatePersonalLabel)
+  );
+
+  server.registerTool(
+    'qiwei_delete_personal_label',
+    {
+      title: '删除企微个人标签',
+      description: '调用 /label/editLabel opType=2 删除个人标签。',
+      inputSchema: {
+        guid: z.string().optional(),
+        labelId: z.string(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiDeletePersonalLabel)
+  );
+
+  server.registerTool(
+    'qiwei_apply_personal_labels',
+    {
+      title: '应用企微个人标签到客户',
+      description: '调用 /label/contactEditLabel 为客户添加/移除个人标签。',
+      inputSchema: {
+        guid: z.string().optional(),
+        externalUserId: z.string(),
+        addLabels: z.array(z.string()).optional(),
+        removeLabels: z.array(z.string()).optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiApplyPersonalLabels)
+  );
+
+  server.registerTool(
+    'qiwei_prepare_broker_playbook',
+    {
+      title: '准备顾问 playbook 分析上下文',
+      description: '从 outputs/messages/ 中收集指定顾问发送的消息,生成 playbook 分析上下文并写入 outputs/broker-playbooks/。',
+      inputSchema: {
+        brokerUserId: z.string(),
+        roomIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiPrepareBrokerPlaybook)
+  );
+
+  server.registerTool(
+    'qiwei_distill_broker',
+    {
+      title: '蒸馏顾问 playbook',
+      description: '默认 Agent 驱动:准备上下文并返回给调用方分析。传 aiMode=keyword 时直接基于关键词生成简版 playbook 并保存。',
+      inputSchema: {
+        brokerUserId: z.string(),
+        aiMode: z.enum(['keyword']).optional(),
+        roomIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiDistillBroker)
+  );
+
+  server.registerTool(
+    'qiwei_save_broker_playbook',
+    {
+      title: '保存顾问 playbook',
+      description: '将 Agent 分析后的 playbook JSON 保存到 outputs/broker-playbooks/<brokerUserId>.json。',
+      inputSchema: {
+        brokerUserId: z.string(),
+        playbook: z.record(z.any()),
+        source: z.string().optional(),
+        messageCount: z.number().int().optional()
+      }
+    },
+    wrap(qiweiSaveBrokerPlaybook)
+  );
+
+  server.registerTool(
+    'qiwei_batch_distill_broker',
+    {
+      title: '批量蒸馏顾问 playbook',
+      description: '对多个 brokerUserId 执行 qiwei_distill_broker。',
+      inputSchema: {
+        brokerUserIds: z.array(z.string()),
+        aiMode: z.enum(['keyword']).optional(),
+        roomIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiBatchDistillBroker)
+  );
+
+  server.registerTool(
+    'qiwei_batch_save_broker_playbook',
+    {
+      title: '批量保存顾问 playbook',
+      description: '批量保存多个顾问的 playbook。',
+      inputSchema: {
+        items: z.array(z.object({ brokerUserId: z.string(), playbook: z.record(z.any()), source: z.string().optional() }))
+      }
+    },
+    wrap(qiweiBatchSaveBrokerPlaybook)
+  );
+
+  server.registerTool(
+    'qiwei_export_broker_playbooks',
+    {
+      title: '导出顾问 playbook',
+      description: '将 outputs/broker-playbooks/ 中保存的 playbook 导出为 Excel。未传 brokerUserIds 时导出全部。',
+      inputSchema: {
+        brokerUserIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiExportBrokerPlaybooks)
+  );
+
+  server.registerTool(
+    'qiwei_get_broker_playbook',
+    {
+      title: '读取顾问 playbook',
+      description: '读取 outputs/broker-playbooks/<brokerUserId>.json 返回 playbook。',
+      inputSchema: {
+        brokerUserId: z.string()
+      }
+    },
+    wrap(qiweiGetBrokerPlaybook)
+  );
+
+  server.registerTool(
+    'qiwei_preview_transfer_package',
+    {
+      title: '预览客户交接包',
+      description: '根据 fromUserId/toUserId 和 roomIds/externalUserIds 生成交接包预览,写入 outputs/transfers/。',
+      inputSchema: {
+        fromUserId: z.string(),
+        toUserId: z.string(),
+        roomIds: z.array(z.string()).optional(),
+        externalUserIds: z.array(z.string()).optional()
+      }
+    },
+    wrap(qiweiPreviewTransferPackage)
+  );
+
+  server.registerTool(
+    'qiwei_execute_transfer',
+    {
+      title: '执行客户交接',
+      description: '读取交接包预览,对每个 room 邀请新顾问入群,可选移除旧顾问并发送欢迎语。结果写入 outputs/transfers/。',
+      inputSchema: {
+        guid: z.string().optional(),
+        previewFile: z.string().optional(),
+        removeOldBroker: z.boolean().optional(),
+        welcomeText: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiExecuteTransfer)
+  );
+
+  server.registerTool(
+    'qiwei_transcribe_voice',
+    {
+      title: '语音转写',
+      description: '接收语音 URL、本地文件路径或 base64,保存到 outputs/voice/,可选解码 silk 并调用转写服务。',
+      inputSchema: {
+        voiceUrl: z.string().optional(),
+        filePath: z.string().optional(),
+        base64Audio: z.string().optional(),
+        transcribe: z.boolean().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiTranscribeVoice)
+  );
+
+  server.registerTool(
+    'qiwei_webhook_status',
+    {
+      title: '查询 webhook 状态',
+      description: '查询本地 webhook server 运行状态和已保存的回调配置。',
+      inputSchema: {}
+    },
+    wrap(qiweiWebhookStatus)
+  );
+
+  server.registerTool(
+    'qiwei_webhook_discover',
+    {
+      title: '获取本地 webhook 回调地址',
+      description: '返回本地 webhook server 的回调 URL。',
+      inputSchema: {}
+    },
+    wrap(qiweiWebhookDiscover)
+  );
+
+  server.registerTool(
+    'qiwei_webhook_server_start',
+    {
+      title: '启动本地 webhook server',
+      description: '在 127.0.0.1 指定端口启动本地 HTTP server 接收企微回调,事件写入 outputs/webhook/。',
+      inputSchema: {
+        port: z.number().int().min(0).max(65535).optional()
+      }
+    },
+    wrap(qiweiWebhookServerStart)
+  );
+
+  server.registerTool(
+    'qiwei_webhook_server_stop',
+    {
+      title: '停止本地 webhook server',
+      description: '停止正在运行的本地 webhook server。',
+      inputSchema: {}
+    },
+    wrap(qiweiWebhookServerStop)
+  );
+
+  server.registerTool(
+    'qiwei_webhook_auto_setup',
+    {
+      title: '自动配置企微回调',
+      description: '启动本地 webhook server(如未运行)并调用 /client/setCallback 将回调地址配置到 Fmode 网关。',
+      inputSchema: {
+        guid: z.string().optional(),
+        port: z.number().int().min(0).max(65535).optional(),
+        callbackUrl: z.string().optional(),
+        secret: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiWebhookAutoSetup)
+  );
+
+  server.registerTool(
+    'qiwei_webhook_setup',
+    {
+      title: '配置企微回调',
+      description: '调用 /client/setCallback 配置指定的回调地址。',
+      inputSchema: {
+        guid: z.string().optional(),
+        callbackUrl: z.string(),
+        secret: z.string().optional(),
+        uid: z.string().optional(),
+        authToken: z.string().optional(),
+        apiBase: z.string().optional()
+      }
+    },
+    wrap(qiweiWebhookSetup)
+  );
+
+  server.registerTool(
+    'qiwei_relay_config',
+    {
+      title: '读取 relay 配置',
+      description: '读取 outputs/webhook/relay-config.json 中的中央 relay 配置。',
+      inputSchema: {}
+    },
+    wrap(qiweiRelayConfig)
+  );
+
+  server.registerTool(
+    'qiwei_relay_save_config',
+    {
+      title: '保存 relay 配置',
+      description: '保存中央 relay 配置到 outputs/webhook/relay-config.json。',
+      inputSchema: {
+        relayBaseUrl: z.string(),
+        tenantId: z.string().optional(),
+        publicKey: z.string().optional()
+      }
+    },
+    wrap(qiweiRelaySaveConfig)
+  );
+
+  server.registerTool(
+    'qiwei_relay_register',
+    {
+      title: '注册 Relay 租户',
+      description: '使用 Fmode token 在中央 Relay 服务端注册租户,自动保存 tenantId/apiKey/apiSecret/privateKey 到 .env.local 和 outputs/webhook/relay-config.json。',
+      inputSchema: {
+        relayBaseUrl: z.string().optional(),
+        description: z.string().optional(),
+        guid: z.string().optional(),
+        force: z.boolean().optional()
+      }
+    },
+    wrap(qiweiRelayRegister)
+  );
+
+  server.registerTool(
+    'qiwei_relay_connect',
+    {
+      title: '连接中央 relay',
+      description: '检查 relay 配置,注册设备到 Relay,调用 /client/setCallback 设置回调地址,并提示启动独立轮询客户端。',
+      inputSchema: {
+        guid: z.string().optional(),
+        force: z.boolean().optional()
+      }
+    },
+    wrap(qiweiRelayConnect)
+  );
+
   return server;
 }
 

+ 355 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-agent-skill-run.js

@@ -0,0 +1,355 @@
+const fs = require('fs');
+const path = require('path');
+const { spawn } = require('child_process');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { redactSecret } = require('../providers/fmode-wecom-gateway');
+const { PACKAGE_ROOT, latestPath } = require('../core/output-paths');
+
+const LEGACY_RUNTIME_DIR = path.join(PACKAGE_ROOT, 'legacy', 'qiwei-agent-skill');
+const DEFAULT_SERVICE_URL = 'http://localhost:3000';
+
+const CONTROL_FIELDS = new Set([
+  'serviceUrl',
+  'apiKey',
+  'timeoutMs',
+  'endpoint',
+  'path',
+  'route',
+  'method',
+  'body',
+  'payload',
+  'params'
+]);
+
+const LEGACY_SKILL_TOOLS = [
+  ['qiwei_legacy_batch_add_friends', 'POST', '/api/skill/batch-add-friends', '源后端:批量加好友'],
+  ['qiwei_legacy_auto_create_group', 'POST', '/api/skill/auto-create-group', '源后端:自动建客户服务群'],
+  ['qiwei_sync_group_messages', 'POST', '/api/skill/sync-group-messages', '同步客户群消息到本地 SQLite'],
+  ['qiwei_sync_external_groups', 'POST', '/api/skill/sync-external-groups', '扫描并导入历史外部群'],
+  ['qiwei_review_imported_groups', 'POST', '/api/skill/review-imported-groups', '重新分析待确认外部群'],
+  ['qiwei_confirm_external_group', 'POST', '/api/skill/confirm-external-group', '确认外部群为客户群'],
+  ['qiwei_add_external_group', 'POST', '/api/skill/add-external-group', '手动添加外部群'],
+  ['qiwei_update_customer_portrait', 'POST', '/api/skill/update-customer-portrait', '更新客户画像'],
+  ['qiwei_prepare_customer_portrait', 'POST', '/api/skill/prepare-customer-portrait', '准备客户画像分析上下文'],
+  ['qiwei_save_customer_portrait', 'POST', '/api/skill/save-customer-portrait', '保存客户画像分析结果'],
+  ['qiwei_batch_update_customer_portrait', 'POST', '/api/skill/batch-update-customer-portrait', '批量更新客户画像'],
+  ['qiwei_batch_save_customer_portrait', 'POST', '/api/skill/batch-save-customer-portrait', '批量保存客户画像结果'],
+  ['qiwei_export_customer_portraits', 'POST', '/api/skill/export-customer-portraits', '导出客户画像 Excel'],
+  ['qiwei_transcribe_voice', 'POST', '/api/skill/transcribe-voice', '转写群聊语音消息'],
+  ['qiwei_get_customer_profile', 'POST', '/api/skill/get-customer-profile', '查询客户完整档案'],
+  ['qiwei_distill_broker', 'POST', '/api/skill/distill-broker', '蒸馏服务顾问 playbook'],
+  ['qiwei_prepare_broker_playbook', 'POST', '/api/skill/prepare-broker-playbook', '准备服务顾问 playbook 上下文'],
+  ['qiwei_save_broker_playbook', 'POST', '/api/skill/save-broker-playbook', '保存服务顾问 playbook'],
+  ['qiwei_batch_distill_broker', 'POST', '/api/skill/batch-distill-broker', '批量蒸馏服务顾问 playbook'],
+  ['qiwei_batch_save_broker_playbook', 'POST', '/api/skill/batch-save-broker-playbook', '批量保存服务顾问 playbook'],
+  ['qiwei_export_broker_playbooks', 'POST', '/api/skill/export-broker-playbooks', '导出服务顾问 playbook Excel'],
+  ['qiwei_preview_transfer_package', 'POST', '/api/skill/preview-transfer-package', '预览客户交接包'],
+  ['qiwei_execute_transfer', 'POST', '/api/skill/execute-transfer', '执行客户交接'],
+  ['qiwei_record_collaboration', 'POST', '/api/skill/record-collaboration', '记录服务顾问协作分边'],
+  ['qiwei_add_customer_tags', 'POST', '/api/skill/add-customer-tags', '添加本地客户标签'],
+  ['qiwei_remove_customer_tags', 'POST', '/api/skill/remove-customer-tags', '移除本地客户标签'],
+  ['qiwei_list_customer_tags', 'POST', '/api/skill/list-customer-tags', '查询客户标签'],
+  ['qiwei_list_all_tags', 'POST', '/api/skill/list-all-tags', '查询全部本地标签'],
+  ['qiwei_sync_qiwe_personal_labels', 'POST', '/api/skill/sync-qiwe-personal-labels', '同步企微个人标签'],
+  ['qiwei_create_qiwe_personal_label', 'POST', '/api/skill/create-qiwe-personal-label', '创建企微个人标签'],
+  ['qiwei_update_qiwe_personal_label', 'POST', '/api/skill/update-qiwe-personal-label', '修改企微个人标签'],
+  ['qiwei_delete_qiwe_personal_label', 'POST', '/api/skill/delete-qiwe-personal-label', '删除企微个人标签'],
+  ['qiwei_apply_qiwe_labels_to_customer', 'POST', '/api/skill/apply-qiwe-labels-to-customer', '给客户打/删企微个人标签'],
+  ['qiwei_friend_polling_status', 'GET', '/api/skill/friend-polling-status', '查询好友通过轮询 worker 状态'],
+  ['qiwei_webhook_status', 'GET', '/api/webhook/status', '查询源后端 Webhook 状态'],
+  ['qiwei_webhook_discover', 'GET', '/api/webhook/discover', '发现 Webhook 回调配置'],
+  ['qiwei_webhook_auto_setup', 'POST', '/api/webhook/auto-setup', '自动配置 Webhook 回调'],
+  ['qiwei_webhook_setup', 'POST', '/api/webhook/setup', '手动设置 Webhook 回调'],
+  ['qiwei_webhook_relay_config', 'GET', '/api/webhook/relay-config', '查询 Webhook Relay 配置'],
+  ['qiwei_webhook_relay_save_config', 'POST', '/api/webhook/relay-config', '保存 Webhook Relay 配置'],
+  ['qiwei_webhook_relay_connect', 'POST', '/api/webhook/relay-connect', '连接 Webhook Relay']
+].map(([name, method, endpoint, title]) => ({ name, method, endpoint, title }));
+
+function readEnvFile(filePath) {
+  if (!fs.existsSync(filePath)) return {};
+  const env = {};
+  const text = fs.readFileSync(filePath, 'utf8');
+  for (const line of text.split(/\r?\n/)) {
+    const trimmed = line.trim();
+    if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) continue;
+    const idx = trimmed.indexOf('=');
+    const key = trimmed.slice(0, idx).trim();
+    let value = trimmed.slice(idx + 1).trim();
+    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+      value = value.slice(1, -1);
+    }
+    env[key] = value;
+  }
+  return env;
+}
+
+function legacyEnv() {
+  return readEnvFile(path.join(LEGACY_RUNTIME_DIR, '.env'));
+}
+
+function serviceUrl(input = {}) {
+  return String(input.serviceUrl || process.env.QIWEI_AGENT_SERVICE_URL || DEFAULT_SERVICE_URL).replace(/\/+$/, '');
+}
+
+function apiKey(input = {}) {
+  const env = legacyEnv();
+  return input.apiKey || process.env.QIWEI_AGENT_API_KEY || process.env.QIWEI_SKILL_API_KEY || env.API_KEY || '';
+}
+
+function stripControl(input = {}) {
+  const body = {};
+  for (const [key, value] of Object.entries(input || {})) {
+    if (!CONTROL_FIELDS.has(key)) body[key] = value;
+  }
+  return body;
+}
+
+function requestBody(input = {}) {
+  if (input.body && typeof input.body === 'object') return input.body;
+  if (input.payload && typeof input.payload === 'object') return input.payload;
+  if (input.params && typeof input.params === 'object') return input.params;
+  return stripControl(input);
+}
+
+function parseJsonMaybe(text) {
+  if (!text) return null;
+  try {
+    return JSON.parse(text);
+  } catch {
+    return text;
+  }
+}
+
+function summarizePayload(payload) {
+  if (!payload || typeof payload !== 'object') return {};
+  return {
+    success: payload.success,
+    status: payload.status,
+    message: payload.message,
+    total: payload.total,
+    successCount: payload.successCount || payload.success,
+    failed: payload.failed,
+    count: payload.count,
+    filePath: payload.filePath
+  };
+}
+
+async function requestLegacyService(input = {}) {
+  const endpoint = input.endpoint || input.path || input.route;
+  if (!endpoint) return errorResult('缺少 endpoint/path/route。');
+
+  const method = String(input.method || 'POST').toUpperCase();
+  const url = `${serviceUrl(input)}${String(endpoint).startsWith('/') ? endpoint : `/${endpoint}`}`;
+  const timeoutMs = Math.max(1000, Number(input.timeoutMs || 120000));
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+  const key = apiKey(input);
+
+  const headers = {};
+  if (key) {
+    headers.Authorization = `Bearer ${key}`;
+    headers['X-API-Key'] = key;
+  }
+  const options = { method, headers, signal: controller.signal };
+  if (!['GET', 'HEAD'].includes(method)) {
+    headers['Content-Type'] = 'application/json';
+    options.body = JSON.stringify(input.body !== undefined ? input.body : requestBody(input));
+  }
+
+  try {
+    const response = await fetch(url, options);
+    const text = await response.text();
+    const payload = parseJsonMaybe(text);
+    if (!response.ok) {
+      return {
+        status: response.status === 401 || response.status === 403 ? 'needs_auth' : 'error',
+        assistantMessage: `源后端接口调用失败:${method} ${endpoint} -> HTTP ${response.status}。`,
+        summary: { endpoint, method, httpStatus: response.status },
+        data: { response: payload },
+        files: [],
+        nextActions: response.status === 401 || response.status === 403
+          ? ['在 legacy/qiwei-agent-skill/.env 配置 API_KEY,或调用工具时传 apiKey']
+          : ['检查源后端日志后重试'],
+        warnings: [],
+        errors: [{ message: typeof payload === 'string' ? payload : JSON.stringify(payload) }]
+      };
+    }
+
+    const message = payload && typeof payload === 'object'
+      ? payload.message || payload.result || payload.status || `源后端接口调用成功:${method} ${endpoint}`
+      : `源后端接口调用成功:${method} ${endpoint}`;
+    return okResult({
+      assistantMessage: String(message),
+      summary: { endpoint, method, httpStatus: response.status, ...summarizePayload(payload) },
+      data: { response: payload }
+    });
+  } catch (error) {
+    const message = error && error.name === 'AbortError'
+      ? `源后端接口超时:${method} ${endpoint}`
+      : `无法访问源后端服务 ${url}:${error && error.message ? error.message : String(error)}`;
+    return {
+      status: 'needs_start',
+      assistantMessage: redactSecret(message),
+      summary: { endpoint, method, serviceUrl: serviceUrl(input) },
+      data: {},
+      files: [],
+      nextActions: [
+        '先调用 qiwei_agent_service_status 检查运行时',
+        '如未启动,调用 qiwei_agent_service_start 或在 legacy/qiwei-agent-skill 下运行 npm install && npm run dev'
+      ],
+      warnings: [],
+      errors: [{ message: redactSecret(message) }]
+    };
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+async function qiweiAgentServiceStatus(input = {}) {
+  const runtimeExists = fs.existsSync(LEGACY_RUNTIME_DIR);
+  const packageJson = path.join(LEGACY_RUNTIME_DIR, 'package.json');
+  const depsInstalled = fs.existsSync(path.join(LEGACY_RUNTIME_DIR, 'node_modules'));
+  const schemaExists = fs.existsSync(path.join(LEGACY_RUNTIME_DIR, 'lib', 'schema.ts'));
+  const envExists = fs.existsSync(path.join(LEGACY_RUNTIME_DIR, '.env'));
+
+  const base = {
+    runtimeDir: LEGACY_RUNTIME_DIR,
+    runtimeExists,
+    packageJsonExists: fs.existsSync(packageJson),
+    depsInstalled,
+    schemaExists,
+    envExists,
+    serviceUrl: serviceUrl(input)
+  };
+
+  if (!runtimeExists) {
+    return errorResult('源 Qiwei 后端运行时尚未迁入目标包。', { summary: base });
+  }
+
+  const health = await requestLegacyService({ ...input, method: 'GET', endpoint: '/api/health', timeoutMs: input.timeoutMs || 3000 });
+  const skills = health.status === 'ok'
+    ? await requestLegacyService({ ...input, method: 'GET', endpoint: '/api/skills', timeoutMs: input.timeoutMs || 5000 })
+    : null;
+  const skillsPayload = skills && skills.status === 'ok' ? skills.data.response : null;
+  const skillCount = Array.isArray(skillsPayload)
+    ? skillsPayload.length
+    : (skillsPayload && Array.isArray(skillsPayload.skills) ? skillsPayload.skills.length : undefined);
+
+  return okResult({
+    assistantMessage: health.status === 'ok'
+      ? '源 Qiwei 后端已迁入且服务在线。'
+      : '源 Qiwei 后端已迁入,但本地服务未在线或未完成依赖安装。',
+    summary: {
+      ...base,
+      serviceOnline: health.status === 'ok',
+      skillCount
+    },
+    data: { health, skills },
+    nextActions: depsInstalled ? [] : [`在 ${LEGACY_RUNTIME_DIR} 运行 npm install`],
+    warnings: envExists ? [] : ['legacy/qiwei-agent-skill/.env 尚不存在;首次运行前通常需要从 .env.example 创建并配置。']
+  });
+}
+
+async function qiweiAgentServiceStart(input = {}) {
+  if (!fs.existsSync(LEGACY_RUNTIME_DIR)) {
+    return errorResult('源 Qiwei 后端运行时不存在,无法启动。', { summary: { runtimeDir: LEGACY_RUNTIME_DIR } });
+  }
+  if (!fs.existsSync(path.join(LEGACY_RUNTIME_DIR, 'node_modules'))) {
+    return {
+      status: 'needs_setup',
+      assistantMessage: '源 Qiwei 后端依赖尚未安装,不能启动。',
+      summary: { runtimeDir: LEGACY_RUNTIME_DIR },
+      data: {},
+      files: [],
+      nextActions: [`cd ${LEGACY_RUNTIME_DIR}`, 'npm install', 'npm run dev'],
+      warnings: [],
+      errors: []
+    };
+  }
+
+  const health = await requestLegacyService({ ...input, method: 'GET', endpoint: '/api/health', timeoutMs: 2000 });
+  if (health.status === 'ok') {
+    return okResult({
+      assistantMessage: '源 Qiwei 后端已经在线,无需重复启动。',
+      summary: health.summary,
+      data: health.data
+    });
+  }
+
+  const outPath = latestPath('tmp', 'qiwei-agent-skill.log');
+  const errPath = latestPath('tmp', 'qiwei-agent-skill.err.log');
+  const out = fs.openSync(outPath, 'a');
+  const err = fs.openSync(errPath, 'a');
+  const command = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+  const child = spawn(command, ['run', 'dev'], {
+    cwd: LEGACY_RUNTIME_DIR,
+    detached: true,
+    stdio: ['ignore', out, err],
+    windowsHide: true,
+    env: { ...process.env, PORT: String(input.port || process.env.PORT || 3000) }
+  });
+  child.unref();
+
+  const pidPath = latestPath('tmp', 'qiwei-agent-skill.pid');
+  fs.writeFileSync(pidPath, String(child.pid), 'utf8');
+
+  return okResult({
+    assistantMessage: `已启动源 Qiwei 后端,PID=${child.pid}。`,
+    summary: { pid: child.pid, runtimeDir: LEGACY_RUNTIME_DIR, serviceUrl: serviceUrl(input) },
+    data: { pid: child.pid, pidPath, outPath, errPath },
+    files: [pidPath, outPath, errPath],
+    nextActions: ['稍等 3-5 秒后调用 qiwei_agent_service_status 确认在线']
+  });
+}
+
+async function qiweiAgentSkillCall(input = {}) {
+  return requestLegacyService(input);
+}
+
+function handlerForTool(tool) {
+  return async (input = {}) => {
+    if (tool.name === 'qiwei_get_broker_playbook') {
+      const brokerId = input.brokerId;
+      if (!brokerId) return errorResult('缺少 brokerId。');
+      return requestLegacyService({
+        ...input,
+        method: 'GET',
+        endpoint: `/api/skill/broker-playbook/${encodeURIComponent(String(brokerId))}`
+      });
+    }
+    return requestLegacyService({
+      ...input,
+      method: tool.method,
+      endpoint: tool.endpoint,
+      body: requestBody(input)
+    });
+  };
+}
+
+function legacySkillHandlers() {
+  const handlers = {};
+  for (const tool of LEGACY_SKILL_TOOLS) handlers[tool.name] = handlerForTool(tool);
+  handlers.qiwei_get_broker_playbook = handlerForTool({
+    name: 'qiwei_get_broker_playbook',
+    method: 'GET',
+    endpoint: '/api/skill/broker-playbook/:brokerId'
+  });
+  return handlers;
+}
+
+module.exports = {
+  LEGACY_RUNTIME_DIR,
+  LEGACY_SKILL_TOOLS: [
+    ...LEGACY_SKILL_TOOLS,
+    {
+      name: 'qiwei_get_broker_playbook',
+      method: 'GET',
+      endpoint: '/api/skill/broker-playbook/:brokerId',
+      title: '查询服务顾问 playbook'
+    }
+  ],
+  qiweiAgentServiceStatus,
+  qiweiAgentServiceStart,
+  qiweiAgentSkillCall,
+  legacySkillHandlers
+};

+ 325 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js

@@ -0,0 +1,325 @@
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { safeResult } = require('../core/shared-gateway');
+
+const PLAYBOOK_DIMENSIONS = [
+  '客户分层', '开场白', '需求挖掘', '异议处理', '跟进节奏',
+  '成交信号', '风险提示', '话术风格', '产品匹配', '转介绍',
+  '复购维护', '竞争应对', '服务边界'
+];
+
+const PLAYBOOK_KEYWORDS = {
+  '客户分层': ['预算', '首付', '刚需', '改善', '投资'],
+  '开场白': ['你好', '您好', '感谢', '介绍'],
+  '需求挖掘': ['考虑', '想要', '需要', '关注', '看重'],
+  '异议处理': ['但是', '不过', '担心', '顾虑', '再考虑'],
+  '跟进节奏': ['明天', '下周', '到时候', '再联系', '约'],
+  '成交信号': ['定', '签', '可以', '没问题', '确定'],
+  '风险提示': ['注意', '提醒', '谨慎', '风险'],
+  '话术风格': ['专业', '耐心', '直接', '细致'],
+  '产品匹配': ['户型', '面积', '楼层', '朝向', '配套'],
+  '转介绍': ['推荐', '介绍', '朋友', '亲戚'],
+  '复购维护': ['后续', '服务', '维护', '长期'],
+  '竞争应对': ['对比', '别家', '其他', '优势'],
+  '服务边界': ['职责', '范围', '不包', '额外']
+};
+
+function brokerPlaybooksDir() {
+  return path.join(outputsRoot(), 'broker-playbooks');
+}
+
+function ensureBrokerPlaybooksDir() {
+  fs.mkdirSync(brokerPlaybooksDir(), { recursive: true });
+}
+
+function playbookFilePath(brokerUserId) {
+  ensureBrokerPlaybooksDir();
+  return path.join(brokerPlaybooksDir(), `${brokerUserId}.json`);
+}
+
+function readPlaybook(brokerUserId) {
+  const filePath = playbookFilePath(brokerUserId);
+  if (!fs.existsSync(filePath)) return null;
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return null;
+  }
+}
+
+function writePlaybook(brokerUserId, playbook) {
+  const filePath = playbookFilePath(brokerUserId);
+  fs.writeFileSync(filePath, JSON.stringify(playbook, null, 2), 'utf8');
+  return filePath;
+}
+
+function listMessageFiles() {
+  const dir = path.join(outputsRoot(), 'messages');
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => f.startsWith('room-') && f.endsWith('.json'))
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
+    .sort((a, b) => b.mtime - a.mtime);
+}
+
+function extractRoomIdFromFileName(fileName) {
+  const match = fileName.match(/^room-([^-]+)-/);
+  return match ? match[1] : null;
+}
+
+function collectMessagesForBroker(brokerUserId, roomIds = null) {
+  const files = listMessageFiles();
+  const messages = [];
+  for (const file of files) {
+    const roomId = extractRoomIdFromFileName(file.name);
+    if (roomIds && !roomIds.includes(roomId)) continue;
+    try {
+      const data = JSON.parse(fs.readFileSync(file.path, 'utf8'));
+      if (!Array.isArray(data)) continue;
+      for (const msg of data) {
+        if (msg.senderId === brokerUserId) {
+          messages.push({ ...msg, roomId });
+        }
+      }
+    } catch {
+      // ignore
+    }
+  }
+  return messages;
+}
+
+function simpleKeywordPlaybook(messages) {
+  const text = messages.map(m => m.content || '').join(' ');
+  const playbook = {};
+  for (const [dimension, keywords] of Object.entries(PLAYBOOK_KEYWORDS)) {
+    const matched = keywords.filter(kw => text.includes(kw));
+    if (matched.length) {
+      playbook[dimension] = {
+        inferred: true,
+        keywords: [...new Set(matched)],
+        note: `从 ${messages.length} 条消息中推断`
+      };
+    }
+  }
+  if (!Object.keys(playbook).length) {
+    playbook.summary = { note: `收集了 ${messages.length} 条消息,未识别到明确 playbook 关键词` };
+  }
+  return playbook;
+}
+
+function buildPlaybookContext(brokerUserId, messages) {
+  const text = messages.map(m => m.content || '').join('\n');
+  return {
+    brokerUserId,
+    messageCount: messages.length,
+    sampleMessages: messages.slice(0, 50),
+    fullTextPreview: text.slice(0, 8000),
+    suggestedDimensions: PLAYBOOK_DIMENSIONS,
+    hint: '请基于 sampleMessages 和 fullTextPreview 分析顾问 playbook,返回 JSON 后调用 qiwei_save_broker_playbook 保存。'
+  };
+}
+
+const qiweiPrepareBrokerPlaybook = safeResult(async function qiweiPrepareBrokerPlaybook(input = {}) {
+  const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
+  if (!brokerUserId) return errorResult('缺少 brokerUserId');
+
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
+  const messages = collectMessagesForBroker(brokerUserId, roomIds);
+
+  const context = buildPlaybookContext(brokerUserId, messages);
+  ensureBrokerPlaybooksDir();
+  const runDir = createRunDir('broker-playbooks', `context-${brokerUserId}`);
+  const filePath = path.join(runDir, `context-${brokerUserId}.json`);
+  fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `已为顾问 ${brokerUserId} 准备 playbook 分析上下文:共 ${messages.length} 条消息。`,
+    summary: { brokerUserId, messageCount: messages.length },
+    data: { context, contextFile: path.relative(outputsRoot(), filePath) },
+    files: [filePath],
+    nextActions: ['基于 context 分析后调用 qiwei_save_broker_playbook 保存']
+  });
+});
+
+const qiweiDistillBroker = safeResult(async function qiweiDistillBroker(input = {}) {
+  const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
+  if (!brokerUserId) return errorResult('缺少 brokerUserId');
+
+  const aiMode = String(input.aiMode || '').trim();
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
+  const messages = collectMessagesForBroker(brokerUserId, roomIds);
+
+  if (aiMode === 'keyword') {
+    const playbook = simpleKeywordPlaybook(messages);
+    const saved = { brokerUserId, playbook, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
+    const filePath = writePlaybook(brokerUserId, saved);
+    return okResult({
+      assistantMessage: `关键词模式 playbook 蒸馏完成:${brokerUserId}。`,
+      summary: { brokerUserId, messageCount: messages.length, dimensions: Object.keys(playbook) },
+      data: saved,
+      files: [filePath]
+    });
+  }
+
+  const context = buildPlaybookContext(brokerUserId, messages);
+  ensureBrokerPlaybooksDir();
+  const runDir = createRunDir('broker-playbooks', `context-${brokerUserId}`);
+  const filePath = path.join(runDir, `context-${brokerUserId}.json`);
+  fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `已为顾问 ${brokerUserId} 准备 playbook 分析上下文,请 Agent 分析后调用 qiwei_save_broker_playbook 保存。`,
+    summary: { brokerUserId, messageCount: messages.length, requiresAgentAnalysis: true },
+    data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_broker_playbook' },
+    files: [filePath],
+    nextActions: ['分析 context 后调用 qiwei_save_broker_playbook']
+  });
+});
+
+const qiweiSaveBrokerPlaybook = safeResult(async function qiweiSaveBrokerPlaybook(input = {}) {
+  const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
+  const playbook = input.playbook || input.content;
+  if (!brokerUserId) return errorResult('缺少 brokerUserId');
+  if (!playbook || typeof playbook !== 'object') return errorResult('缺少 playbook(JSON)');
+
+  const saved = {
+    brokerUserId,
+    playbook,
+    source: input.source || 'agent',
+    messageCount: input.messageCount || 0,
+    updatedAt: new Date().toISOString()
+  };
+  const filePath = writePlaybook(brokerUserId, saved);
+
+  return okResult({
+    assistantMessage: `顾问 playbook 已保存:${brokerUserId}。`,
+    summary: { brokerUserId, dimensions: Object.keys(playbook) },
+    data: saved,
+    files: [filePath]
+  });
+});
+
+const qiweiBatchDistillBroker = safeResult(async function qiweiBatchDistillBroker(input = {}) {
+  const brokerUserIds = Array.isArray(input.brokerUserIds) ? input.brokerUserIds.map(String).filter(Boolean) : [];
+  if (!brokerUserIds.length) return errorResult('缺少 brokerUserIds');
+
+  const results = [];
+  for (const brokerUserId of brokerUserIds) {
+    try {
+      const result = await qiweiDistillBroker({ ...input, brokerUserId });
+      results.push({ brokerUserId, status: result.status, summary: result.summary });
+    } catch (error) {
+      results.push({ brokerUserId, status: 'error', message: String(error && error.message ? error.message : error) });
+    }
+  }
+
+  return okResult({
+    assistantMessage: `批量 playbook 蒸馏完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
+    summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
+    data: { results }
+  });
+});
+
+const qiweiBatchSaveBrokerPlaybook = safeResult(async function qiweiBatchSaveBrokerPlaybook(input = {}) {
+  const items = Array.isArray(input.items) ? input.items : [];
+  if (!items.length) return errorResult('缺少 items({ brokerUserId, playbook } 数组)');
+
+  const results = [];
+  for (const item of items) {
+    try {
+      const result = await qiweiSaveBrokerPlaybook({ brokerUserId: item.brokerUserId, playbook: item.playbook, source: item.source });
+      results.push({ brokerUserId: item.brokerUserId, status: result.status, summary: result.summary });
+    } catch (error) {
+      results.push({ brokerUserId: item.brokerUserId, status: 'error', message: String(error && error.message ? error.message : error) });
+    }
+  }
+
+  return okResult({
+    assistantMessage: `批量 playbook 保存完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
+    summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
+    data: { results }
+  });
+});
+
+const qiweiExportBrokerPlaybooks = safeResult(async function qiweiExportBrokerPlaybooks(input = {}) {
+  let brokerUserIds = Array.isArray(input.brokerUserIds)
+    ? input.brokerUserIds.map(String).filter(Boolean)
+    : null;
+
+  if (!brokerUserIds) {
+    ensureBrokerPlaybooksDir();
+    brokerUserIds = fs.readdirSync(brokerPlaybooksDir())
+      .filter(f => f.endsWith('.json') && !f.startsWith('context-'))
+      .map(f => f.replace(/\.json$/, ''));
+  }
+
+  if (!brokerUserIds.length) return errorResult('没有可导出的 playbook');
+
+  let XLSX;
+  try {
+    XLSX = require('xlsx');
+  } catch {
+    return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
+  }
+
+  const rows = [];
+  for (const brokerUserId of brokerUserIds) {
+    const data = readPlaybook(brokerUserId);
+    if (!data || !data.playbook) continue;
+    rows.push({ brokerUserId, ...flattenObject(data.playbook), updatedAt: data.updatedAt });
+  }
+
+  ensureBrokerPlaybooksDir();
+  const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
+  const filePath = path.join(brokerPlaybooksDir(), fileName);
+  const worksheet = XLSX.utils.json_to_sheet(rows);
+  const workbook = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(workbook, worksheet, 'playbooks');
+  XLSX.writeFile(workbook, filePath);
+
+  return okResult({
+    assistantMessage: `已导出 ${rows.length} 条顾问 playbook:${filePath}。`,
+    summary: { rowCount: rows.length },
+    data: { filePath: path.relative(outputsRoot(), filePath) },
+    files: [filePath]
+  });
+});
+
+const qiweiGetBrokerPlaybook = safeResult(async function qiweiGetBrokerPlaybook(input = {}) {
+  const brokerUserId = String(input.brokerUserId || input.brokerId || '').trim();
+  if (!brokerUserId) return errorResult('缺少 brokerUserId');
+
+  const playbook = readPlaybook(brokerUserId);
+  if (!playbook) return errorResult(`顾问 ${brokerUserId} 暂无 playbook`);
+
+  return okResult({
+    assistantMessage: `已读取顾问 ${brokerUserId} 的 playbook。`,
+    summary: { brokerUserId, dimensions: Object.keys(playbook.playbook || {}) },
+    data: playbook
+  });
+});
+
+function flattenObject(obj, prefix = '') {
+  const result = {};
+  for (const [key, value] of Object.entries(obj)) {
+    const newKey = prefix ? `${prefix}.${key}` : key;
+    if (value && typeof value === 'object' && !Array.isArray(value)) {
+      Object.assign(result, flattenObject(value, newKey));
+    } else {
+      result[newKey] = Array.isArray(value) ? value.join(',') : value;
+    }
+  }
+  return result;
+}
+
+module.exports = {
+  qiweiDistillBroker,
+  qiweiPrepareBrokerPlaybook,
+  qiweiSaveBrokerPlaybook,
+  qiweiBatchDistillBroker,
+  qiweiBatchSaveBrokerPlaybook,
+  qiweiExportBrokerPlaybooks,
+  qiweiGetBrokerPlaybook
+};

+ 437 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js

@@ -0,0 +1,437 @@
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { outputsRoot } = require('../core/output-paths');
+const {
+  buildContext,
+  gatewayCall,
+  requireGuid,
+  assertMethodsInCatalog,
+  safeResult
+} = require('../core/shared-gateway');
+
+const REQUIRED_METHODS = {
+  searchContact: '/contact/searchContact',
+  addWx: '/contact/addSearchWxContact',
+  addWork: '/contact/addSearchWxWorkContact',
+  createRoom: '/room/createRoom',
+  modifyRoomName: '/room/modifyRoomName',
+  inviteRoomMember: '/room/inviteRoomMember',
+  sendText: '/msg/sendText',
+  getWxContactList: '/contact/getWxContactList',
+  getRoomList: '/room/getRoomList'
+};
+
+function cleanPhone(raw) {
+  let value = String(raw || '').trim().replace(/[^\d]/g, '');
+  if (value.length >= 12 && /^(?:86)?1[3-9]\d{9}$/.test(value)) value = value.replace(/^86/, '');
+  return value;
+}
+
+function normalizeCustomers(input = {}) {
+  const raw = [];
+  if (Array.isArray(input.customers)) {
+    for (const item of input.customers) raw.push(item && typeof item === 'object' ? item : { phone: item });
+  } else if (Array.isArray(input.phones)) {
+    for (const phone of input.phones) raw.push({ phone });
+  } else if (input.filePath) {
+    raw.push(...readCustomersFromExcel(input.filePath));
+  }
+
+  const invalid = [];
+  const seen = new Set();
+  const customers = [];
+  let duplicateRemoved = 0;
+  for (const item of raw) {
+    const phone = cleanPhone(item.phone);
+    if (!/^1[3-9]\d{9}$/.test(phone)) {
+      invalid.push(String(item.phone || ''));
+      continue;
+    }
+    if (seen.has(phone)) {
+      duplicateRemoved++;
+      continue;
+    }
+    seen.add(phone);
+    customers.push({
+      phone,
+      name: item.name ? String(item.name) : undefined,
+      greeting: item.greeting || item.verifyText ? String(item.greeting || item.verifyText) : undefined,
+      groupName: item.groupName ? String(item.groupName) : undefined,
+      supportMemberIds: Array.isArray(item.supportMemberIds) ? item.supportMemberIds.map(String).filter(Boolean) : undefined
+    });
+  }
+  return { customers, invalidRemoved: invalid.length, duplicateRemoved, invalid };
+}
+
+function readCustomersFromExcel(filePath) {
+  const resolved = path.resolve(String(filePath));
+  if (!fs.existsSync(resolved)) throw new Error(`Excel 文件不存在: ${resolved}`);
+  if (!/\.xlsx?$/i.test(resolved)) throw new Error('filePath 仅支持 .xlsx / .xls');
+  let XLSX;
+  try {
+    XLSX = require('xlsx');
+  } catch {
+    throw new Error('当前包尚未安装 xlsx,无法读取 Excel;请改传 customers/phones,或运行 npm install xlsx');
+  }
+  const workbook = XLSX.readFile(resolved);
+  const sheet = workbook.Sheets[workbook.SheetNames[0]];
+  const rows = XLSX.utils.sheet_to_json(sheet, { defval: '' });
+  const headers = Object.keys(rows[0] || {});
+  const findColumn = names => headers.find(h => names.some(n => h.toLowerCase().includes(n.toLowerCase())));
+  const phoneCol = findColumn(['手机号', '手机号码', '手机', '电话', 'phone', 'mobile', 'tel']);
+  const nameCol = findColumn(['姓名', '名字', '客户姓名', '客户名', 'name', 'customer']);
+  const greetingCol = findColumn(['验证消息', '好友申请', '申请内容', 'greeting', 'verifyText', 'message']);
+  const groupNameCol = findColumn(['群名', '群名称', 'groupName', 'roomName']);
+  if (!phoneCol) throw new Error('Excel 中未找到手机号列');
+  return rows.map(row => ({
+    phone: row[phoneCol],
+    name: nameCol ? row[nameCol] : undefined,
+    greeting: greetingCol ? row[greetingCol] : undefined,
+    groupName: groupNameCol ? row[groupNameCol] : undefined
+  }));
+}
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function classifySearchResult(data) {
+  const searchStatus = Number(data && data.searchStatus);
+  const wx = data && data.wxUserInfo ? data.wxUserInfo : {};
+  const work = data && data.workUserInfo ? data.workUserInfo : {};
+  const externalUserId = wx.userId || work.userId || '';
+  return { searchStatus, wx, work, externalUserId };
+}
+
+const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(input = {}) {
+  assertMethodsInCatalog(REQUIRED_METHODS);
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const { customers, invalidRemoved, duplicateRemoved, invalid } = normalizeCustomers(input);
+  if (!customers.length) {
+    const { errorResult } = require('../core/result-envelope');
+    return errorResult('没有有效手机号', { summary: { invalidRemoved }, data: { invalid } });
+  }
+
+  const perMinute = Math.max(1, Number(input.rateLimitPerMinute || 12));
+  const intervalMs = Math.ceil(60000 / perMinute);
+  const retry = Math.max(1, Math.min(5, Number(input.maxAttempts || 2)));
+  const defaultGreeting = String(input.defaultGreeting || input.greeting || '你好,方便加您企业微信沟通。');
+  const details = [];
+  let success = 0;
+  let failed = 0;
+
+  for (let i = 0; i < customers.length; i++) {
+    const customer = customers[i];
+    let lastError = '';
+    let done = false;
+    for (let attempt = 1; attempt <= retry; attempt++) {
+      try {
+        const searchData = await gatewayCall(ctx, REQUIRED_METHODS.searchContact, {
+          guid: ctx.guid,
+          keyword: customer.phone
+        });
+        const found = classifySearchResult(searchData);
+        if (!found.searchStatus) {
+          lastError = '搜索不到该用户';
+          break;
+        }
+
+        if (found.searchStatus === 2 || found.searchStatus === 3) {
+          details.push({
+            phone: customer.phone,
+            name: customer.name,
+            status: 'SUCCESS',
+            reason: found.searchStatus === 2 ? 'already_friend' : 'already_added_by_other',
+            externalUserId: found.externalUserId,
+            attempts: attempt
+          });
+          success++;
+          done = true;
+          break;
+        }
+
+        const verifyText = customer.greeting || defaultGreeting;
+        let addData;
+        if (found.wx.userId && found.wx.unionId) {
+          addData = await gatewayCall(ctx, REQUIRED_METHODS.addWx, {
+            guid: ctx.guid,
+            userId: String(found.wx.userId),
+            unionId: String(found.wx.unionId),
+            verifyText
+          });
+        } else if (found.work.userId && found.work.corpId && found.work.ticket) {
+          addData = await gatewayCall(ctx, REQUIRED_METHODS.addWork, {
+            guid: ctx.guid,
+            userId: String(found.work.userId),
+            corpId: String(found.work.corpId),
+            ticket: String(found.work.ticket),
+            verifyText
+          });
+        } else {
+          lastError = '搜索结果缺少添加好友所需参数';
+          break;
+        }
+
+        details.push({
+          phone: customer.phone,
+          name: customer.name,
+          status: 'SUCCESS',
+          externalUserId: addData && addData.userId ? String(addData.userId) : found.externalUserId,
+          attempts: attempt
+        });
+        success++;
+        done = true;
+        break;
+      } catch (error) {
+        const { redactSecret } = require('../providers/fmode-wecom-gateway');
+        lastError = redactSecret(error && error.message ? error.message : String(error));
+        if (attempt < retry) await sleep(1000 * attempt);
+      }
+    }
+    if (!done) {
+      failed++;
+      details.push({ phone: customer.phone, name: customer.name, status: 'FAILED', reason: lastError || '未知错误', attempts: retry });
+    }
+    if (i < customers.length - 1) await sleep(intervalMs);
+  }
+
+  return okResult({
+    assistantMessage: `批量加好友完成:成功 ${success},失败 ${failed}。`,
+    summary: { total: customers.length, success, failed, duplicateRemoved, invalidRemoved, rateLimitPerMinute: perMinute },
+    data: { details, invalid }
+  });
+});
+
+const qiweiAutoCreateGroup = safeResult(async function qiweiAutoCreateGroup(input = {}) {
+  assertMethodsInCatalog(REQUIRED_METHODS);
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const memberList = Array.isArray(input.memberList)
+    ? input.memberList.map(String).filter(Boolean)
+    : [input.customerUserId || input.externalUserId].map(v => String(v || '')).filter(Boolean);
+  if (!memberList.length) {
+    const { errorResult } = require('../core/result-envelope');
+    return errorResult('缺少 memberList 或 customerUserId/externalUserId');
+  }
+
+  const isOuterRoom = input.isOuterRoom === undefined ? 1 : Number(input.isOuterRoom);
+  const room = await gatewayCall(ctx, REQUIRED_METHODS.createRoom, {
+    guid: ctx.guid,
+    isOuterRoom,
+    memberList
+  });
+  const roomId = room && (room.roomId || room.RoomId || room.id);
+  if (!roomId) {
+    const { errorResult } = require('../core/result-envelope');
+    return errorResult('建群接口未返回 roomId', { data: { room } });
+  }
+
+  const { redactSecret } = require('../providers/fmode-wecom-gateway');
+  const warnings = [];
+  const groupName = String(input.groupName || '').trim();
+  if (groupName) {
+    try {
+      await gatewayCall(ctx, REQUIRED_METHODS.modifyRoomName, { guid: ctx.guid, roomId, name: groupName });
+    } catch (error) {
+      warnings.push(`群名设置失败: ${redactSecret(error && error.message ? error.message : String(error))}`);
+    }
+  }
+
+  const supportMemberIds = Array.isArray(input.supportMemberIds) ? input.supportMemberIds.map(String).filter(Boolean) : [];
+  let supportInvited = false;
+  if (supportMemberIds.length) {
+    try {
+      await gatewayCall(ctx, REQUIRED_METHODS.inviteRoomMember, {
+        guid: ctx.guid,
+        roomId,
+        isOuterRoom,
+        memberList: supportMemberIds
+      });
+      supportInvited = true;
+    } catch (error) {
+      warnings.push(`邀请协作成员失败: ${redactSecret(error && error.message ? error.message : String(error))}`);
+    }
+  }
+
+  let welcomeSent = false;
+  const welcomeText = String(input.welcomeText || '').trim();
+  if (welcomeText) {
+    try {
+      await gatewayCall(ctx, REQUIRED_METHODS.sendText, {
+        guid: ctx.guid,
+        toId: roomId,
+        content: welcomeText,
+        isNoNeedRead: true
+      });
+      welcomeSent = true;
+    } catch (error) {
+      warnings.push(`欢迎语发送失败: ${redactSecret(error && error.message ? error.message : String(error))}`);
+    }
+  }
+
+  return okResult({
+    assistantMessage: `自动建群完成:roomId=${roomId}${groupName ? `,群名=${groupName}` : ''}。`,
+    summary: { roomId, groupName: groupName || null, memberCount: memberList.length + supportMemberIds.length, supportInvited, welcomeSent },
+    data: { roomId, groupName: groupName || null, memberList, supportMemberIds, supportInvited, welcomeSent },
+    warnings
+  });
+});
+
+function friendStatusText(searchStatus) {
+  const status = Number(searchStatus);
+  if (status === 2) return 'already_friend';
+  if (status === 3) return 'already_added_by_other';
+  if (status === 1) return 'not_added';
+  if (status === 0) return 'not_found';
+  return 'unknown';
+}
+
+function pollingIntervalText(ageMinutes, searchStatus) {
+  const status = Number(searchStatus);
+  if (status === 2 || status === 3) return '无需继续轮询';
+  if (ageMinutes > 60) return '超过 1 小时未通过,建议每小时检查一次';
+  return '等待对方通过好友申请,建议每分钟检查一次';
+}
+
+function readSavedPortrait(externalUserId) {
+  if (!externalUserId) return null;
+  const filePath = path.join(outputsRoot(), 'portraits', `${externalUserId}.json`);
+  try {
+    if (!fs.existsSync(filePath)) return null;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return null;
+  }
+}
+
+const qiweiCheckFriendStatus = safeResult(async function qiweiCheckFriendStatus(input = {}) {
+  assertMethodsInCatalog({ searchContact: '/contact/searchContact' });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const normalized = normalizeCustomers(input);
+  const externalUserIds = Array.isArray(input.externalUserIds)
+    ? input.externalUserIds.map(String).filter(Boolean)
+    : [];
+
+  const targets = [];
+  for (const c of normalized.customers) targets.push({ phone: c.phone, name: c.name });
+  for (const id of externalUserIds) targets.push({ externalUserId: id });
+
+  if (!targets.length) return errorResult('缺少待查询客户:请传 customers/phones 或 externalUserIds');
+
+  const details = [];
+  let confirmed = 0;
+  let pending = 0;
+  let notFound = 0;
+
+  for (const target of targets) {
+    if (target.phone) {
+      const data = await gatewayCall(ctx, REQUIRED_METHODS.searchContact, {
+        guid: ctx.guid,
+        keyword: target.phone
+      });
+      const found = classifySearchResult(data);
+      const statusText = friendStatusText(found.searchStatus);
+      if (found.searchStatus === 2 || found.searchStatus === 3) confirmed++;
+      else if (found.searchStatus === 0) notFound++;
+      else pending++;
+      details.push({
+        phone: target.phone,
+        name: target.name,
+        externalUserId: found.externalUserId,
+        searchStatus: found.searchStatus,
+        statusText,
+        message: pollingIntervalText(0, found.searchStatus)
+      });
+    } else {
+      pending++;
+      details.push({
+        externalUserId: target.externalUserId,
+        statusText: 'provided_external_user_id',
+        message: '仅提供 externalUserId,无法直接查询状态,建议结合手机号再查'
+      });
+    }
+  }
+
+  return okResult({
+    assistantMessage: `好友状态检查完成:已确认 ${confirmed},待通过 ${pending},未找到 ${notFound}。`,
+    summary: { total: targets.length, confirmed, pending, notFound },
+    data: { details }
+  });
+});
+
+const qiweiGetCustomerProfile = safeResult(async function qiweiGetCustomerProfile(input = {}) {
+  assertMethodsInCatalog({
+    searchContact: '/contact/searchContact',
+    getWxContactList: '/contact/getWxContactList',
+    getRoomList: '/room/getRoomList'
+  });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const phone = cleanPhone(input.phone);
+  const externalUserId = String(input.externalUserId || '').trim();
+  if (!phone && !externalUserId) return errorResult('缺少 phone 或 externalUserId');
+
+  let contactInfo = null;
+  let resolvedExternalUserId = externalUserId;
+
+  if (phone) {
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.searchContact, {
+      guid: ctx.guid,
+      keyword: phone
+    });
+    contactInfo = classifySearchResult(data);
+    if (contactInfo.externalUserId) resolvedExternalUserId = contactInfo.externalUserId;
+  }
+
+  let contactList = [];
+  try {
+    const listData = await gatewayCall(ctx, REQUIRED_METHODS.getWxContactList, {
+      guid: ctx.guid,
+      currentSeq: 0,
+      limit: 100,
+      bizType: 1
+    });
+    const list = Array.isArray(listData && listData.contactList) ? listData.contactList : [];
+    contactList = resolvedExternalUserId
+      ? list.filter(c => c && (c.userId === resolvedExternalUserId || c.externalUserId === resolvedExternalUserId))
+      : list.slice(0, 5);
+  } catch (error) {
+    // 联系人列表非关键,失败可忽略
+  }
+
+  let rooms = [];
+  try {
+    const roomData = await gatewayCall(ctx, REQUIRED_METHODS.getRoomList, {
+      guid: ctx.guid,
+      nextStartIndex: 0
+    });
+    const roomList = Array.isArray(roomData && roomData.roomList) ? roomData.roomList : [];
+    rooms = roomList.slice(0, 20);
+  } catch (error) {
+    // 群列表非关键,失败可忽略
+  }
+
+  const portrait = readSavedPortrait(resolvedExternalUserId);
+
+  return okResult({
+    assistantMessage: `客户档案查询完成:${resolvedExternalUserId ? `externalUserId=${resolvedExternalUserId}` : '未解析到 externalUserId'}。`,
+    summary: { phone: phone || null, externalUserId: resolvedExternalUserId || null, hasPortrait: !!portrait, contactListCount: contactList.length, roomCount: rooms.length },
+    data: { contact: contactInfo, contactList, rooms, portrait }
+  });
+});
+
+module.exports = {
+  qiweiBatchAddFriends,
+  qiweiAutoCreateGroup,
+  qiweiCheckFriendStatus,
+  qiweiGetCustomerProfile,
+  cleanPhone,
+  normalizeCustomers
+};

+ 208 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js

@@ -0,0 +1,208 @@
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { createRunDir, outputsRoot } = require('../core/output-paths');
+const {
+  buildContext,
+  gatewayCall,
+  requireGuid,
+  assertMethodsInCatalog,
+  safeResult
+} = require('../core/shared-gateway');
+
+const REQUIRED_METHODS = {
+  inviteRoomMember: '/room/inviteRoomMember',
+  removeRoomMember: '/room/removeRoomMember',
+  sendText: '/msg/sendText'
+};
+
+function transfersDir() {
+  return path.join(outputsRoot(), 'transfers');
+}
+
+function ensureTransfersDir() {
+  fs.mkdirSync(transfersDir(), { recursive: true });
+}
+
+function listPreviewFiles() {
+  ensureTransfersDir();
+  return fs.readdirSync(transfersDir())
+    .filter(f => /^preview-.*\.json$/.test(f))
+    .map(f => ({ name: f, path: path.join(transfersDir(), f), mtime: fs.statSync(path.join(transfersDir(), f)).mtime }))
+    .sort((a, b) => b.mtime - a.mtime);
+}
+
+function readPreviewFile(fileNameOrId) {
+  const files = listPreviewFiles();
+  let filePath;
+  if (fileNameOrId) {
+    filePath = path.join(transfersDir(), path.basename(fileNameOrId));
+  } else if (files.length) {
+    filePath = files[0].path;
+  }
+  if (!filePath || !fs.existsSync(filePath)) return null;
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return null;
+  }
+}
+
+function readConfirmedMapping() {
+  const filePath = path.join(outputsRoot(), 'groups', 'confirmed-mapping.json');
+  if (!fs.existsSync(filePath)) return {};
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return {};
+  }
+}
+
+const qiweiPreviewTransferPackage = safeResult(async function qiweiPreviewTransferPackage(input = {}) {
+  const fromUserId = String(input.fromUserId || '').trim();
+  const toUserId = String(input.toUserId || '').trim();
+  const externalUserIds = Array.isArray(input.externalUserIds) ? input.externalUserIds.map(String).filter(Boolean) : [];
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : [];
+
+  if (!fromUserId || !toUserId) return errorResult('缺少 fromUserId 或 toUserId');
+  if (!externalUserIds.length && !roomIds.length) return errorResult('缺少 externalUserIds 或 roomIds');
+
+  const mapping = readConfirmedMapping();
+  const items = [];
+
+  for (const roomId of roomIds) {
+    const mapped = mapping[roomId];
+    items.push({
+      roomId,
+      externalUserId: mapped ? mapped.externalUserId : undefined,
+      customerName: mapped ? mapped.customerName : undefined,
+      fromUserId,
+      toUserId,
+      action: 'transfer_group_ownership'
+    });
+  }
+
+  for (const externalUserId of externalUserIds) {
+    const roomId = roomIds.length ? undefined : Object.entries(mapping).find(([, v]) => v.externalUserId === externalUserId)?.[0];
+    items.push({
+      externalUserId,
+      roomId,
+      fromUserId,
+      toUserId,
+      action: 'transfer_customer'
+    });
+  }
+
+  const preview = {
+    fromUserId,
+    toUserId,
+    createdAt: new Date().toISOString(),
+    status: 'DRAFT',
+    items
+  };
+
+  ensureTransfersDir();
+  const runDir = createRunDir('transfers', `preview-${fromUserId}-to-${toUserId}`);
+  const fileName = `preview-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+  const filePath = path.join(runDir, fileName);
+  fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
+
+  // 同时写入 latest
+  const latestFile = path.join(transfersDir(), fileName);
+  fs.writeFileSync(latestFile, JSON.stringify(preview, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `交接包预览已生成:${items.length} 条记录。`,
+    summary: { fromUserId, toUserId, itemCount: items.length, status: 'DRAFT' },
+    data: { preview, filePath: path.relative(outputsRoot(), filePath) },
+    files: [filePath],
+    nextActions: ['确认后调用 qiwei_execute_transfer 执行交接']
+  });
+});
+
+const qiweiExecuteTransfer = safeResult(async function qiweiExecuteTransfer(input = {}) {
+  assertMethodsInCatalog(REQUIRED_METHODS);
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const previewFile = String(input.previewFile || '').trim() || undefined;
+  const removeOldBroker = input.removeOldBroker === true;
+  const preview = readPreviewFile(previewFile);
+  if (!preview) return errorResult('未找到交接包预览文件,请先调用 qiwei_preview_transfer_package');
+
+  const results = [];
+  let success = 0;
+  let failed = 0;
+
+  for (const item of preview.items) {
+    const roomId = item.roomId;
+    const toUserId = preview.toUserId;
+    const fromUserId = preview.fromUserId;
+    if (!roomId) {
+      results.push({ ...item, status: 'SKIPPED', reason: '缺少 roomId' });
+      continue;
+    }
+
+    try {
+      await gatewayCall(ctx, REQUIRED_METHODS.inviteRoomMember, {
+        guid: ctx.guid,
+        roomId,
+        memberList: [toUserId]
+      });
+
+      if (removeOldBroker) {
+        try {
+          await gatewayCall(ctx, REQUIRED_METHODS.removeRoomMember, {
+            guid: ctx.guid,
+            roomId,
+            memberList: [fromUserId]
+          });
+        } catch (error) {
+          // 移除旧顾问失败不阻断
+        }
+      }
+
+      const welcomeText = String(input.welcomeText || '您好,我是您的新服务顾问,后续由我为您服务。').trim();
+      if (welcomeText) {
+        try {
+          await gatewayCall(ctx, REQUIRED_METHODS.sendText, {
+            guid: ctx.guid,
+            toId: roomId,
+            content: welcomeText,
+            isNoNeedRead: true
+          });
+        } catch (error) {
+          // 欢迎语失败不阻断
+        }
+      }
+
+      results.push({ ...item, status: 'SUCCESS' });
+      success++;
+    } catch (error) {
+      failed++;
+      results.push({ ...item, status: 'FAILED', reason: error && error.message ? error.message : String(error) });
+    }
+  }
+
+  preview.status = 'EXECUTED';
+  preview.executedAt = new Date().toISOString();
+  preview.results = results;
+
+  ensureTransfersDir();
+  const runDir = createRunDir('transfers', `execute-${preview.fromUserId}-to-${preview.toUserId}`);
+  const fileName = `execution-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+  const filePath = path.join(runDir, fileName);
+  fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `交接执行完成:成功 ${success},失败 ${failed}。`,
+    summary: { success, failed, total: preview.items.length, removeOldBroker },
+    data: { results, filePath: path.relative(outputsRoot(), filePath) },
+    files: [filePath]
+  });
+});
+
+module.exports = {
+  qiweiPreviewTransferPackage,
+  qiweiExecuteTransfer
+};

+ 942 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js

@@ -0,0 +1,942 @@
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
+const {
+  buildContext,
+  gatewayCall,
+  requireGuid,
+  assertMethodsInCatalog,
+  safeResult
+} = require('../core/shared-gateway');
+
+const REQUIRED_METHODS = {
+  getRoomList: '/room/getRoomList',
+  batchGetRoomDetail: '/room/batchGetRoomDetail',
+  syncMsg: '/msg/syncMsg',
+  getSessionPage: '/session/getSessionPage'
+};
+
+const DEFAULT_KEYWORD_CONFIG = {
+  matchMode: 'threshold',
+  threshold: 2,
+  highConfidenceTerms: ['客户群', '服务群'],
+  keywords: [
+    '客户群', '服务群', '售后群', '咨询群', 'VIP群', '专属群',
+    '业主群', '项目群', '订单群', '用户群', '粉丝群', '会员群',
+    '客户', '服务', '售后', '咨询'
+  ]
+};
+
+function decodeRoomName(raw) {
+  if (!raw) return '';
+  try {
+    const decoded = Buffer.from(raw, 'base64').toString('utf8');
+    if (decoded && /[一-龥]/.test(decoded)) return decoded;
+  } catch {
+    // ignore
+  }
+  return raw;
+}
+
+function groupsDir() {
+  return path.join(outputsRoot(), 'groups');
+}
+
+function messagesDir() {
+  return path.join(outputsRoot(), 'messages');
+}
+
+function ensureGroupsDir() {
+  fs.mkdirSync(groupsDir(), { recursive: true });
+}
+
+function ensureMessagesDir() {
+  fs.mkdirSync(messagesDir(), { recursive: true });
+}
+
+function safeReadJson(filePath, fallback) {
+  if (!filePath || !fs.existsSync(filePath)) return fallback;
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+function customerKeywordsPath() {
+  return path.join(groupsDir(), 'customer-keywords.json');
+}
+
+function loadCustomerKeywords() {
+  const stored = safeReadJson(customerKeywordsPath(), null);
+  if (!stored || !stored.config) return { ...DEFAULT_KEYWORD_CONFIG };
+  return {
+    matchMode: stored.config.matchMode === 'any' ? 'any' : 'threshold',
+    threshold: Math.max(1, Number(stored.config.threshold) || DEFAULT_KEYWORD_CONFIG.threshold),
+    highConfidenceTerms: Array.isArray(stored.config.highConfidenceTerms)
+      ? stored.config.highConfidenceTerms
+      : DEFAULT_KEYWORD_CONFIG.highConfidenceTerms,
+    keywords: Array.isArray(stored.config.keywords)
+      ? stored.config.keywords
+      : DEFAULT_KEYWORD_CONFIG.keywords
+  };
+}
+
+function saveCustomerKeywords(config) {
+  ensureGroupsDir();
+  const payload = {
+    version: '1.0',
+    updatedAt: new Date().toISOString(),
+    config: {
+      matchMode: config.matchMode === 'any' ? 'any' : 'threshold',
+      threshold: Math.max(1, Number(config.threshold) || DEFAULT_KEYWORD_CONFIG.threshold),
+      highConfidenceTerms: Array.isArray(config.highConfidenceTerms)
+        ? config.highConfidenceTerms
+        : DEFAULT_KEYWORD_CONFIG.highConfidenceTerms,
+      keywords: Array.isArray(config.keywords)
+        ? config.keywords
+        : DEFAULT_KEYWORD_CONFIG.keywords
+    }
+  };
+  fs.writeFileSync(customerKeywordsPath(), JSON.stringify(payload, null, 2), 'utf8');
+  return payload.config;
+}
+
+function buildClassifierConfig(input = {}) {
+  const persisted = loadCustomerKeywords();
+  const matchMode = input.matchMode === 'any' || input.matchMode === 'threshold'
+    ? input.matchMode
+    : persisted.matchMode;
+  const threshold = Math.max(1, Number(input.threshold || persisted.threshold) || 1);
+
+  return {
+    keywords: Array.isArray(input.customerKeywords) && input.customerKeywords.length
+      ? input.customerKeywords
+      : persisted.keywords,
+    highConfidenceTerms: Array.isArray(input.highConfidenceTerms) && input.highConfidenceTerms.length
+      ? input.highConfidenceTerms
+      : persisted.highConfidenceTerms,
+    matchMode,
+    threshold
+  };
+}
+
+function normalizeMatchResult(classification) {
+  return {
+    isCustomerGroup: Boolean(classification.isCustomerGroup),
+    reviewStatus: classification.reviewStatus || 'IMPORTED',
+    confidence: typeof classification.confidence === 'number' ? classification.confidence : 0,
+    reason: classification.reason || '',
+    matchedKeywords: Array.isArray(classification.matchedKeywords) ? classification.matchedKeywords : []
+  };
+}
+
+function classifyGroupByName(roomName, classifierConfig = buildClassifierConfig()) {
+  const text = String(roomName || '');
+  const { keywords, highConfidenceTerms, matchMode, threshold } = classifierConfig;
+
+  const highMatched = [...new Set((highConfidenceTerms || []).filter(kw => text.includes(kw)))];
+  if (highMatched.length > 0) {
+    return normalizeMatchResult({
+      isCustomerGroup: true,
+      reviewStatus: 'AUTO_CONFIRMED',
+      confidence: 1.0,
+      reason: `高置信命中:${highMatched.slice(0, 5).join('、')}`,
+      matchedKeywords: highMatched
+    });
+  }
+
+  const matched = [...new Set((keywords || []).filter(kw => text.includes(kw)))];
+  const hitCount = matched.length;
+
+  if (matchMode === 'any') {
+    if (hitCount > 0) {
+      return normalizeMatchResult({
+        isCustomerGroup: true,
+        reviewStatus: 'AUTO_CONFIRMED',
+        confidence: 0.8,
+        reason: `命中关键词:${matched.slice(0, 5).join('、')}`,
+        matchedKeywords: matched
+      });
+    }
+  } else if (hitCount >= threshold) {
+    return normalizeMatchResult({
+      isCustomerGroup: true,
+      reviewStatus: 'AUTO_CONFIRMED',
+      confidence: Math.min(0.95, 0.5 + 0.12 * hitCount),
+      reason: `命中 ${hitCount} 个关键词(阈值 ${threshold})`,
+      matchedKeywords: matched
+    });
+  } else if (hitCount > 0) {
+    return normalizeMatchResult({
+      isCustomerGroup: true,
+      reviewStatus: 'SUGGESTED',
+      confidence: 0.35,
+      reason: `仅命中 ${hitCount} 个弱关键词,建议人工确认`,
+      matchedKeywords: matched
+    });
+  }
+
+  return normalizeMatchResult({
+    isCustomerGroup: false,
+    reviewStatus: 'IMPORTED',
+    confidence: 0,
+    reason: '未命中客户群关键词',
+    matchedKeywords: []
+  });
+}
+
+function createRoomRecord(raw, source) {
+  const members = Array.isArray(raw.roomMemberList)
+    ? raw.roomMemberList
+    : Array.isArray(raw.memberList)
+      ? raw.memberList
+      : Array.isArray(raw.members)
+        ? raw.members
+        : [];
+  return {
+    roomId: String(raw.roomId || ''),
+    roomName: decodeRoomName(raw.roomName || ''),
+    memberCount: Number(raw.roomMemberCount) || 0,
+    roomHeadimgUrl: raw.roomHeadimgUrl || raw.roomAvatarUrl || undefined,
+    roomExtType: raw.roomExtType !== undefined ? Number(raw.roomExtType) : undefined,
+    members: members.map(m => ({
+      userId: String(m.userId || m.wxId || m.id || ''),
+      userName: String(m.userName || m.nickname || m.name || ''),
+      type: Number(m.userType || m.type || m.memberType || 0)
+    })),
+    source,
+    sources: [source],
+    seenAt: new Date().toISOString()
+  };
+}
+
+function dedupRooms(rooms) {
+  const map = new Map();
+  for (const room of rooms) {
+    if (!room.roomId) continue;
+    const existing = map.get(room.roomId);
+    if (!existing) {
+      map.set(room.roomId, room);
+    } else {
+      existing.sources = [...new Set([...existing.sources, ...room.sources])];
+      existing.source = existing.sources.join(',');
+      if (room.roomName && room.roomName !== existing.roomName) {
+        existing.roomName = room.roomName;
+      }
+      if (room.memberCount && room.memberCount > existing.memberCount) {
+        existing.memberCount = room.memberCount;
+      }
+      if (room.roomHeadimgUrl && !existing.roomHeadimgUrl) {
+        existing.roomHeadimgUrl = room.roomHeadimgUrl;
+      }
+      if (room.roomExtType !== undefined && existing.roomExtType === undefined) {
+        existing.roomExtType = room.roomExtType;
+      }
+      if (room.seenAt && (!existing.seenAt || room.seenAt > existing.seenAt)) {
+        existing.seenAt = room.seenAt;
+      }
+      if (Array.isArray(room.members) && room.members.length > (existing.members?.length || 0)) {
+        existing.members = room.members;
+      }
+    }
+  }
+  return Array.from(map.values());
+}
+
+function isExternalRoom(room, includeInternalGroups = false) {
+  if (includeInternalGroups) return true;
+  return room.roomExtType === undefined || room.roomExtType === 2;
+}
+
+async function scanRoomsFromRoomList(ctx, maxPages = 100) {
+  assertMethodsInCatalog({ getRoomList: REQUIRED_METHODS.getRoomList });
+  const rooms = [];
+  let nextStartIndex = 0;
+  let hasMore = true;
+  let pages = 0;
+
+  while (hasMore && pages < maxPages) {
+    pages++;
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.getRoomList, {
+      guid: ctx.guid,
+      nextStartIndex
+    });
+    const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
+    for (const room of roomList) {
+      rooms.push(createRoomRecord(room, 'roomList'));
+    }
+    hasMore = data.hasMore === 1 || data.hasMore === true;
+    nextStartIndex = data.nextStartIndex ?? -1;
+    if (!roomList.length || nextStartIndex < 0) break;
+  }
+
+  return { rooms, pages };
+}
+
+function extractRoomIdFromSession(session) {
+  if (!session) return '';
+  return String(session.roomId || session.sessionId || session.fromRoomId || session.id || '');
+}
+
+function isGroupSession(session) {
+  if (!session) return false;
+  if (session.sessionType === 1 || session.chatType === 2 || session.isGroup === true) return true;
+  return false;
+}
+
+async function scanRoomsFromSessions(ctx, maxPages = 100) {
+  assertMethodsInCatalog({ getSessionPage: REQUIRED_METHODS.getSessionPage });
+  const rooms = [];
+  let currentSeq = 0;
+  let hasMore = true;
+  let pages = 0;
+
+  while (hasMore && pages < maxPages) {
+    pages++;
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.getSessionPage, {
+      guid: ctx.guid,
+      sessionType: 1,
+      currentSeq
+    });
+    const sessionList = Array.isArray(data && data.sessionList)
+      ? data.sessionList
+      : Array.isArray(data && data.list)
+        ? data.list
+        : [];
+
+    for (const session of sessionList) {
+      const roomId = extractRoomIdFromSession(session);
+      if (!roomId || !isGroupSession(session)) continue;
+      const roomName = decodeRoomName(session.sessionName || session.roomName || '');
+      if (!roomName) continue; // 跳过无名称的会话(通常是单聊或系统通知)
+      rooms.push({
+        roomId,
+        roomName,
+        memberCount: Number(session.roomMemberCount || session.memberCount) || 0,
+        roomHeadimgUrl: session.roomHeadimgUrl || session.sessionAvatar || undefined,
+        roomExtType: session.roomExtType !== undefined ? Number(session.roomExtType) : undefined,
+        source: 'session',
+        sources: ['session'],
+        seenAt: new Date().toISOString()
+      });
+    }
+
+    hasMore = data.hasMore === 1 || data.hasMore === true;
+    currentSeq = data.currentSeq ?? -1;
+    if (!sessionList.length || currentSeq < 0) break;
+  }
+
+  return { rooms, pages };
+}
+
+async function enrichRoomsWithDetails(ctx, roomIds, chunkSize = 50) {
+  if (!roomIds.length) return [];
+  assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
+  const rooms = [];
+  for (let i = 0; i < roomIds.length; i += chunkSize) {
+    const chunk = roomIds.slice(i, i + chunkSize);
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.batchGetRoomDetail, {
+      guid: ctx.guid,
+      roomIdList: chunk
+    });
+    const roomList = Array.isArray(data && data.roomList) ? data.roomList : [];
+    for (const room of roomList) {
+      rooms.push(createRoomRecord(room, 'messages'));
+    }
+  }
+  return rooms;
+}
+
+async function scanRoomsFromMessages(ctx, maxPages = 300, maxTotalMessages = 20000) {
+  assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
+  const roomIds = new Set();
+  let msgSeq = 0;
+  let hasMore = true;
+  let pages = 0;
+  let totalMessages = 0;
+
+  while (hasMore && pages < maxPages && totalMessages < maxTotalMessages) {
+    pages++;
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
+      guid: ctx.guid,
+      msgSeq,
+      limit: 500
+    });
+    const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
+    hasMore = Boolean(data.hasMore);
+    msgSeq = data.travelSyncKey ?? msgSeq + 1;
+    totalMessages += msgList.length;
+
+    for (const msg of msgList) {
+      const roomId = String(msg.fromRoomId || '');
+      if (roomId) roomIds.add(roomId);
+    }
+
+    if (!msgList.length) break;
+  }
+
+  const roomIdList = Array.from(roomIds);
+  const details = await enrichRoomsWithDetails(ctx, roomIdList, 50);
+  return { rooms: details, pages, rawRoomIds: roomIdList, totalMessages };
+}
+
+function listGroupFiles() {
+  ensureGroupsDir();
+  const files = fs.readdirSync(groupsDir())
+    .filter(f => /^rooms-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.json$/.test(f))
+    .map(f => ({ name: f, path: path.join(groupsDir(), f), mtime: fs.statSync(path.join(groupsDir(), f)).mtime }))
+    .sort((a, b) => b.mtime - a.mtime);
+  return files;
+}
+
+function readLatestRooms() {
+  return safeReadJson(latestPath('groups', 'rooms-latest.json'), []);
+}
+
+function confirmedMappingPath() {
+  ensureGroupsDir();
+  return path.join(groupsDir(), 'confirmed-mapping.json');
+}
+
+function readConfirmedMapping() {
+  return safeReadJson(confirmedMappingPath(), {});
+}
+
+function writeConfirmedMapping(mapping) {
+  ensureGroupsDir();
+  fs.writeFileSync(confirmedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
+}
+
+function rejectedMappingPath() {
+  ensureGroupsDir();
+  return path.join(groupsDir(), 'rejected-mapping.json');
+}
+
+function readRejectedMapping() {
+  return safeReadJson(rejectedMappingPath(), {});
+}
+
+function writeRejectedMapping(mapping) {
+  ensureGroupsDir();
+  fs.writeFileSync(rejectedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
+}
+
+function resolveScope(input) {
+  const validScopes = ['self', 'session', 'messages', 'all'];
+  if (input.scope && validScopes.includes(input.scope)) return input.scope;
+  if (input.fromMessages === true) return 'messages';
+  return 'all';
+}
+
+function timestampFileName() {
+  return `rooms-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+}
+
+function countBySource(rooms) {
+  return rooms.reduce((acc, room) => {
+    for (const s of room.sources || [room.source]) {
+      acc[s] = (acc[s] || 0) + 1;
+    }
+    return acc;
+  }, {});
+}
+
+const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroups(input = {}) {
+  assertMethodsInCatalog({
+    getRoomList: REQUIRED_METHODS.getRoomList,
+    batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail,
+    getSessionPage: REQUIRED_METHODS.getSessionPage,
+    syncMsg: REQUIRED_METHODS.syncMsg
+  });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const scope = resolveScope(input);
+  const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
+  const includeInternalGroups = input.includeInternalGroups === true;
+  const autoClassify = input.autoClassify !== false;
+  const scanFromMessages = input.scanFromMessages === true;
+  const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
+  const previewOnlyForUnsure = input.previewOnlyForUnsure !== false;
+  const classifier = buildClassifierConfig(input);
+
+  const collected = [];
+  const perSource = { roomList: { rooms: [], pages: 0 }, session: { rooms: [], pages: 0 }, messages: { rooms: [], pages: 0, totalMessages: 0 } };
+
+  if (scope === 'self' || scope === 'all') {
+    try {
+      const result = await scanRoomsFromRoomList(ctx, maxPages);
+      perSource.roomList = result;
+      collected.push(...result.rooms);
+    } catch (err) {
+      // 继续其他来源
+    }
+  }
+
+  if (scope === 'session' || scope === 'all') {
+    try {
+      const result = await scanRoomsFromSessions(ctx, maxPages);
+      perSource.session = result;
+      collected.push(...result.rooms);
+    } catch (err) {
+      // 继续其他来源
+    }
+  }
+
+  if (scope === 'messages' || (scope === 'all' && scanFromMessages)) {
+    try {
+      const result = await scanRoomsFromMessages(ctx, maxPages);
+      perSource.messages = result;
+      collected.push(...result.rooms);
+    } catch (err) {
+      // 继续其他来源
+    }
+  }
+
+  const deduped = dedupRooms(collected);
+  const filtered = deduped.filter(r => isExternalRoom(r, includeInternalGroups));
+
+  const classifiedRooms = [];
+  for (const room of filtered) {
+    if (!autoClassify) {
+      classifiedRooms.push({
+        ...room,
+        reviewStatus: 'IMPORTED',
+        confidence: 0,
+        reason: '自动分类已关闭',
+        matchedKeywords: []
+      });
+      continue;
+    }
+
+    let text = room.roomName || '';
+    let classification = classifyGroupByName(text, classifier);
+
+    if (previewMessages > 0 && previewOnlyForUnsure && classification.reviewStatus !== 'AUTO_CONFIRMED') {
+      try {
+        const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
+        if (previews.length) {
+          text += ' ' + previews.join(' ');
+          classification = classifyGroupByName(text, classifier);
+        }
+      } catch {
+        // 忽略消息拉取失败,保留初次分类结果
+      }
+    }
+
+    classifiedRooms.push({ ...room, ...classification });
+  }
+
+  const runDir = createRunDir('groups', 'sync-external-groups');
+  const fileName = timestampFileName();
+  const filePath = path.join(runDir, fileName);
+
+  const manifest = {
+    scope,
+    maxPages,
+    includeInternalGroups,
+    autoClassify,
+    previewMessages,
+    previewOnlyForUnsure,
+    matchMode: classifier.matchMode,
+    threshold: classifier.threshold,
+    total: classifiedRooms.length,
+    sources: countBySource(classifiedRooms),
+    files: [fileName]
+  };
+
+  fs.writeFileSync(filePath, JSON.stringify(classifiedRooms, null, 2), 'utf8');
+  const manifestPath = writeRunManifest(runDir, manifest);
+
+  const latestFile = latestPath('groups', 'rooms-latest.json');
+  fs.writeFileSync(latestFile, JSON.stringify(classifiedRooms, null, 2), 'utf8');
+
+  const scanManifestPath = path.join(groupsDir(), 'group-scan-manifest.json');
+  fs.writeFileSync(scanManifestPath, JSON.stringify({
+    lastRunAt: new Date().toISOString(),
+    lastRunDir: path.relative(outputsRoot(), runDir),
+    scope,
+    total: classifiedRooms.length,
+    autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
+    suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
+    imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length
+  }, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `外部群同步完成:扫描到 ${classifiedRooms.length} 个群(scope=${scope})。`,
+    summary: {
+      scanned: classifiedRooms.length,
+      selfCount: perSource.roomList.rooms.length,
+      sessionCount: perSource.session.rooms.length,
+      messageCount: perSource.messages.rooms.length,
+      mergedCount: deduped.length,
+      externalCount: filtered.length,
+      autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
+      suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
+      imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length,
+      previewMessages,
+      previewOnlyForUnsure,
+      pages: perSource.roomList.pages + perSource.session.pages + perSource.messages.pages
+    },
+    data: {
+      rooms: classifiedRooms.slice(0, 20),
+      filePath: path.relative(outputsRoot(), filePath),
+      manifestPath: path.relative(outputsRoot(), manifestPath)
+    },
+    files: [filePath, manifestPath]
+  });
+});
+
+const qiweiListExternalGroups = safeResult(async function qiweiListExternalGroups(input = {}) {
+  const rooms = readLatestRooms();
+  const confirmed = readConfirmedMapping();
+  const rejected = readRejectedMapping();
+  const classifier = buildClassifierConfig(input);
+
+  let result = rooms.map(room => {
+    const mapped = confirmed[room.roomId];
+    const rejectedRecord = rejected[room.roomId];
+
+    if (rejectedRecord) {
+      return {
+        ...room,
+        reviewStatus: 'REJECTED',
+        confidence: 0,
+        reason: rejectedRecord.reason || '经纪人手动拒绝',
+        matchedKeywords: [],
+        customerId: undefined,
+        externalUserId: undefined
+      };
+    }
+
+    if (mapped) {
+      return {
+        ...room,
+        reviewStatus: 'CONFIRMED',
+        confidence: 1.0,
+        reason: '经纪人手动确认',
+        matchedKeywords: [],
+        customerId: mapped.customerId,
+        externalUserId: mapped.externalUserId,
+        customerName: mapped.customerName
+      };
+    }
+
+    const classification = classifyGroupByName(room.roomName, classifier);
+    return { ...room, ...classification };
+  });
+
+  const keyword = String(input.keyword || '').trim();
+  if (keyword) {
+    result = result.filter(r => (r.roomName || '').includes(keyword) || (r.roomId || '').includes(keyword));
+  }
+
+  const statusFilter = String(input.status || '').trim();
+  if (statusFilter) {
+    result = result.filter(r => r.reviewStatus === statusFilter);
+  }
+
+  const sourceFilter = String(input.source || '').trim();
+  if (sourceFilter) {
+    result = result.filter(r => r.sources && r.sources.includes(sourceFilter));
+  }
+
+  if (input.includeRejected !== true) {
+    result = result.filter(r => r.reviewStatus !== 'REJECTED');
+  }
+
+  return okResult({
+    assistantMessage: `共 ${result.length} 个外部群(已同步 ${rooms.length})。`,
+    summary: {
+      total: result.length,
+      imported: result.filter(r => r.reviewStatus === 'IMPORTED').length,
+      suggested: result.filter(r => r.reviewStatus === 'SUGGESTED').length,
+      autoConfirmed: result.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
+      confirmed: result.filter(r => r.reviewStatus === 'CONFIRMED').length,
+      rejected: result.filter(r => r.reviewStatus === 'REJECTED').length
+    },
+    data: { groups: result }
+  });
+});
+
+async function fetchPreviewMessages(ctx, roomId, previewMessages) {
+  const texts = [];
+  let msgSeq = 0;
+  let hasMore = true;
+  let pages = 0;
+  const maxPages = Math.min(10, Math.ceil(previewMessages / 100));
+
+  while (hasMore && pages < maxPages && texts.length < previewMessages) {
+    pages++;
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
+      guid: ctx.guid,
+      msgSeq,
+      limit: 100
+    });
+    const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
+    hasMore = Boolean(data.hasMore);
+    msgSeq = data.travelSyncKey ?? msgSeq + 1;
+
+    for (const msg of msgList) {
+      if (String(msg.fromRoomId || '') !== roomId) continue;
+      const content = String(msg.content || msg.msgContent || '').trim();
+      if (content) texts.push(content);
+      if (texts.length >= previewMessages) break;
+    }
+
+    if (!msgList.length) break;
+  }
+
+  return texts;
+}
+
+const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMembers(input = {}) {
+  assertMethodsInCatalog({ batchGetRoomDetail: REQUIRED_METHODS.batchGetRoomDetail });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : [];
+  if (!roomIds.length) return errorResult('缺少 roomIds');
+
+  const classifier = buildClassifierConfig(input);
+  const autoClassify = input.autoClassify !== false;
+  const previewMessages = Math.max(0, Math.min(100, Number(input.previewMessages || 0)));
+  const updateSnapshot = input.updateSnapshot === true;
+
+  const details = await enrichRoomsWithDetails(ctx, roomIds, 20);
+
+  const classified = [];
+  for (const room of details) {
+    let text = room.roomName || '';
+    if (previewMessages > 0) {
+      try {
+        const previews = await fetchPreviewMessages(ctx, room.roomId, previewMessages);
+        if (previews.length) text += ' ' + previews.join(' ');
+      } catch {
+        // 忽略消息拉取失败
+      }
+    }
+
+    if (!autoClassify) {
+      classified.push({ ...room, reviewStatus: 'IMPORTED', confidence: 0, reason: '自动分类已关闭', matchedKeywords: [] });
+      continue;
+    }
+
+    const classification = classifyGroupByName(text, classifier);
+    classified.push({ ...room, ...classification });
+  }
+
+  if (updateSnapshot) {
+    const snapshot = readLatestRooms();
+    const snapshotMap = new Map(snapshot.map(r => [r.roomId, r]));
+    for (const room of classified) {
+      snapshotMap.set(room.roomId, room);
+    }
+    fs.writeFileSync(latestPath('groups', 'rooms-latest.json'), JSON.stringify(Array.from(snapshotMap.values()), null, 2), 'utf8');
+  }
+
+  return okResult({
+    assistantMessage: `群成员分析完成:分析 ${classified.length} 个群。`,
+    summary: {
+      analyzed: classified.length,
+      autoConfirmed: classified.filter(d => d.reviewStatus === 'AUTO_CONFIRMED').length,
+      suggested: classified.filter(d => d.reviewStatus === 'SUGGESTED').length,
+      imported: classified.filter(d => d.reviewStatus === 'IMPORTED').length
+    },
+    data: { details: classified }
+  });
+});
+
+function updateConfirmedMapping(roomId, fields) {
+  const mapping = readConfirmedMapping();
+  mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, confirmedAt: new Date().toISOString() };
+  writeConfirmedMapping(mapping);
+  return mapping[roomId];
+}
+
+const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternalGroup(input = {}) {
+  const roomId = String(input.roomId || '').trim();
+  if (!roomId) return errorResult('缺少 roomId');
+
+  const customerId = String(input.customerId || '').trim() || undefined;
+  const externalUserId = String(input.externalUserId || '').trim() || undefined;
+  const customerName = String(input.customerName || '').trim() || undefined;
+
+  const rooms = readLatestRooms();
+  const room = rooms.find(r => r.roomId === roomId);
+  if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中,请先调用 qiwei_sync_external_groups 或改用 qiwei_add_external_group`);
+
+  const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName: room.roomName });
+
+  return okResult({
+    assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
+    summary: { roomId, roomName: room.roomName, reviewStatus: 'CONFIRMED' },
+    data: record
+  });
+});
+
+const qiweiAddExternalGroup = safeResult(async function qiweiAddExternalGroup(input = {}) {
+  const roomId = String(input.roomId || '').trim();
+  if (!roomId) return errorResult('缺少 roomId');
+
+  const customerId = String(input.customerId || '').trim() || undefined;
+  const externalUserId = String(input.externalUserId || '').trim() || undefined;
+  const customerName = String(input.customerName || '').trim() || undefined;
+  const roomName = String(input.roomName || '').trim() || undefined;
+
+  const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName });
+
+  return okResult({
+    assistantMessage: `已手动添加外部群:${roomName || roomId}。`,
+    summary: { roomId, reviewStatus: 'CONFIRMED' },
+    data: record
+  });
+});
+
+const qiweiConfigureGroupKeywords = safeResult(async function qiweiConfigureGroupKeywords(input = {}) {
+  if (input.reset === true) {
+    saveCustomerKeywords(DEFAULT_KEYWORD_CONFIG);
+    return okResult({
+      assistantMessage: '已重置为客户群关键词默认配置。',
+      summary: { ...DEFAULT_KEYWORD_CONFIG, keywordCount: DEFAULT_KEYWORD_CONFIG.keywords.length },
+      data: { config: DEFAULT_KEYWORD_CONFIG }
+    });
+  }
+
+  const current = loadCustomerKeywords();
+  const next = {
+    matchMode: input.matchMode === 'any' || input.matchMode === 'threshold'
+      ? input.matchMode
+      : current.matchMode,
+    threshold: Math.max(1, Number(input.threshold || current.threshold) || 1),
+    highConfidenceTerms: Array.isArray(input.highConfidenceTerms)
+      ? input.highConfidenceTerms
+      : current.highConfidenceTerms,
+    keywords: Array.isArray(input.keywords)
+      ? input.keywords
+      : current.keywords
+  };
+
+  saveCustomerKeywords(next);
+
+  return okResult({
+    assistantMessage: `已更新客户群关键词配置:模式 ${next.matchMode},关键词 ${next.keywords.length} 个,高置信度 ${next.highConfidenceTerms.length} 个。`,
+    summary: {
+      matchMode: next.matchMode,
+      threshold: next.threshold,
+      keywordCount: next.keywords.length,
+      highConfidenceCount: next.highConfidenceTerms.length
+    },
+    data: { config: next }
+  });
+});
+
+const qiweiRejectExternalGroup = safeResult(async function qiweiRejectExternalGroup(input = {}) {
+  const roomId = String(input.roomId || '').trim();
+  if (!roomId) return errorResult('缺少 roomId');
+
+  const rooms = readLatestRooms();
+  const room = rooms.find(r => r.roomId === roomId);
+  if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中`);
+
+  const mapping = readRejectedMapping();
+  mapping[roomId] = {
+    roomId,
+    roomName: room.roomName,
+    rejectedAt: new Date().toISOString(),
+    reason: String(input.reason || '经纪人手动拒绝')
+  };
+  writeRejectedMapping(mapping);
+
+  return okResult({
+    assistantMessage: `已拒绝外部群:${room.roomName || roomId}。`,
+    summary: { roomId, roomName: room.roomName, reviewStatus: 'REJECTED' },
+    data: mapping[roomId]
+  });
+});
+
+const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(input = {}) {
+  assertMethodsInCatalog({ syncMsg: REQUIRED_METHODS.syncMsg });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String).filter(Boolean) : null;
+  const maxPages = Math.max(1, Math.min(300, Number(input.maxPages || 100)));
+  const maxMessagesPerRoom = Math.max(1, Math.min(5000, Number(input.maxMessagesPerRoom || 1000)));
+
+  let targetRoomIds = roomIds;
+  if (!targetRoomIds) {
+    const mapping = readConfirmedMapping();
+    targetRoomIds = Object.keys(mapping);
+    if (!targetRoomIds.length) return errorResult('未指定 roomIds 且没有已确认的客户群映射,请先调用 qiwei_confirm_external_group');
+  }
+
+  const results = [];
+  for (const roomId of targetRoomIds) {
+    let hasMore = true;
+    let msgSeq = 0;
+    let pages = 0;
+    let newCount = 0;
+    const messages = [];
+
+    while (hasMore && pages < maxPages && newCount < maxMessagesPerRoom) {
+      pages++;
+      const data = await gatewayCall(ctx, REQUIRED_METHODS.syncMsg, {
+        guid: ctx.guid,
+        msgSeq,
+        limit: 200
+      });
+      const msgList = Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [];
+      hasMore = Boolean(data.hasMore);
+      msgSeq = data.travelSyncKey ?? msgSeq + 1;
+
+      for (const msg of msgList) {
+        const msgRoomId = String(msg.fromRoomId || '');
+        if (msgRoomId !== roomId) continue;
+        messages.push({
+          msgId: msg.msgUniqueIdentifier || msg.msgServerId || `${roomId}_${msg.seq}`,
+          seq: msg.seq,
+          senderId: msg.senderId || '',
+          senderName: msg.senderName || '',
+          msgType: String(msg.msgType),
+          content: msg.content || msg.msgContent || '',
+          timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString()
+        });
+        newCount++;
+      }
+
+      if (!msgList.length) break;
+    }
+
+    if (messages.length) {
+      ensureMessagesDir();
+      const fileName = `room-${roomId}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
+      const filePath = path.join(messagesDir(), fileName);
+      fs.writeFileSync(filePath, JSON.stringify(messages, null, 2), 'utf8');
+      results.push({ roomId, newCount: messages.length, filePath: path.relative(outputsRoot(), filePath) });
+    } else {
+      results.push({ roomId, newCount: 0, filePath: null });
+    }
+  }
+
+  const totalNew = results.reduce((sum, r) => sum + r.newCount, 0);
+  return okResult({
+    assistantMessage: `群消息同步完成:${targetRoomIds.length} 个群,共 ${totalNew} 条新消息。`,
+    summary: { rooms: targetRoomIds.length, totalNew, syncedRooms: results.filter(r => r.newCount > 0).length },
+    data: { results },
+    files: results.filter(r => r.filePath).map(r => path.join(outputsRoot(), r.filePath))
+  });
+});
+
+module.exports = {
+  qiweiSyncExternalGroups,
+  qiweiListExternalGroups,
+  qiweiAnalyzeGroupMembers,
+  qiweiConfirmExternalGroup,
+  qiweiAddExternalGroup,
+  qiweiConfigureGroupKeywords,
+  qiweiRejectExternalGroup,
+  qiweiSyncGroupMessages
+};

+ 104 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js

@@ -5,13 +5,17 @@ const {
   readQiweiAuthToken,
   ensureQiweiUid,
   readQiweiApiBase,
-  saveQiweiClientConfig
+  saveQiweiClientConfig,
+  readQiweiGuid
 } = require('../core/credentials');
 const { callFmodeWecomGateway, redactSecret } = require('../providers/fmode-wecom-gateway');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { latestPath } = require('../core/output-paths');
 const { saveSubscribePage, 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 { ensureRelayWebhookConfigured, registerRelayTenant } = require('./qiwei-webhook-relay-run');
 
 const QR_STATUS = {
   '-1': '未登录,需要扫码登录',
@@ -39,6 +43,27 @@ function authRequiredResult() {
   };
 }
 
+async function ensureRelayAndCallback({ token, apiBase, uid, guid, input }) {
+  if (!guid) {
+    throw new Error('缺少 guid,无法配置 Relay 回调');
+  }
+
+  // 如果还没有租户凭证,先注册
+  const relayCreds = require('../core/relay-config');
+  if (!relayCreds.isRelayEnabled()) {
+    try {
+      await registerRelayTenant({ ...input, guid, description: '本地 Skill 自动注册' });
+    } catch (err) {
+      // 如果已经注册且 force 未开启,可能失败;继续尝试用本地已有凭证配置回调
+      if (!relayCreds.getTenantId() || !relayCreds.getTenantApiSecret()) {
+        throw err;
+      }
+    }
+  }
+
+  return ensureRelayWebhookConfigured({ ...input, guid, authToken: token, apiBase, uid });
+}
+
 function saveQrcodeImage(base64Data) {
   try {
     const filePath = latestPath('login', 'qiwei-login-qrcode.png');
@@ -262,6 +287,53 @@ async function qiweiLoginStart(input = {}) {
       apiBase
     });
     const data = result.data || {};
+    const qrcodeBase64 = String(data.loginQrcodeBase64Data || '').replace(/^data:image\/\w+;base64,/, '');
+    const qrcodeBuffer = qrcodeBase64 ? Buffer.from(qrcodeBase64, 'base64') : null;
+
+    // 优先启动可交互的 fallback 本地服务,让用户能在网页里直接输入验证码
+    if (qrcodeBuffer) {
+      try {
+        const { url } = await startLoginFallbackServer({
+          token,
+          apiBase,
+          uid,
+          qrcodeBuffer,
+          port: 0,
+          onQrcode: (buffer) => {
+            try {
+              fs.writeFileSync(latestPath('login', 'qiwei-login-qrcode.png'), buffer);
+            } catch {}
+          }
+        });
+        const opened = input.openBrowser === false ? false : openInBrowser(url);
+        return okResult({
+          assistantMessage: [
+            `企业微信登录二维码已由 Fmode 网关生成(uid: ${uid})。`,
+            opened
+              ? `已自动打开扫码登录页:${url}`
+              : `请打开扫码登录页:${url}`,
+            '页面会自动检测扫码状态;若手机端需要验证码,可直接在页面输入 6 位数字。'
+          ].join('\n'),
+          summary: {
+            uid,
+            flowUrl: url,
+            fallbackServer: true,
+            browserOpened: opened
+          },
+          data: {
+            uid,
+            flowUrl: url,
+            fallbackServer: true,
+            browserOpened: opened
+          },
+          files: [],
+          nextActions: ['在页面中完成扫码与验证码输入', '调用 qiwei_login_check 确认状态']
+        });
+      } catch {
+        // fallback server 启动失败时继续走纯静态兜底
+      }
+    }
+
     const filePath = saveQrcodeImage(data.loginQrcodeBase64Data);
     const htmlPath = filePath ? saveQrcodePreviewHtml(filePath) : '';
     const opened = input.openBrowser === false ? false : Boolean(htmlPath && openInBrowser(htmlPath));
@@ -280,7 +352,8 @@ async function qiweiLoginStart(input = {}) {
         uid,
         qrcodeFile: filePath || null,
         previewFile: htmlPath || null,
-        browserOpened: opened
+        browserOpened: opened,
+        fallbackServer: false
       },
       data: {
         uid,
@@ -317,17 +390,43 @@ async function qiweiLoginCheck(input = {}) {
     const statusText = QR_STATUS[statusCode] || `未知状态 ${statusCode}`;
     const detail = data.detail || {};
     if (statusCode === '2') {
+      const guid = String(detail.guid || readQiweiGuid(input) || '').trim();
+      if (guid) {
+        saveQiweiClientConfig({ guid, apiBase });
+      }
+
+      let relaySetup = null;
+      if (isRelayEnabled() || process.env.RELAY_BASE_URL) {
+        try {
+          relaySetup = await ensureRelayAndCallback({ token, apiBase, uid, guid, input });
+        } catch (err) {
+          console.warn('[Login] Relay 自动配置失败:', err.message);
+          relaySetup = { success: false, error: err.message };
+        }
+      }
+
+      const assistantMessage = relaySetup?.success
+        ? `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。Relay 回调已自动配置:${relaySetup.callbackUrl}。请在服务器上执行 npm run relay 启动轮询。`
+        : `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。后续业务请求将通过 Fmode 网关转发的企业微信接口执行。`;
+
       return okResult({
-        assistantMessage: `企业微信登录成功!账号:${detail.nickname || detail.userId || '未知'}。后续业务请求将通过 Fmode 网关转发的企业微信接口执行。`,
+        assistantMessage,
         summary: {
           loggedIn: true,
           uid,
           statusCode: 2,
           userId: detail.userId,
           nickname: detail.nickname,
-          corpId: detail.corpId
+          corpId: detail.corpId,
+          guid,
+          relaySetup: relaySetup
+            ? { success: relaySetup.success, callbackUrl: relaySetup.callbackUrl, error: relaySetup.error }
+            : null
         },
-        data: { uid, status: detail }
+        data: { uid, status: detail, guid, relaySetup },
+        nextActions: relaySetup?.success
+          ? ['在服务器上执行 npm run relay 启动轮询客户端']
+          : []
       });
     }
     if (statusCode === '10') {

+ 534 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -0,0 +1,534 @@
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { createRunDir, outputsRoot } = require('../core/output-paths');
+const {
+  buildContext,
+  gatewayCall,
+  requireGuid,
+  assertMethodsInCatalog,
+  safeResult
+} = require('../core/shared-gateway');
+
+const REQUIRED_METHODS = {
+  syncLabelList: '/label/syncLabelList',
+  editLabel: '/label/editLabel',
+  contactEditLabel: '/label/contactEditLabel'
+};
+
+const PORTRAIT_FIELDS = [
+  'intent', 'budgetRange', 'preferredAreas', 'houseType', 'timeline', 'keyConcerns',
+  'urgency', 'aiSummary', 'personaType', 'fiveW2H', 'priorityMatrix', 'confidence',
+  'coreAnxiety', 'decisionMaker', 'loanCapacity', 'negotiationStage'
+];
+
+const PORTRAIT_KEYWORDS = {
+  intent: ['买房', '购房', '租房', '出租', '出售', '置换', '投资'],
+  budgetRange: ['预算', '万', '总价', '首付', '月供', '贷款'],
+  timeline: ['尽快', '着急', '最近', '这个月', '下个月', '半年', '今年', '年底', '明年初'],
+  keyConcerns: ['学区', '学校', '地铁', '医院', '商圈', '装修', '物业', '停车', '电梯', '采光'],
+  urgency: ['着急', '马上', '尽快', '急']
+};
+
+function portraitsDir() {
+  return path.join(outputsRoot(), 'portraits');
+}
+
+function tagsDir() {
+  return path.join(outputsRoot(), 'tags');
+}
+
+function ensurePortraitsDir() {
+  fs.mkdirSync(portraitsDir(), { recursive: true });
+}
+
+function ensureTagsDir() {
+  fs.mkdirSync(tagsDir(), { recursive: true });
+}
+
+function portraitFilePath(externalUserId) {
+  ensurePortraitsDir();
+  return path.join(portraitsDir(), `${externalUserId}.json`);
+}
+
+function readPortrait(externalUserId) {
+  const filePath = portraitFilePath(externalUserId);
+  if (!fs.existsSync(filePath)) return null;
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return null;
+  }
+}
+
+function writePortrait(externalUserId, portrait) {
+  const filePath = portraitFilePath(externalUserId);
+  fs.writeFileSync(filePath, JSON.stringify(portrait, null, 2), 'utf8');
+  return filePath;
+}
+
+function tagFilePath(externalUserId) {
+  ensureTagsDir();
+  return path.join(tagsDir(), `${externalUserId}.json`);
+}
+
+function readTags(externalUserId) {
+  const filePath = tagFilePath(externalUserId);
+  if (!fs.existsSync(filePath)) return [];
+  try {
+    const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
+    return Array.isArray(data.tags) ? data.tags : [];
+  } catch {
+    return [];
+  }
+}
+
+function writeTags(externalUserId, tags) {
+  const filePath = tagFilePath(externalUserId);
+  fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: [...new Set(tags)], updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+  return filePath;
+}
+
+function listMessageFiles() {
+  const dir = path.join(outputsRoot(), 'messages');
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => f.startsWith('room-') && f.endsWith('.json'))
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
+    .sort((a, b) => b.mtime - a.mtime);
+}
+
+function extractRoomIdFromFileName(fileName) {
+  const match = fileName.match(/^room-([^-]+)-/);
+  return match ? match[1] : null;
+}
+
+function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
+  const files = listMessageFiles();
+  const messages = [];
+  for (const file of files) {
+    const roomId = extractRoomIdFromFileName(file.name);
+    if (roomIds && !roomIds.includes(roomId)) continue;
+    try {
+      const data = JSON.parse(fs.readFileSync(file.path, 'utf8'));
+      if (!Array.isArray(data)) continue;
+      for (const msg of data) {
+        if (msg.senderId === externalUserId) {
+          messages.push({ ...msg, roomId });
+        }
+      }
+    } catch {
+      // ignore
+    }
+  }
+  return messages;
+}
+
+function simpleKeywordPortrait(messages) {
+  const text = messages.map(m => m.content || '').join(' ');
+  const portrait = {};
+  for (const [field, keywords] of Object.entries(PORTRAIT_KEYWORDS)) {
+    const matched = keywords.filter(kw => text.includes(kw));
+    if (matched.length) {
+      portrait[field] = {
+        inferred: true,
+        keywords: [...new Set(matched)],
+        note: `从 ${messages.length} 条消息中推断`
+      };
+    }
+  }
+  if (!Object.keys(portrait).length) {
+    portrait.aiSummary = { note: `收集了 ${messages.length} 条消息,未识别到明确画像关键词` };
+  }
+  return portrait;
+}
+
+function buildPortraitContext(externalUserId, messages) {
+  const text = messages.map(m => m.content || '').join('\n');
+  return {
+    externalUserId,
+    messageCount: messages.length,
+    sampleMessages: messages.slice(0, 50),
+    fullTextPreview: text.slice(0, 8000),
+    suggestedAnalysisDimensions: PORTRAIT_FIELDS,
+    hint: '请基于 sampleMessages 和 fullTextPreview 分析客户画像,返回 JSON 后调用 qiwei_save_customer_portrait 保存。'
+  };
+}
+
+const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCustomerPortrait(input = {}) {
+  const externalUserId = String(input.externalUserId || '').trim();
+  if (!externalUserId) return errorResult('缺少 externalUserId');
+
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
+  const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
+
+  const context = buildPortraitContext(externalUserId, messages);
+  ensurePortraitsDir();
+  const runDir = createRunDir('portraits', `context-${externalUserId}`);
+  const filePath = path.join(runDir, `context-${externalUserId}.json`);
+  fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
+    summary: { externalUserId, messageCount: messages.length },
+    data: { context, contextFile: path.relative(outputsRoot(), filePath) },
+    files: [filePath],
+    nextActions: ['基于 context 分析后调用 qiwei_save_customer_portrait 保存']
+  });
+});
+
+const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustomerPortrait(input = {}) {
+  const externalUserId = String(input.externalUserId || '').trim();
+  if (!externalUserId) return errorResult('缺少 externalUserId');
+
+  const aiMode = String(input.aiMode || '').trim();
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
+  const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
+
+  if (aiMode === 'keyword') {
+    const portrait = simpleKeywordPortrait(messages);
+    const saved = { externalUserId, portrait, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
+    const filePath = writePortrait(externalUserId, saved);
+    return okResult({
+      assistantMessage: `关键词模式画像更新完成:${externalUserId}。`,
+      summary: { externalUserId, messageCount: messages.length, fields: Object.keys(portrait) },
+      data: saved,
+      files: [filePath]
+    });
+  }
+
+  // 默认 Agent 驱动:返回上下文
+  const context = buildPortraitContext(externalUserId, messages);
+  ensurePortraitsDir();
+  const runDir = createRunDir('portraits', `context-${externalUserId}`);
+  const filePath = path.join(runDir, `context-${externalUserId}.json`);
+  fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文,请 Agent 分析后调用 qiwei_save_customer_portrait 保存。`,
+    summary: { externalUserId, messageCount: messages.length, requiresAgentAnalysis: true },
+    data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_customer_portrait' },
+    files: [filePath],
+    nextActions: ['分析 context 后调用 qiwei_save_customer_portrait']
+  });
+});
+
+const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPortrait(input = {}) {
+  const externalUserId = String(input.externalUserId || '').trim();
+  const portrait = input.portrait || input.aiResult;
+  if (!externalUserId) return errorResult('缺少 externalUserId');
+  if (!portrait || typeof portrait !== 'object') return errorResult('缺少 portrait(画像 JSON)');
+
+  const saved = {
+    externalUserId,
+    portrait,
+    source: input.source || 'agent',
+    messageCount: input.messageCount || 0,
+    updatedAt: new Date().toISOString()
+  };
+  const filePath = writePortrait(externalUserId, saved);
+
+  return okResult({
+    assistantMessage: `客户画像已保存:${externalUserId}。`,
+    summary: { externalUserId, fields: Object.keys(portrait) },
+    data: saved,
+    files: [filePath]
+  });
+});
+
+const qiweiBatchUpdateCustomerPortrait = safeResult(async function qiweiBatchUpdateCustomerPortrait(input = {}) {
+  const externalUserIds = Array.isArray(input.externalUserIds)
+    ? input.externalUserIds.map(String).filter(Boolean)
+    : [];
+  if (!externalUserIds.length) return errorResult('缺少 externalUserIds');
+
+  const results = [];
+  for (const externalUserId of externalUserIds) {
+    try {
+      const result = await qiweiUpdateCustomerPortrait({ ...input, externalUserId });
+      results.push({ externalUserId, status: result.status, summary: result.summary });
+    } catch (error) {
+      results.push({ externalUserId, status: 'error', message: String(error && error.message ? error.message : error) });
+    }
+  }
+
+  return okResult({
+    assistantMessage: `批量画像更新完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
+    summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
+    data: { results }
+  });
+});
+
+const qiweiBatchSaveCustomerPortrait = safeResult(async function qiweiBatchSaveCustomerPortrait(input = {}) {
+  const items = Array.isArray(input.items) ? input.items : [];
+  if (!items.length) return errorResult('缺少 items({ externalUserId, portrait } 数组)');
+
+  const results = [];
+  for (const item of items) {
+    try {
+      const result = await qiweiSaveCustomerPortrait({ externalUserId: item.externalUserId, portrait: item.portrait, source: item.source });
+      results.push({ externalUserId: item.externalUserId, status: result.status, summary: result.summary });
+    } catch (error) {
+      results.push({ externalUserId: item.externalUserId, status: 'error', message: String(error && error.message ? error.message : error) });
+    }
+  }
+
+  return okResult({
+    assistantMessage: `批量画像保存完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
+    summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
+    data: { results }
+  });
+});
+
+const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustomerPortraits(input = {}) {
+  let externalUserIds = Array.isArray(input.externalUserIds)
+    ? input.externalUserIds.map(String).filter(Boolean)
+    : null;
+
+  if (!externalUserIds) {
+    ensurePortraitsDir();
+    externalUserIds = fs.readdirSync(portraitsDir())
+      .filter(f => f.endsWith('.json') && !f.startsWith('context-'))
+      .map(f => f.replace(/\.json$/, ''));
+  }
+
+  if (!externalUserIds.length) return errorResult('没有可导出的画像');
+
+  let XLSX;
+  try {
+    XLSX = require('xlsx');
+  } catch {
+    return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
+  }
+
+  const rows = [];
+  for (const externalUserId of externalUserIds) {
+    const data = readPortrait(externalUserId);
+    if (!data || !data.portrait) continue;
+    rows.push({ externalUserId, ...flattenObject(data.portrait), updatedAt: data.updatedAt });
+  }
+
+  ensurePortraitsDir();
+  const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
+  const filePath = path.join(portraitsDir(), fileName);
+  const worksheet = XLSX.utils.json_to_sheet(rows);
+  const workbook = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(workbook, worksheet, 'portraits');
+  XLSX.writeFile(workbook, filePath);
+
+  return okResult({
+    assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,
+    summary: { rowCount: rows.length },
+    data: { filePath: path.relative(outputsRoot(), filePath) },
+    files: [filePath]
+  });
+});
+
+function flattenObject(obj, prefix = '') {
+  const result = {};
+  for (const [key, value] of Object.entries(obj)) {
+    const newKey = prefix ? `${prefix}.${key}` : key;
+    if (value && typeof value === 'object' && !Array.isArray(value)) {
+      Object.assign(result, flattenObject(value, newKey));
+    } else {
+      result[newKey] = Array.isArray(value) ? value.join(',') : value;
+    }
+  }
+  return result;
+}
+
+const qiweiAddCustomerTags = safeResult(async function qiweiAddCustomerTags(input = {}) {
+  const externalUserId = String(input.externalUserId || '').trim();
+  const tags = Array.isArray(input.tags) ? input.tags.map(String).filter(Boolean) : [];
+  if (!externalUserId) return errorResult('缺少 externalUserId');
+  if (!tags.length) return errorResult('缺少 tags');
+
+  const current = readTags(externalUserId);
+  const updated = [...new Set([...current, ...tags])];
+  writeTags(externalUserId, updated);
+
+  return okResult({
+    assistantMessage: `已为客户 ${externalUserId} 添加标签:${tags.join('、')}。`,
+    summary: { externalUserId, added: tags.length, total: updated.length },
+    data: { tags: updated }
+  });
+});
+
+const qiweiRemoveCustomerTags = safeResult(async function qiweiRemoveCustomerTags(input = {}) {
+  const externalUserId = String(input.externalUserId || '').trim();
+  const tags = Array.isArray(input.tags) ? input.tags.map(String).filter(Boolean) : [];
+  if (!externalUserId) return errorResult('缺少 externalUserId');
+  if (!tags.length) return errorResult('缺少 tags');
+
+  const current = readTags(externalUserId);
+  const tagSet = new Set(tags.map(t => t.toLowerCase()));
+  const updated = current.filter(t => !tagSet.has(String(t).toLowerCase()));
+  writeTags(externalUserId, updated);
+
+  return okResult({
+    assistantMessage: `已为客户 ${externalUserId} 移除标签:${tags.join('、')}。`,
+    summary: { externalUserId, removed: current.length - updated.length, total: updated.length },
+    data: { tags: updated }
+  });
+});
+
+const qiweiListCustomerTags = safeResult(async function qiweiListCustomerTags(input = {}) {
+  const externalUserId = String(input.externalUserId || '').trim();
+  if (!externalUserId) return errorResult('缺少 externalUserId');
+
+  const tags = readTags(externalUserId);
+  return okResult({
+    assistantMessage: `客户 ${externalUserId} 共有 ${tags.length} 个标签。`,
+    summary: { externalUserId, count: tags.length },
+    data: { tags }
+  });
+});
+
+const qiweiListAllTags = safeResult(async function qiweiListAllTags(input = {}) {
+  ensureTagsDir();
+  const files = fs.readdirSync(tagsDir()).filter(f => f.endsWith('.json'));
+  const tagSet = new Set();
+  for (const file of files) {
+    try {
+      const data = JSON.parse(fs.readFileSync(path.join(tagsDir(), file), 'utf8'));
+      if (Array.isArray(data.tags)) data.tags.forEach(t => tagSet.add(t));
+    } catch {
+      // ignore
+    }
+  }
+  const tags = Array.from(tagSet).sort();
+  return okResult({
+    assistantMessage: `共有 ${tags.length} 个去重后的本地标签。`,
+    summary: { count: tags.length },
+    data: { tags }
+  });
+});
+
+const qiweiSyncPersonalLabels = safeResult(async function qiweiSyncPersonalLabels(input = {}) {
+  assertMethodsInCatalog({ syncLabelList: REQUIRED_METHODS.syncLabelList });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const data = await gatewayCall(ctx, REQUIRED_METHODS.syncLabelList, {
+    guid: ctx.guid,
+    currentSeq: 0,
+    labelType: 2
+  });
+  const labels = Array.isArray(data && data.labelList) ? data.labelList : [];
+
+  return okResult({
+    assistantMessage: `已同步 ${labels.length} 个企微个人标签。`,
+    summary: { count: labels.length },
+    data: { labels: labels.slice(0, 50) }
+  });
+});
+
+const qiweiCreatePersonalLabel = safeResult(async function qiweiCreatePersonalLabel(input = {}) {
+  assertMethodsInCatalog({ editLabel: REQUIRED_METHODS.editLabel });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const labelName = String(input.labelName || '').trim();
+  const labelSuperId = String(input.labelSuperId || '').trim();
+  if (!labelName) return errorResult('缺少 labelName');
+
+  await gatewayCall(ctx, REQUIRED_METHODS.editLabel, {
+    guid: ctx.guid,
+    opType: 1,
+    paramList: [{ labelName, labelSuperId }]
+  });
+
+  return okResult({
+    assistantMessage: `已创建个人标签:${labelName}。`,
+    summary: { labelName },
+    data: { labelName, labelSuperId }
+  });
+});
+
+const qiweiUpdatePersonalLabel = safeResult(async function qiweiUpdatePersonalLabel(input = {}) {
+  assertMethodsInCatalog({ editLabel: REQUIRED_METHODS.editLabel });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const labelId = String(input.labelId || '').trim();
+  const labelName = String(input.labelName || '').trim();
+  const labelSuperId = String(input.labelSuperId || '').trim();
+  if (!labelId || !labelName) return errorResult('缺少 labelId 或 labelName');
+
+  await gatewayCall(ctx, REQUIRED_METHODS.editLabel, {
+    guid: ctx.guid,
+    opType: 3,
+    paramList: [{ labelId, labelName, labelSuperId }]
+  });
+
+  return okResult({
+    assistantMessage: `已更新个人标签:${labelId} → ${labelName}。`,
+    summary: { labelId, labelName },
+    data: { labelId, labelName, labelSuperId }
+  });
+});
+
+const qiweiDeletePersonalLabel = safeResult(async function qiweiDeletePersonalLabel(input = {}) {
+  assertMethodsInCatalog({ editLabel: REQUIRED_METHODS.editLabel });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const labelId = String(input.labelId || '').trim();
+  if (!labelId) return errorResult('缺少 labelId');
+
+  await gatewayCall(ctx, REQUIRED_METHODS.editLabel, {
+    guid: ctx.guid,
+    opType: 2,
+    paramList: [{ labelId }]
+  });
+
+  return okResult({
+    assistantMessage: `已删除个人标签:${labelId}。`,
+    summary: { labelId },
+    data: { labelId }
+  });
+});
+
+const qiweiApplyPersonalLabels = safeResult(async function qiweiApplyPersonalLabels(input = {}) {
+  assertMethodsInCatalog({ contactEditLabel: REQUIRED_METHODS.contactEditLabel });
+  const ctx = buildContext(input);
+  requireGuid(ctx);
+
+  const externalUserId = String(input.externalUserId || input.userId || '').trim();
+  const addLabels = Array.isArray(input.addLabels) ? input.addLabels.map(String).filter(Boolean) : [];
+  const removeLabels = Array.isArray(input.removeLabels) ? input.removeLabels.map(String).filter(Boolean) : [];
+  if (!externalUserId) return errorResult('缺少 externalUserId/userId');
+
+  await gatewayCall(ctx, REQUIRED_METHODS.contactEditLabel, {
+    guid: ctx.guid,
+    userId: externalUserId,
+    labelIdList: addLabels,
+    labelSuperIdList: addLabels.map(() => ''),
+    labelOwnerList: addLabels.map(() => ''),
+    delLabelIdList: removeLabels
+  });
+
+  return okResult({
+    assistantMessage: `已为客户 ${externalUserId} 应用个人标签。`,
+    summary: { externalUserId, added: addLabels.length, removed: removeLabels.length },
+    data: { externalUserId, addLabels, removeLabels }
+  });
+});
+
+module.exports = {
+  qiweiPrepareCustomerPortrait,
+  qiweiUpdateCustomerPortrait,
+  qiweiSaveCustomerPortrait,
+  qiweiBatchUpdateCustomerPortrait,
+  qiweiBatchSaveCustomerPortrait,
+  qiweiExportCustomerPortraits,
+  qiweiAddCustomerTags,
+  qiweiRemoveCustomerTags,
+  qiweiListCustomerTags,
+  qiweiListAllTags,
+  qiweiSyncPersonalLabels,
+  qiweiCreatePersonalLabel,
+  qiweiUpdatePersonalLabel,
+  qiweiDeletePersonalLabel,
+  qiweiApplyPersonalLabels
+};

+ 151 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-voice-run.js

@@ -0,0 +1,151 @@
+const fs = require('fs');
+const path = require('path');
+const { okResult, errorResult } = require('../core/result-envelope');
+const { createRunDir, outputsRoot } = require('../core/output-paths');
+const {
+  buildContext,
+  gatewayCall,
+  safeResult
+} = require('../core/shared-gateway');
+
+function voiceDir() {
+  return path.join(outputsRoot(), 'voice');
+}
+
+function ensureVoiceDir() {
+  fs.mkdirSync(voiceDir(), { recursive: true });
+}
+
+function extensionFromBuffer(buffer) {
+  if (!buffer || buffer.length < 4) return 'bin';
+  const header = buffer.slice(0, 4).toString('hex');
+  if (header.startsWith('23414d52') || header.startsWith('02152353')) return 'silk';
+  if (header.startsWith('52494646')) return 'wav';
+  if (header.startsWith('494433') || header.startsWith('fffb') || header.startsWith('fff3')) return 'mp3';
+  return 'bin';
+}
+
+function decodeSilk(inputPath, outputPath) {
+  let wxVoice;
+  try {
+    wxVoice = require('@binsee/wx-voice');
+  } catch {
+    return { ok: false, error: '未安装 @binsee/wx-voice,无法解码 silk;请运行 npm install @binsee/wx-voice' };
+  }
+  try {
+    const voice = new wxVoice();
+    voice.decode(inputPath, outputPath, { format: 'mp3' });
+    return { ok: true };
+  } catch (error) {
+    return { ok: false, error: error && error.message ? error.message : String(error) };
+  }
+}
+
+async function fetchVoice(url) {
+  const response = await fetch(url);
+  if (!response.ok) throw new Error(`下载语音失败: HTTP ${response.status}`);
+  const arrayBuffer = await response.arrayBuffer();
+  return Buffer.from(arrayBuffer);
+}
+
+async function transcribeWithFmodeListen(audioPath, apiBase, token) {
+  const fmodeListenUrl = process.env.FMODE_LISTEN_URL || `${apiBase.replace(/\/api\/qiwei$/, '')}/api/listen/transcribe`;
+  const audioBuffer = fs.readFileSync(audioPath);
+  const response = await fetch(fmodeListenUrl, {
+    method: 'POST',
+    headers: {
+      Authorization: `Bearer ${token}`,
+      'Content-Type': 'audio/wav'
+    },
+    body: audioBuffer
+  });
+  if (!response.ok) throw new Error(`转写服务返回 HTTP ${response.status}`);
+  const json = await response.json();
+  return json.text || json.data || '';
+}
+
+const qiweiTranscribeVoice = safeResult(async function qiweiTranscribeVoice(input = {}) {
+  const voiceUrl = String(input.voiceUrl || '').trim();
+  const filePath = input.filePath ? path.resolve(String(input.filePath)) : null;
+  const base64Audio = String(input.base64Audio || '').trim();
+  const attemptTranscribe = input.transcribe !== false;
+
+  if (!voiceUrl && !filePath && !base64Audio) return errorResult('缺少 voiceUrl、filePath 或 base64Audio');
+
+  ensureVoiceDir();
+  const runDir = createRunDir('voice', 'transcribe');
+  let sourceBuffer;
+  let sourceExt;
+  let sourcePath;
+
+  if (filePath) {
+    if (!fs.existsSync(filePath)) return errorResult(`语音文件不存在: ${filePath}`);
+    sourceBuffer = fs.readFileSync(filePath);
+    sourceExt = path.extname(filePath).replace(/^\./, '') || extensionFromBuffer(sourceBuffer);
+    sourcePath = path.join(runDir, `source.${sourceExt}`);
+    fs.copyFileSync(filePath, sourcePath);
+  } else if (base64Audio) {
+    sourceBuffer = Buffer.from(base64Audio, 'base64');
+    sourceExt = extensionFromBuffer(sourceBuffer);
+    sourcePath = path.join(runDir, `source.${sourceExt}`);
+    fs.writeFileSync(sourcePath, sourceBuffer);
+  } else {
+    sourceBuffer = await fetchVoice(voiceUrl);
+    sourceExt = extensionFromBuffer(sourceBuffer);
+    sourcePath = path.join(runDir, `source.${sourceExt}`);
+    fs.writeFileSync(sourcePath, sourceBuffer);
+  }
+
+  let audioPath = sourcePath;
+  let decoded = false;
+  if (sourceExt === 'silk') {
+    const decodedPath = path.join(runDir, 'audio.mp3');
+    const result = decodeSilk(sourcePath, decodedPath);
+    if (result.ok) {
+      audioPath = decodedPath;
+      decoded = true;
+    } else {
+      return errorResult(result.error, { files: [sourcePath] });
+    }
+  }
+
+  let transcript = '';
+  let transcribed = false;
+  if (attemptTranscribe) {
+    try {
+      const ctx = buildContext(input);
+      transcript = await transcribeWithFmodeListen(audioPath, ctx.apiBase, ctx.token);
+      transcribed = true;
+    } catch (error) {
+      transcript = '';
+    }
+  }
+
+  const manifest = {
+    createdAt: new Date().toISOString(),
+    sourceType: filePath ? 'file' : base64Audio ? 'base64' : 'url',
+    originalExt: sourceExt,
+    decoded,
+    transcribed,
+    transcript,
+    files: {
+      source: path.relative(outputsRoot(), sourcePath),
+      audio: path.relative(outputsRoot(), audioPath)
+    }
+  };
+  const manifestPath = path.join(runDir, 'manifest.json');
+  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
+
+  return okResult({
+    assistantMessage: transcribed
+      ? `语音转写完成:${transcript.slice(0, 80)}${transcript.length > 80 ? '...' : ''}`
+      : `语音文件已保存${decoded ? '并解码' : ''},未成功转写。`,
+    summary: { decoded, transcribed, sourceExt, transcriptLength: transcript.length },
+    data: manifest,
+    files: [sourcePath, audioPath, manifestPath]
+  });
+});
+
+module.exports = {
+  qiweiTranscribeVoice
+};

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

@@ -0,0 +1,418 @@
+const { okResult, errorResult } = require('../core/result-envelope');
+const {
+  buildContext,
+  gatewayCall,
+  assertMethodsInCatalog,
+  safeResult
+} = require('../core/shared-gateway');
+const {
+  readQiweiGuid,
+  readQiweiAuthToken,
+  readQiweiApiBase,
+  readQiweiUid,
+  saveQiweiClientConfig
+} = require('../core/credentials');
+const {
+  startWebhookServer,
+  stopWebhookServer,
+  getWebhookServerStatus,
+  readConfig,
+  writeConfig,
+  readRelayConfig,
+  writeRelayConfig
+} = require('../core/webhook-server');
+const {
+  isRelayEnabled,
+  getRelayBaseUrl,
+  getTenantApiSecret,
+  getTenantId,
+  getRelayPrivateKey,
+  saveRelayCredentials,
+  writeRelayConfigFile,
+  readRelayConfigFile
+} = require('../core/relay-config');
+
+const REQUIRED_METHODS = {
+  setCallback: '/client/setCallback'
+};
+
+function generateSecret() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let result = '';
+  for (let i = 0; i < 32; i++) result += chars.charAt(Math.floor(Math.random() * chars.length));
+  return result;
+}
+
+function generateRelaySecret() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let result = '';
+  for (let i = 0; i < 32; i++) result += chars.charAt(Math.floor(Math.random() * chars.length));
+  return result;
+}
+
+async function fetchRelay(baseUrl, path, apiSecret, body) {
+  const res = await fetch(`${baseUrl}${path}`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${apiSecret}`
+    },
+    body: JSON.stringify(body || {})
+  });
+
+  const text = await res.text();
+  let json = {};
+  try { json = JSON.parse(text); } catch { /* ignore */ }
+
+  if (!res.ok) {
+    const err = new Error(json.error || json.message || text || `HTTP ${res.status}`);
+    err.status = res.status;
+    err.code = json.code || json.errorCode;
+    throw err;
+  }
+
+  return json;
+}
+
+async function registerRelayTenant(input = {}) {
+  const baseUrl = String(input.relayBaseUrl || getRelayBaseUrl()).replace(/\/$/, '');
+  const token = readQiweiAuthToken(input);
+  if (!token) throw Object.assign(new Error('缺少 Fmode 鉴权 token'), { kind: 'auth' });
+
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  const description = String(input.description || '本地 Skill').trim();
+
+  const res = await fetch(`${baseUrl}/api/tenant/register`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      Authorization: `Bearer ${token}`
+    },
+    body: JSON.stringify({
+      description,
+      ...(guid ? { deviceGuid: guid } : {})
+    })
+  });
+
+  const text = await res.text();
+  let json = {};
+  try { json = JSON.parse(text); } catch { /* ignore */ }
+
+  // 已注册且允许覆盖:尝试读取本地旧配置或提示
+  if (!res.ok) {
+    const code = json.code || json.errorCode;
+    const message = json.error || json.message || text || `HTTP ${res.status}`;
+    if ((code === 'ALREADY_REGISTERED' || /already|已注册/i.test(message)) && input.force) {
+      // 服务端不允许重复注册时,无法自动覆盖;返回已有配置的覆盖操作
+      const existing = readRelayConfigFile();
+      if (!existing.tenantId) {
+        throw Object.assign(new Error('租户已存在,但本地没有旧凭证。请联系管理员重置后重新注册。'), { kind: 'relay', code: 'ALREADY_REGISTERED' });
+      }
+      return {
+        tenantId: existing.tenantId,
+        apiKey: existing.apiKey || '',
+        apiSecret: existing.apiSecret || getTenantApiSecret(),
+        privateKey: existing.privateKey || getRelayPrivateKey(),
+        publicKey: existing.publicKey || '',
+        relayBaseUrl: baseUrl,
+        fromCache: true
+      };
+    }
+    throw Object.assign(new Error(message), { kind: 'relay', code });
+  }
+
+  return {
+    relayBaseUrl: baseUrl,
+    tenantId: json.tenantId,
+    apiKey: json.apiKey,
+    apiSecret: json.apiSecret,
+    privateKey: json.privateKey,
+    publicKey: json.publicKey
+  };
+}
+
+async function ensureRelayWebhookConfigured(input = {}, options = {}) {
+  if (!isRelayEnabled()) {
+    return { success: false, error: 'Relay 未启用,请先调用 qiwei_relay_register 注册租户' };
+  }
+
+  const baseUrl = getRelayBaseUrl();
+  const apiSecret = getTenantApiSecret();
+  const tenantId = getTenantId();
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+
+  if (!guid) {
+    return { success: false, error: '缺少 guid,请先完成企微设备登录' };
+  }
+  if (!tenantId) {
+    return { success: false, error: '缺少 tenantId,请先注册 Relay 租户' };
+  }
+
+  // 注册/更新设备到 Relay
+  let relaySecret;
+  try {
+    const deviceRes = await fetchRelay(baseUrl, '/api/tenant/device', apiSecret, { guid });
+    relaySecret = deviceRes.relaySecret || generateRelaySecret();
+  } catch (err) {
+    console.warn('[Relay] 注册设备到 Relay 失败,使用本地生成 secret:', err.message);
+    relaySecret = generateRelaySecret();
+  }
+
+  const callbackUrl = `${baseUrl}/api/webhook/ingest/${tenantId}/${guid}`;
+
+  // 避免重复配置:如果本地已记录相同地址则跳过
+  const config = readConfig();
+  if (config.callbackUrl === callbackUrl && !options.force) {
+    return { success: true, callbackUrl, source: 'cache' };
+  }
+
+  // 调用 Fmode 网关设置回调
+  const ctx = buildContext(input);
+  await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
+    callbackUrl,
+    authSecret: relaySecret,
+    authType: 'Authorization'
+  });
+
+  writeConfig({ callbackUrl, secret: relaySecret });
+  writeRelayConfigFile({ deviceGuid: guid, tenantId });
+
+  return { success: true, callbackUrl, source: 'relay' };
+}
+
+const qiweiWebhookStatus = safeResult(async function qiweiWebhookStatus() {
+  const status = getWebhookServerStatus();
+  const config = readConfig();
+  const relayEnabled = isRelayEnabled();
+  return okResult({
+    assistantMessage: status.running
+      ? `本地 webhook server 运行中:${status.localUrl}${relayEnabled ? ';Relay 模式已启用' : ''}`
+      : `本地 webhook server 未运行。${relayEnabled ? 'Relay 模式已启用' : ''}`,
+    summary: { running: status.running, port: status.port, relayEnabled },
+    data: { status, config, relayEnabled }
+  });
+});
+
+const qiweiWebhookDiscover = safeResult(async function qiweiWebhookDiscover() {
+  const status = getWebhookServerStatus();
+  if (!status.running) {
+    return okResult({
+      assistantMessage: '本地 webhook server 未运行,请先调用 qiwei_webhook_server_start。',
+      summary: { running: false },
+      data: {}
+    });
+  }
+  return okResult({
+    assistantMessage: `本地回调地址:${status.localUrl}`,
+    summary: { running: true, port: status.port },
+    data: { localUrl: status.localUrl }
+  });
+});
+
+const qiweiWebhookServerStart = safeResult(async function qiweiWebhookServerStart(input = {}) {
+  const port = Number(input.port || 0);
+  const result = await startWebhookServer(port);
+  const status = getWebhookServerStatus();
+  writeConfig({ callbackUrl: status.localUrl });
+  return okResult({
+    assistantMessage: `本地 webhook server 已启动:${status.localUrl}`,
+    summary: { port: status.port, alreadyRunning: result.alreadyRunning || false },
+    data: { localUrl: status.localUrl }
+  });
+});
+
+const qiweiWebhookServerStop = safeResult(async function qiweiWebhookServerStop() {
+  const result = await stopWebhookServer();
+  return okResult({
+    assistantMessage: result.stopped ? '本地 webhook server 已停止。' : '本地 webhook server 未运行。',
+    summary: result,
+    data: result
+  });
+});
+
+const qiweiWebhookAutoSetup = safeResult(async function qiweiWebhookAutoSetup(input = {}) {
+  assertMethodsInCatalog(REQUIRED_METHODS);
+
+  // Relay 模式优先
+  if (isRelayEnabled() || input.relayBaseUrl || process.env.RELAY_BASE_URL) {
+    const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+    if (!guid) {
+      return errorResult('Relay 自动配置需要 guid,请先完成企微设备登录或显式传入 guid');
+    }
+    const result = await ensureRelayWebhookConfigured({ ...input, guid }, { force: input.force === true });
+    if (!result.success) return errorResult(result.error);
+    return okResult({
+      assistantMessage: `已自动配置 Relay 回调:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端。`,
+      summary: { callbackUrl: result.callbackUrl, source: result.source },
+      data: { callbackUrl: result.callbackUrl, source: result.source }
+    });
+  }
+
+  // 本地 webhook server 模式
+  const ctx = buildContext(input);
+  const status = getWebhookServerStatus();
+  if (!status.running) {
+    await startWebhookServer(Number(input.port || 0));
+  }
+  const finalStatus = getWebhookServerStatus();
+  const callbackUrl = String(input.callbackUrl || finalStatus.localUrl || '').trim();
+  if (!callbackUrl) return errorResult('无法确定回调地址,请显式传入 callbackUrl 或先启动 webhook server');
+
+  const secret = String(input.secret || generateSecret()).trim();
+  await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
+    callbackUrl,
+    authSecret: secret,
+    authType: 'Authorization'
+  });
+
+  writeConfig({ callbackUrl, secret });
+
+  return okResult({
+    assistantMessage: `已自动配置企微回调:${callbackUrl}。`,
+    summary: { callbackUrl },
+    data: { callbackUrl, secret }
+  });
+});
+
+const qiweiWebhookSetup = safeResult(async function qiweiWebhookSetup(input = {}) {
+  assertMethodsInCatalog(REQUIRED_METHODS);
+  const ctx = buildContext(input);
+
+  const callbackUrl = String(input.callbackUrl || '').trim();
+  if (!callbackUrl) return errorResult('缺少 callbackUrl');
+  const secret = String(input.secret || generateSecret()).trim();
+
+  await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
+    callbackUrl,
+    authSecret: secret,
+    authType: 'Authorization'
+  });
+
+  writeConfig({ callbackUrl, secret });
+
+  return okResult({
+    assistantMessage: `已配置企微回调:${callbackUrl}。`,
+    summary: { callbackUrl },
+    data: { callbackUrl, secret }
+  });
+});
+
+const qiweiRelayConfig = safeResult(async function qiweiRelayConfig() {
+  const config = readRelayConfig();
+  const enabled = isRelayEnabled();
+  const creds = {
+    relayBaseUrl: getRelayBaseUrl(),
+    tenantId: getTenantId(),
+    apiKey: getTenantApiKey() ? '••••••••' : '',
+    apiSecret: getTenantApiSecret() ? '••••••••' : '',
+    privateKey: getRelayPrivateKey() ? '••••••••' : '',
+    deviceGuid: readRelayConfigFile().deviceGuid || ''
+  };
+  return okResult({
+    assistantMessage: enabled
+      ? `Relay 已启用:${creds.relayBaseUrl},tenantId=${creds.tenantId}`
+      : 'Relay 尚未配置。',
+    summary: { enabled, ...creds },
+    data: { enabled, config, creds }
+  });
+});
+
+const qiweiRelaySaveConfig = safeResult(async function qiweiRelaySaveConfig(input = {}) {
+  const relayBaseUrl = String(input.relayBaseUrl || '').trim();
+  const tenantId = String(input.tenantId || '').trim();
+  const publicKey = String(input.publicKey || '').trim();
+  if (!relayBaseUrl) return errorResult('缺少 relayBaseUrl');
+
+  writeRelayConfig({ relayBaseUrl, tenantId, publicKey });
+  return okResult({
+    assistantMessage: `Relay 配置已保存:${relayBaseUrl}。`,
+    summary: { relayBaseUrl },
+    data: { relayBaseUrl, tenantId, publicKey }
+  });
+});
+
+const qiweiRelayRegister = safeResult(async function qiweiRelayRegister(input = {}) {
+  const token = readQiweiAuthToken(input);
+  if (!token) {
+    return {
+      status: 'needs_auth',
+      assistantMessage: '缺少 Fmode 鉴权 token,无法注册 Relay 租户。',
+      summary: { configured: false },
+      data: {},
+      files: [],
+      nextActions: ['配置 QIWEI_AUTH_TOKEN / FMODE_API_KEY 后重试'],
+      warnings: [],
+      errors: [{ message: '缺少 Fmode 鉴权 token', kind: 'auth' }]
+    };
+  }
+
+  try {
+    const creds = await registerRelayTenant(input);
+    const saved = saveRelayCredentials({
+      relayBaseUrl: creds.relayBaseUrl,
+      tenantId: creds.tenantId,
+      tenantApiKey: creds.apiKey,
+      tenantApiSecret: creds.apiSecret,
+      privateKey: creds.privateKey,
+      publicKey: creds.publicKey,
+      deviceGuid: creds.deviceGuid || ''
+    });
+
+    const message = creds.fromCache
+      ? `Relay 租户已存在,已使用本地缓存凭证。tenantId=${creds.tenantId}`
+      : `Relay 租户注册成功:tenantId=${creds.tenantId}。凭证已保存。`;
+
+    return okResult({
+      assistantMessage: `${message} 接下来可调用 qiwei_webhook_auto_setup 或 qiwei_relay_connect 完成回调配置。`,
+      summary: { tenantId: creds.tenantId, saved },
+      data: { tenantId: creds.tenantId, relayBaseUrl: creds.relayBaseUrl, saved }
+    });
+  } catch (error) {
+    return {
+      status: error.kind === 'auth' ? 'needs_auth' : 'error',
+      assistantMessage: `Relay 租户注册失败:${error.message}`,
+      summary: { errorKind: error.kind || 'relay', code: error.code },
+      data: {},
+      files: [],
+      nextActions: error.kind === 'auth' ? ['配置 QIWEI_AUTH_TOKEN / FMODE_API_KEY 后重试'] : ['检查 Relay 服务端状态后重试'],
+      warnings: [],
+      errors: [{ message: error.message, kind: error.kind || 'relay' }]
+    };
+  }
+});
+
+const qiweiRelayConnect = safeResult(async function qiweiRelayConnect(input = {}) {
+  if (!isRelayEnabled()) {
+    return errorResult('Relay 尚未配置,请先调用 qiwei_relay_register 注册租户');
+  }
+
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) {
+    return errorResult('缺少 guid,请先完成企微设备登录或显式传入 guid');
+  }
+
+  const result = await ensureRelayWebhookConfigured({ ...input, guid }, { force: input.force === true });
+  if (!result.success) return errorResult(result.error);
+
+  return okResult({
+    assistantMessage: `Relay 回调已配置:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端(或使用 systemd/pm2 持久化运行)。`,
+    summary: { callbackUrl: result.callbackUrl, source: result.source },
+    data: { callbackUrl: result.callbackUrl, source: result.source },
+    nextActions: ['执行 npm run relay 启动轮询客户端', '或配置 systemd 服务持久化运行']
+  });
+});
+
+module.exports = {
+  qiweiWebhookStatus,
+  qiweiWebhookDiscover,
+  qiweiWebhookServerStart,
+  qiweiWebhookServerStop,
+  qiweiWebhookAutoSetup,
+  qiweiWebhookSetup,
+  qiweiRelayConfig,
+  qiweiRelaySaveConfig,
+  qiweiRelayRegister,
+  qiweiRelayConnect,
+  ensureRelayWebhookConfigured,
+  registerRelayTenant
+};

+ 104 - 0
claude-code/claude-code-qiwe-assistant/package-lock.json

@@ -10,6 +10,7 @@
       "dependencies": {
         "@modelcontextprotocol/sdk": "^1.12.1",
         "cross-spawn": "7.0.6",
+        "xlsx": "^0.18.5",
         "zod": "^3.24.1"
       },
       "engines": {
@@ -81,6 +82,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/adler-32": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
+      "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/ajv": {
       "version": "8.20.0",
       "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -189,6 +199,28 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/cfb": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
+      "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "crc-32": "~1.2.0"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/codepage": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
+      "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/content-disposition": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -246,6 +278,18 @@
         "url": "https://opencollective.com/express"
       }
     },
+    "node_modules/crc-32": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
+      "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
+      "license": "Apache-2.0",
+      "bin": {
+        "crc32": "bin/crc32.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/cross-spawn": {
       "version": "7.0.6",
       "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -494,6 +538,15 @@
         "node": ">= 0.6"
       }
     },
+    "node_modules/frac": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
+      "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/fresh": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@@ -1068,6 +1121,18 @@
         "url": "https://github.com/sponsors/ljharb"
       }
     },
+    "node_modules/ssf": {
+      "version": "0.11.2",
+      "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
+      "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "frac": "~1.1.2"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/statuses": {
       "version": "2.0.2",
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1150,12 +1215,51 @@
         "node": ">= 8"
       }
     },
+    "node_modules/wmf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
+      "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/word": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
+      "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/wrappy": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
       "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
       "license": "ISC"
     },
+    "node_modules/xlsx": {
+      "version": "0.18.5",
+      "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
+      "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "adler-32": "~1.3.0",
+        "cfb": "~1.2.1",
+        "codepage": "~1.15.0",
+        "crc-32": "~1.2.1",
+        "ssf": "~0.11.2",
+        "wmf": "~1.0.1",
+        "word": "~0.3.0"
+      },
+      "bin": {
+        "xlsx": "bin/xlsx.njs"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
     "node_modules/zod": {
       "version": "3.25.76",
       "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

+ 4 - 1
claude-code/claude-code-qiwe-assistant/package.json

@@ -4,8 +4,10 @@
   "description": "企业微信技能包:保留 Fmode 网关转发的企业微信接口,并增加按需下载的官方 CLI 会议与文档能力",
   "main": "mcp/src/server.js",
   "scripts": {
-    "check": "node --check mcp/src/server.js && node --check mcp/src/core/api-catalog.js && node --check mcp/src/core/credentials.js && node --check mcp/src/core/result-envelope.js && node --check mcp/src/core/output-paths.js && node --check mcp/src/core/subscribe-page.js && node --check mcp/src/core/login-flow-server.js && node --check mcp/src/core/wecom-cli-runtime.js && node --check mcp/src/providers/fmode-wecom-gateway.js && node --check mcp/src/providers/wecom-official-cli.js && node --check mcp/src/tools/qiwei-api-catalog-run.js && node --check mcp/src/tools/qiwei-login-run.js && node --check mcp/src/tools/qiwei-subscription-run.js && node --check mcp/src/tools/wecom-official-cli-run.js && node --check bin/qiwei-official-cli.js && node --check scripts/install-wecom-cli.js && node --check scripts/smoke-test.js && node --check scripts/wecom-cli-smoke-test.js && node --check scripts/validate-output-standard.js",
+    "check": "node --check mcp/src/server.js && node --check mcp/src/core/api-catalog.js && node --check mcp/src/core/credentials.js && node --check mcp/src/core/result-envelope.js && node --check mcp/src/core/output-paths.js && node --check mcp/src/core/shared-gateway.js && node --check mcp/src/core/subscribe-page.js && node --check mcp/src/core/login-flow-server.js && node --check mcp/src/core/login-fallback-server.js && node --check mcp/src/core/wecom-cli-runtime.js && node --check mcp/src/core/webhook-server.js && node --check mcp/src/core/relay-config.js && node --check mcp/src/dashboard/server.js && node --check mcp/src/dashboard/app.js && node --check scripts/start-dashboard.js && node --check scripts/start-relay-client.js && node --check mcp/src/providers/fmode-wecom-gateway.js && node --check mcp/src/providers/wecom-official-cli.js && node --check mcp/src/tools/qiwei-api-catalog-run.js && node --check mcp/src/tools/qiwei-login-run.js && node --check mcp/src/tools/qiwei-subscription-run.js && node --check mcp/src/tools/qiwei-customer-ops-run.js && node --check mcp/src/tools/qiwei-group-management-run.js && node --check mcp/src/tools/qiwei-portrait-tags-run.js && node --check mcp/src/tools/qiwei-broker-playbook-run.js && node --check mcp/src/tools/qiwei-customer-transfer-run.js && node --check mcp/src/tools/qiwei-voice-run.js && node --check mcp/src/tools/qiwei-webhook-relay-run.js && node --check mcp/src/tools/wecom-official-cli-run.js && node --check bin/qiwei-official-cli.js && node --check scripts/install-wecom-cli.js && node --check scripts/smoke-test.js && node --check scripts/wecom-cli-smoke-test.js && node --check scripts/validate-output-standard.js",
     "start": "node mcp/src/server.js",
+    "dashboard": "node scripts/start-dashboard.js",
+    "relay": "node scripts/start-relay-client.js",
     "smoke": "node scripts/smoke-test.js && node scripts/wecom-cli-smoke-test.js",
     "outputs:validate": "node scripts/validate-output-standard.js",
     "wecom:install": "node scripts/install-wecom-cli.js",
@@ -15,6 +17,7 @@
   "dependencies": {
     "@modelcontextprotocol/sdk": "^1.12.1",
     "cross-spawn": "7.0.6",
+    "xlsx": "^0.18.5",
     "zod": "^3.24.1"
   },
   "engines": {

+ 415 - 13
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -1,6 +1,8 @@
 #!/usr/bin/env node
 const assert = require('assert');
 const http = require('http');
+const fs = require('fs');
+const path = require('path');
 const { loadCatalog, searchEndpoints, findEndpoint } = require('../mcp/src/core/api-catalog');
 const { searchQiweiApis, getQiweiApiDoc, callQiweiApi } = require('../mcp/src/tools/qiwei-api-catalog-run');
 const {
@@ -9,11 +11,54 @@ const {
   qiweiLoginCheck,
   qiweiLoginVerify
 } = require('../mcp/src/tools/qiwei-login-run');
+const { stopLoginFlowServer } = require('../mcp/src/core/login-flow-server');
+const { stopLoginFallbackServer } = require('../mcp/src/core/login-fallback-server');
 const {
   qiweiSubscriptionStatus,
   qiweiSubscribe,
   qiweiSubscriptionAutoRenew
 } = require('../mcp/src/tools/qiwei-subscription-run');
+const {
+  qiweiCheckFriendStatus,
+  qiweiGetCustomerProfile
+} = require('../mcp/src/tools/qiwei-customer-ops-run');
+const {
+  qiweiSyncExternalGroups,
+  qiweiListExternalGroups,
+  qiweiAnalyzeGroupMembers,
+  qiweiConfirmExternalGroup,
+  qiweiAddExternalGroup,
+  qiweiConfigureGroupKeywords,
+  qiweiRejectExternalGroup,
+  qiweiSyncGroupMessages
+} = require('../mcp/src/tools/qiwei-group-management-run');
+const {
+  qiweiPrepareCustomerPortrait,
+  qiweiUpdateCustomerPortrait,
+  qiweiSaveCustomerPortrait,
+  qiweiAddCustomerTags,
+  qiweiListCustomerTags,
+  qiweiSyncPersonalLabels,
+  qiweiCreatePersonalLabel
+} = require('../mcp/src/tools/qiwei-portrait-tags-run');
+const {
+  qiweiDistillBroker,
+  qiweiPrepareBrokerPlaybook,
+  qiweiSaveBrokerPlaybook,
+  qiweiGetBrokerPlaybook
+} = require('../mcp/src/tools/qiwei-broker-playbook-run');
+const {
+  qiweiPreviewTransferPackage,
+  qiweiExecuteTransfer
+} = require('../mcp/src/tools/qiwei-customer-transfer-run');
+const { qiweiTranscribeVoice } = require('../mcp/src/tools/qiwei-voice-run');
+const {
+  qiweiWebhookStatus,
+  qiweiWebhookDiscover,
+  qiweiWebhookServerStart,
+  qiweiWebhookServerStop,
+  qiweiWebhookAutoSetup
+} = require('../mcp/src/tools/qiwei-webhook-relay-run');
 
 const PRIVATE_PROVIDER_NAME = ['private', 'provider'].join('-');
 const PRIVATE_PROVIDER_HOST = ['private', 'provider', 'invalid'].join('.');
@@ -23,7 +68,6 @@ function assertProviderHidden(value) {
   const output = JSON.stringify(value);
   assert(!output.includes(PRIVATE_PROVIDER_NAME));
   assert(!output.includes(PRIVATE_PROVIDER_HOST));
-  assert(!output.includes('server-only-guid'));
   assert(!output.includes('server-only-token'));
 }
 
@@ -53,23 +97,79 @@ async function startMockGateway() {
 
     let data;
     if (url.pathname === '/doApi') {
-      data = {
-        nickname: '测试账号',
-        echoedMethod: body.method,
-        guid: 'server-only-guid',
-        [PRIVATE_CREDENTIAL_KEY]: 'server-only-token',
-        provider: PRIVATE_PROVIDER_NAME,
-        providerName: PRIVATE_PROVIDER_NAME,
-        providerHost: PRIVATE_PROVIDER_HOST,
-        supportUrl: `https://${PRIVATE_PROVIDER_HOST}/help`,
-        diagnostic: `See https://${PRIVATE_PROVIDER_HOST}/diagnostic`
-      };
+      if (body.method === '/contact/searchContact') {
+        data = {
+          searchStatus: 2,
+          wxUserInfo: { userId: 'wx-u-1', nickname: '测试客户' },
+          workUserInfo: null
+        };
+      } else if (body.method === '/contact/getWxContactList') {
+        data = {
+          currentSeq: 0,
+          contactCount: 1,
+          hasMore: false,
+          contactList: [{ userId: 'wx-u-1', nickname: '测试客户' }]
+        };
+      } else if (body.method === '/room/getRoomList') {
+        data = {
+          hasMore: false,
+          nextStartIndex: 0,
+          roomCount: 2,
+          roomList: [
+            { roomId: 'r-1', roomName: '张三买房服务群', roomMemberCount: 3 },
+            { roomId: 'r-2', roomName: 'random-group', roomMemberCount: 2 }
+          ]
+        };
+      } else if (body.method === '/session/getSessionPage') {
+        data = {
+          hasMore: false,
+          currentSeq: 0,
+          sessionCount: 1,
+          sessionList: [
+            { roomId: 'r-3', sessionName: '潜在VIP群', sessionType: 1, roomMemberCount: 4 }
+          ]
+        };
+      } else if (body.method === '/room/batchGetRoomDetail') {
+        data = {
+          roomList: (body.params.roomIdList || []).map(roomId => ({
+            roomId,
+            roomName: roomId === 'r-1' ? '张三买房服务群' : '其他群',
+            roomMemberCount: roomId === 'r-1' ? 3 : 2
+          }))
+        };
+      } else if (body.method === '/msg/syncMsg') {
+        data = {
+          hasMore: false,
+          travelSyncKey: 1,
+          syncMsgList: [
+            { fromRoomId: 'r-1', seq: 1, senderId: 's-1', senderName: '张三', msgType: 1, content: '你好', msgUniqueIdentifier: 'm-1', timestamp: Math.floor(Date.now() / 1000) }
+          ]
+        };
+      } else if (body.method === '/label/syncLabelList') {
+        data = { currentSeq: 0, hasMore: false, labelCount: 1, labelList: [{ labelId: 'l-1', labelName: '高意向' }] };
+      } else if (body.method === '/label/editLabel') {
+        data = { success: true };
+      } else if (body.method === '/label/contactEditLabel') {
+        data = { success: true };
+      } else {
+        data = {
+          nickname: '测试账号',
+          echoedMethod: body.method,
+          guid: 'server-only-guid',
+          [PRIVATE_CREDENTIAL_KEY]: 'server-only-token',
+          provider: PRIVATE_PROVIDER_NAME,
+          providerName: PRIVATE_PROVIDER_NAME,
+          providerHost: PRIVATE_PROVIDER_HOST,
+          supportUrl: `https://${PRIVATE_PROVIDER_HOST}/help`,
+          diagnostic: `See https://${PRIVATE_PROVIDER_HOST}/diagnostic`
+        };
+      }
     } else if (url.pathname === '/login/status') {
       data = { configured: true, online: true, statusCode: 2 };
     } else if (url.pathname === '/login/start') {
       data = { uid: body.uid, guid: 'server-only-guid', loginQrcodeBase64Data: Buffer.from('mock-png').toString('base64') };
     } else if (url.pathname === '/login/check') {
-      data = { uid: body.uid, guid: 'server-only-guid', status: 2, detail: { nickname: '测试账号', userId: 'u-1' } };
+      data = { uid: body.uid, guid: 'server-only-guid', status: 2, detail: { nickname: '测试账号', userId: 'u-1', guid: 'server-only-guid' } };
     } else if (url.pathname === '/login/verify') {
       data = { accepted: true };
     } else if (url.pathname === '/subscribe/status') {
@@ -103,6 +203,54 @@ async function startMockGateway() {
   };
 }
 
+async function startMockVerifyGateway() {
+  const requests = [];
+  let verified = false;
+  const server = http.createServer(async (req, res) => {
+    const body = await readBody(req);
+    const url = new URL(req.url, 'http://localhost');
+    requests.push({
+      method: req.method,
+      path: url.pathname,
+      query: Object.fromEntries(url.searchParams.entries()),
+      authorization: req.headers.authorization,
+      body
+    });
+
+    let data;
+    if (url.pathname === '/login/status') {
+      data = { configured: true, online: false, statusCode: -1 };
+    } else if (url.pathname === '/login/start') {
+      data = { uid: body.uid, guid: 'server-only-guid', loginQrcodeBase64Data: Buffer.from('mock-png').toString('base64') };
+    } else if (url.pathname === '/login/check') {
+      if (verified) {
+        data = { uid: body.uid, status: 2, detail: { nickname: '测试账号', userId: 'u-1', guid: 'server-only-guid' } };
+      } else {
+        data = { uid: body.uid, status: 10, detail: {} };
+      }
+    } else if (url.pathname === '/login/verify') {
+      verified = true;
+      data = { accepted: true };
+    } else if (url.pathname === '/subscribe/status') {
+      data = { subscribed: true, seats: 2, usedSeats: 1, expireAt: '2026-08-10T00:00:00.000Z', autoRenew: true, balance: 1000 };
+    } else {
+      res.writeHead(404, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify({ code: 404, mess: 'not found' }));
+      return;
+    }
+
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    res.end(JSON.stringify({ code: 200, data }));
+  });
+  await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
+  const address = server.address();
+  return {
+    requests,
+    apiBase: `http://127.0.0.1:${address.port}`,
+    close: () => new Promise(resolve => server.close(resolve))
+  };
+}
+
 async function main() {
   const catalog = loadCatalog();
   assert(catalog.endpoints.length >= 100, 'catalog should contain 100+ endpoints');
@@ -176,6 +324,257 @@ async function main() {
     assert.deepStrictEqual(gateway.requests.at(-1).body, { uid: common.uid, code: '123456' });
     console.log('[ok] login tools use uid-based Fmode gateway routes and hide internal identifiers');
 
+    const verifyGateway = await startMockVerifyGateway();
+    try {
+      const verifyCommon = {
+        authToken: 'sk-test-relay-token',
+        uid: 'qiwei-verify-device',
+        apiBase: verifyGateway.apiBase,
+        persistConfig: false
+      };
+      const fallbackStart = await qiweiLoginStart({ ...verifyCommon, flowUi: false, openBrowser: false });
+      assert.strictEqual(fallbackStart.status, 'ok');
+      assert.strictEqual(fallbackStart.summary.fallbackServer, true);
+      assert.ok(fallbackStart.summary.flowUrl.startsWith('http://127.0.0.1:'), 'fallback server should return a local URL');
+      assertProviderHidden(fallbackStart);
+
+      const fallbackUrl = fallbackStart.summary.flowUrl;
+      const pageRes = await fetch(fallbackUrl);
+      assert.strictEqual(pageRes.status, 200);
+      const pageHtml = await pageRes.text();
+      assert.ok(pageHtml.includes('企业微信扫码登录'));
+      assert.ok(!pageHtml.includes('sk-test-relay-token'), 'fallback page must not expose token');
+
+      const qrRes = await fetch(`${fallbackUrl}flow/qrcode`);
+      assert.strictEqual(qrRes.status, 200);
+      assert.strictEqual(qrRes.headers.get('content-type'), 'image/png');
+      const qrBody = await qrRes.arrayBuffer();
+      assert.deepStrictEqual(Buffer.from(qrBody), Buffer.from('mock-png'));
+
+      const checkRes = await fetch(`${fallbackUrl}flow/check`, { method: 'POST' });
+      assert.strictEqual(checkRes.status, 200);
+      const checkJson = await checkRes.json();
+      assert.strictEqual(checkJson.status, 10);
+
+      const verifyRes = await fetch(`${fallbackUrl}flow/verify`, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ code: '123456' })
+      });
+      assert.strictEqual(verifyRes.status, 200);
+      const verifyJson = await verifyRes.json();
+      assert.strictEqual(verifyJson.ok, true);
+      const lastVerifyRequest = verifyGateway.requests.at(-1);
+      assert.strictEqual(lastVerifyRequest.path, '/login/verify');
+      assert.deepStrictEqual(lastVerifyRequest.body, { uid: verifyCommon.uid, code: '123456' });
+
+      const checkAfterRes = await fetch(`${fallbackUrl}flow/check`, { method: 'POST' });
+      assert.strictEqual(checkAfterRes.status, 200);
+      const checkAfterJson = await checkAfterRes.json();
+      assert.strictEqual(checkAfterJson.status, 2);
+      assert.strictEqual(checkAfterJson.detail.userId, 'u-1');
+
+      console.log('[ok] fallback login server serves QR, polls status and accepts verify code');
+    } finally {
+      await verifyGateway.close();
+      stopLoginFallbackServer();
+    }
+
+    const friendStatus = await qiweiCheckFriendStatus({ ...common, guid: 'guid-1', phones: ['13800000000'] });
+    assert.strictEqual(friendStatus.status, 'ok');
+    assert.strictEqual(friendStatus.summary.confirmed, 1);
+    assert.strictEqual(friendStatus.data.details[0].statusText, 'already_friend');
+    assertProviderHidden(friendStatus);
+
+    const portraitFile = path.join(__dirname, '..', 'outputs', 'portraits', 'wx-u-1.json');
+    const tagFile = path.join(__dirname, '..', 'outputs', 'tags', 'wx-u-1.json');
+    try { fs.unlinkSync(portraitFile); } catch {}
+    try { fs.unlinkSync(tagFile); } catch {}
+
+    const profile = await qiweiGetCustomerProfile({ ...common, guid: 'guid-1', phone: '13800000000' });
+    assert.strictEqual(profile.status, 'ok');
+    assert.strictEqual(profile.summary.hasPortrait, false);
+    assert.strictEqual(profile.data.contactList.length, 1);
+    assert.strictEqual(profile.data.rooms.length, 2);
+    assertProviderHidden(profile);
+    console.log('[ok] customer-ops tools check friend status and query customer profile');
+
+    const groupMappingFile = path.join(__dirname, '..', 'outputs', 'groups', 'confirmed-mapping.json');
+    const keywordConfigFile = path.join(__dirname, '..', 'outputs', 'groups', 'customer-keywords.json');
+    const rejectedMappingFile = path.join(__dirname, '..', 'outputs', 'groups', 'rejected-mapping.json');
+    try { fs.unlinkSync(groupMappingFile); } catch {}
+    try { fs.unlinkSync(keywordConfigFile); } catch {}
+    try { fs.unlinkSync(rejectedMappingFile); } catch {}
+
+    const configured = await qiweiConfigureGroupKeywords({ reset: true });
+    assert.strictEqual(configured.status, 'ok');
+    assert.strictEqual(configured.summary.matchMode, 'threshold');
+    assert(configured.summary.keywordCount >= 10);
+
+    const synced = await qiweiSyncExternalGroups({ ...common, guid: 'guid-1', scope: 'all' });
+    assert.strictEqual(synced.status, 'ok');
+    assert.strictEqual(synced.summary.scanned, 3);
+    assert.strictEqual(synced.summary.selfCount, 2);
+    assert.strictEqual(synced.summary.sessionCount, 1);
+    assert.strictEqual(synced.summary.autoConfirmed, 1);
+    assert.strictEqual(synced.summary.suggested, 1);
+    assert.strictEqual(synced.summary.imported, 1);
+    assertProviderHidden(synced);
+
+    const listed = await qiweiListExternalGroups({});
+    assert.strictEqual(listed.status, 'ok');
+    assert.strictEqual(listed.summary.total, 3);
+    assert.strictEqual(listed.summary.autoConfirmed, 1);
+    assert.strictEqual(listed.summary.suggested, 1);
+    assert.strictEqual(listed.summary.imported, 1);
+    assertProviderHidden(listed);
+
+    const suggestedOnly = await qiweiListExternalGroups({ status: 'SUGGESTED' });
+    assert.strictEqual(suggestedOnly.status, 'ok');
+    assert.strictEqual(suggestedOnly.summary.total, 1);
+    assert.strictEqual(suggestedOnly.data.groups[0].roomId, 'r-3');
+
+    const rejected = await qiweiRejectExternalGroup({ roomId: 'r-2', reason: '测试拒绝' });
+    assert.strictEqual(rejected.status, 'ok');
+    assert.strictEqual(rejected.summary.reviewStatus, 'REJECTED');
+
+    const listedWithRejected = await qiweiListExternalGroups({ includeRejected: true });
+    assert.strictEqual(listedWithRejected.status, 'ok');
+    assert.strictEqual(listedWithRejected.summary.rejected, 1);
+
+    const analyzed = await qiweiAnalyzeGroupMembers({ ...common, guid: 'guid-1', roomIds: ['r-1', 'r-3'], previewMessages: 5, updateSnapshot: true });
+    assert.strictEqual(analyzed.status, 'ok');
+    assert.strictEqual(analyzed.summary.analyzed, 2);
+    assert.strictEqual(analyzed.summary.autoConfirmed, 1);
+    assertProviderHidden(analyzed);
+
+    const confirmed = await qiweiConfirmExternalGroup({ roomId: 'r-1', externalUserId: 'wx-u-1', customerName: '张三' });
+    assert.strictEqual(confirmed.status, 'ok');
+    assert.strictEqual(confirmed.summary.reviewStatus, 'CONFIRMED');
+
+    const added = await qiweiAddExternalGroup({ roomId: 'r-99', roomName: '手动群', externalUserId: 'wx-u-2' });
+    assert.strictEqual(added.status, 'ok');
+
+    const messages = await qiweiSyncGroupMessages({ ...common, guid: 'guid-1', roomIds: ['r-1'] });
+    assert.strictEqual(messages.status, 'ok');
+    assert.strictEqual(messages.summary.totalNew, 1);
+    assertProviderHidden(messages);
+    console.log('[ok] group-management tools sync, list, analyze, configure, reject, confirm and sync messages');
+
+    const scanManifestFile = path.join(__dirname, '..', 'outputs', 'groups', 'group-scan-manifest.json');
+    assert(fs.existsSync(scanManifestFile), 'group-scan-manifest.json should exist');
+    assert(fs.existsSync(keywordConfigFile), 'customer-keywords.json should exist');
+    assert(fs.existsSync(rejectedMappingFile), 'rejected-mapping.json should exist');
+
+    const messageFile = path.join(__dirname, '..', 'outputs', 'messages', 'room-r1-test-portrait.json');
+    fs.mkdirSync(path.dirname(messageFile), { recursive: true });
+    fs.writeFileSync(messageFile, JSON.stringify([
+      { fromRoomId: 'r1', seq: 2, senderId: 'wx-u-1', senderName: '张三', msgType: 1, content: '我想买房,预算200万', msgUniqueIdentifier: 'm-2', timestamp: Math.floor(Date.now() / 1000) }
+    ]));
+
+    const prepared = await qiweiPrepareCustomerPortrait({ externalUserId: 'wx-u-1', roomIds: ['r1'] });
+    assert.strictEqual(prepared.status, 'ok');
+    assert.strictEqual(prepared.summary.messageCount, 1);
+
+    const updated = await qiweiUpdateCustomerPortrait({ externalUserId: 'wx-u-1', aiMode: 'keyword', roomIds: ['r1'] });
+    assert.strictEqual(updated.status, 'ok');
+    assert.strictEqual(updated.summary.fields.length > 0, true);
+
+    const saved = await qiweiSaveCustomerPortrait({ externalUserId: 'wx-u-1', portrait: { intent: '购房', budgetRange: '200万' } });
+    assert.strictEqual(saved.status, 'ok');
+
+    const tagsAdded = await qiweiAddCustomerTags({ externalUserId: 'wx-u-1', tags: ['高意向', '购房'] });
+    assert.strictEqual(tagsAdded.status, 'ok');
+    assert.strictEqual(tagsAdded.summary.total, 2);
+
+    const tagsListed = await qiweiListCustomerTags({ externalUserId: 'wx-u-1' });
+    assert.strictEqual(tagsListed.status, 'ok');
+    assert.strictEqual(tagsListed.data.tags.includes('高意向'), true);
+
+    const labels = await qiweiSyncPersonalLabels({ ...common, guid: 'guid-1' });
+    assert.strictEqual(labels.status, 'ok');
+    assert.strictEqual(labels.summary.count, 1);
+
+    const labelCreated = await qiweiCreatePersonalLabel({ ...common, guid: 'guid-1', labelName: '测试标签' });
+    assert.strictEqual(labelCreated.status, 'ok');
+    assertProviderHidden(labelCreated);
+
+    try { fs.unlinkSync(messageFile); } catch {}
+    console.log('[ok] portrait-tags tools prepare, update, save portrait and manage tags/labels');
+
+    const brokerMessageFile = path.join(__dirname, '..', 'outputs', 'messages', 'room-r2-test-playbook.json');
+    fs.writeFileSync(brokerMessageFile, JSON.stringify([
+      { fromRoomId: 'r2', seq: 1, senderId: 'broker-1', senderName: '顾问', msgType: 1, content: '您好,这个户型很适合您,预算也匹配', msgUniqueIdentifier: 'bm-1', timestamp: Math.floor(Date.now() / 1000) }
+    ]));
+
+    const brokerPrepared = await qiweiPrepareBrokerPlaybook({ brokerUserId: 'broker-1', roomIds: ['r2'] });
+    assert.strictEqual(brokerPrepared.status, 'ok');
+    assert.strictEqual(brokerPrepared.summary.messageCount, 1);
+
+    const brokerDistilled = await qiweiDistillBroker({ brokerUserId: 'broker-1', aiMode: 'keyword', roomIds: ['r2'] });
+    assert.strictEqual(brokerDistilled.status, 'ok');
+    assert.strictEqual(brokerDistilled.summary.dimensions.length > 0, true);
+
+    const brokerSaved = await qiweiSaveBrokerPlaybook({ brokerUserId: 'broker-1', playbook: { '客户分层': '精准匹配', '开场白': '您好' } });
+    assert.strictEqual(brokerSaved.status, 'ok');
+
+    const brokerGot = await qiweiGetBrokerPlaybook({ brokerUserId: 'broker-1' });
+    assert.strictEqual(brokerGot.status, 'ok');
+    assert.strictEqual(brokerGot.data.brokerUserId, 'broker-1');
+
+    try { fs.unlinkSync(brokerMessageFile); } catch {}
+    console.log('[ok] broker-playbook tools prepare, distill, save and get playbook');
+
+    const preview = await qiweiPreviewTransferPackage({ fromUserId: 'old-broker', toUserId: 'new-broker', roomIds: ['r-1'] });
+    assert.strictEqual(preview.status, 'ok');
+    assert.strictEqual(preview.summary.itemCount, 1);
+
+    const executed = await qiweiExecuteTransfer({ ...common, guid: 'guid-1', removeOldBroker: true });
+    assert.strictEqual(executed.status, 'ok');
+    assert.strictEqual(executed.summary.success, 1);
+    assertProviderHidden(executed);
+    console.log('[ok] customer-transfer tools preview and execute transfer');
+
+    const wavHeader = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAA=='; // minimal WAV header-ish base64
+    const voice = await qiweiTranscribeVoice({ base64Audio: wavHeader, transcribe: false });
+    assert.strictEqual(voice.status, 'ok');
+    assert.strictEqual(voice.summary.sourceExt, 'wav');
+    console.log('[ok] voice tool saves audio file');
+
+    const statusBefore = await qiweiWebhookStatus({});
+    assert.strictEqual(statusBefore.status, 'ok');
+    assert.strictEqual(statusBefore.summary.running, false);
+
+    const started = await qiweiWebhookServerStart({ port: 0 });
+    assert.strictEqual(started.status, 'ok');
+    assert.strictEqual(typeof started.summary.port, 'number');
+
+    const discovered = await qiweiWebhookDiscover({});
+    assert.strictEqual(discovered.status, 'ok');
+    assert.strictEqual(discovered.summary.running, true);
+
+    const autoSetup = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
+    assert.strictEqual(autoSetup.status, 'ok');
+    assert.strictEqual(gateway.requests.at(-1).body.method, '/client/setCallback');
+    const setCallbackParams = gateway.requests.at(-1).body.params;
+    assert.strictEqual(setCallbackParams.callbackUrl.startsWith('http://127.0.0.1:'), true);
+    assert.strictEqual(setCallbackParams.authSecret && setCallbackParams.authSecret.length > 0, true);
+    assert.strictEqual(setCallbackParams.guid, undefined);
+    assertProviderHidden(autoSetup);
+
+    const localUrl = discovered.data.localUrl;
+    const callbackRes = await fetch(localUrl, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ msgType: 1, content: 'test', fromRoomId: 'r-1' })
+    });
+    assert.strictEqual(callbackRes.status, 200);
+
+    const stopped = await qiweiWebhookServerStop({});
+    assert.strictEqual(stopped.status, 'ok');
+    assert.strictEqual(stopped.data.stopped, true);
+    console.log('[ok] webhook-relay tools start server, auto-setup and ingest callback');
+
     for (const request of gateway.requests) {
       assert.strictEqual(request.authorization, 'Bearer sk-test-relay-token');
     }
@@ -184,6 +583,9 @@ async function main() {
     await gateway.close();
   }
 
+  stopLoginFlowServer();
+  stopLoginFallbackServer();
+
   console.log('\nAll smoke tests passed.');
 }
 

+ 10 - 0
claude-code/claude-code-qiwe-assistant/scripts/start-dashboard.js

@@ -0,0 +1,10 @@
+const path = require('path');
+const { startServer } = require('../mcp/src/dashboard/server');
+
+// 确保工作目录为项目根目录,以便正确读取 .env / .claude/settings.json 等配置
+process.chdir(path.resolve(__dirname, '..'));
+
+startServer().catch(error => {
+  console.error('启动 Dashboard 失败:', error);
+  process.exit(1);
+});

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

@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+/**
+ * Relay 长轮询客户端
+ *
+ * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件,
+ * 用本地 RSA 私钥解密后落盘到 outputs/webhook/。
+ *
+ * 启动方式:
+ *   node scripts/start-relay-client.js [device-guid]
+ *   npm run relay
+ */
+
+const crypto = require('crypto');
+const { saveWebhookEvent } = require('../mcp/src/core/webhook-server');
+const {
+  getRelayBaseUrl,
+  getTenantApiSecret,
+  getRelayPrivateKey,
+  getTenantId,
+  getRelayDeviceGuid
+} = require('../mcp/src/core/relay-config');
+
+const POLL_WAIT_MS = 30000;
+const INITIAL_BACKOFF_MS = 1000;
+const MAX_BACKOFF_MS = 60000;
+
+function saveEvent(eventId, payload) {
+  const filePath = saveWebhookEvent({ eventId, ...payload }, 'relay');
+  return filePath;
+}
+
+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');
+}
+
+async function ackEvents(baseUrl, apiSecret, guid, eventIds) {
+  if (!eventIds.length) return;
+  try {
+    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());
+      return;
+    }
+
+    const data = await res.json();
+    console.log(`[RelayClient] ACK ${data.ackedCount || eventIds.length} 条事件`);
+  } catch (err) {
+    console.warn('[RelayClient] ACK 请求异常:', err.message);
+  }
+}
+
+async function runPollOnce(baseUrl, 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 baseUrl = getRelayBaseUrl();
+  const apiSecret = getTenantApiSecret();
+  const privateKey = getRelayPrivateKey();
+  const tenantId = getTenantId();
+
+  // guid 优先级:命令行参数 > 环境变量 > relay-config.json
+  const guid = process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid();
+
+  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, 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);
+});

+ 82 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-broker-playbook/SKILL.md

@@ -0,0 +1,82 @@
+---
+name: qiwei-broker-playbook
+description: 迁移自 Qiwei 项目的顾问 playbook 能力:准备/蒸馏/保存/批量/导出顾问 playbook、读取已保存 playbook。数据保存在 outputs/broker-playbooks/。
+---
+
+# 顾问 Playbook
+
+## 使用边界
+
+本 skill 负责:
+
+- 基于群消息准备顾问 playbook 分析上下文;
+- 关键词模式快速生成简版 playbook;
+- 保存 Agent 分析后的 playbook;
+- 批量蒸馏/保存/导出 playbook;
+- 读取已保存的 playbook。
+
+## 数据存储
+
+- Playbook 文件:`outputs/broker-playbooks/<brokerUserId>.json`
+- 分析上下文:`outputs/broker-playbooks/<timestamp>/context-<brokerUserId>.json`
+- Playbook 导出:`outputs/broker-playbooks/export-<timestamp>.xlsx`
+
+## 标准流程
+
+### 准备顾问 playbook
+
+```json
+{
+  "brokerUserId": "broker-1",
+  "roomIds": ["r1"]
+}
+```
+
+返回分析上下文。分析后调用 `qiwei_save_broker_playbook` 保存。
+
+### 关键词模式蒸馏
+
+```json
+{
+  "brokerUserId": "broker-1",
+  "aiMode": "keyword",
+  "roomIds": ["r1"]
+}
+```
+
+直接基于消息关键词生成简版 playbook 并保存。
+
+### 保存 playbook
+
+```json
+{
+  "brokerUserId": "broker-1",
+  "playbook": {
+    "客户分层": "...",
+    "开场白": "..."
+  }
+}
+```
+
+### 批量与导出
+
+- `qiwei_batch_distill_broker`
+- `qiwei_batch_save_broker_playbook`
+- `qiwei_export_broker_playbooks`
+- `qiwei_get_broker_playbook`
+
+## 迁移说明
+
+原 Qiwei 项目中的 `BrokerPlaybook` SQLite 表改为文件化:
+
+- Playbook 分析 → `outputs/broker-playbooks/`
+
+MCP 工具:
+
+- `qiwei_prepare_broker_playbook`
+- `qiwei_distill_broker`
+- `qiwei_save_broker_playbook`
+- `qiwei_batch_distill_broker`
+- `qiwei_batch_save_broker_playbook`
+- `qiwei_export_broker_playbooks`
+- `qiwei_get_broker_playbook`

+ 115 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-ops/SKILL.md

@@ -0,0 +1,115 @@
+---
+name: qiwei-customer-ops
+description: 迁移自 Qiwei 项目的客户群运营能力:批量搜索并添加企微好友、检查好友申请状态、查询客户档案、自动创建客户服务群、设置群名、邀请协作成员、发送欢迎语。所有企微调用必须通过 Fmode 网关,并以 mcp/catalog/qiwei-endpoints.json 的 method 与参数为准;不替换 qiwei-login、qiwei-api-catalog、订阅和官方 CLI 既有流程。
+---
+
+# 企微客户群运营
+
+## 使用边界
+
+本 skill 只负责客户运营业务动作:
+
+- 批量按手机号搜索并添加好友;
+- 检查好友申请状态;
+- 按手机号或 externalUserId 查询客户档案;
+- 按外部联系人 `userId`/成员列表创建服务群;
+- 可选设置群名、邀请协作成员、发送欢迎语。
+
+不要在这里重新实现登录、订阅、设备恢复或通用 API 检索流程。遇到未登录、订阅不足、token 缺失时,转用已有工具:
+
+- `qiwei_login_status`
+- `qiwei_login_start`
+- `qiwei_subscription_status`
+- `qiwei_subscribe`
+- `qiwei_api_search`
+- `qiwei_api_doc`
+- `qiwei_api_call`
+
+## 接口规范
+
+业务工具必须符合 `mcp/catalog/qiwei-endpoints.json`:
+
+- 批量加好友与检查好友状态使用 `/contact/searchContact`;
+- 自动建群使用 `/room/createRoom`、`/room/modifyRoomName`、`/room/inviteRoomMember`、`/msg/sendText`;
+- 客户档案使用 `/contact/searchContact`、`/contact/getWxContactList`、`/room/getRoomList`;
+- 请求信封固定为 `{ "uid": "...", "method": "...", "params": {...} }`;
+- 鉴权固定走 `Authorization: Bearer <Fmode token>`;
+- `guid` 必须来自已登录设备,不要让用户配置企业微信底层访问凭据。
+
+## 标准流程
+
+### 批量加好友
+
+1. 如用户没有确认登录设备,先调用 `qiwei_login_status`。
+2. 调用 `qiwei_batch_add_friends`,传入:
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "customers": [
+    { "phone": "13800000000", "name": "张三", "greeting": "你好,方便加您企业微信沟通。" }
+  ],
+  "rateLimitPerMinute": 12
+}
+```
+
+也可以传 `phones`,或安装依赖后传 `filePath` 读取 Excel。Excel 至少需要手机号列。
+
+### 检查好友申请状态
+
+批量加好友后,可调用 `qiwei_check_friend_status` 查看哪些客户已通过、待通过或未找到:
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "phones": ["13800000000", "13900000000"]
+}
+```
+
+返回每条手机号的 `searchStatus` 与 `statusText`:
+
+- `already_friend`:已是双向好友;
+- `already_added_by_other`:已被其他人添加;
+- `not_added`:可发起好友申请;
+- `not_found`:搜索不到该用户。
+
+### 查询客户档案
+
+拿到 externalUserId 或手机号后,调用 `qiwei_get_customer_profile`:
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "phone": "13800000000"
+}
+```
+
+返回联系人搜索结果、外部联系人列表匹配项、群列表,以及本地已保存的画像文件(如有)。
+
+### 自动建群
+
+1. 确认客户已经通过好友申请,并拿到外部联系人 `userId`。
+2. 调用 `qiwei_auto_create_group`:
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "customerUserId": "<客户 userId>",
+  "supportMemberIds": ["<协作成员 userId>"],
+  "groupName": "张三服务群",
+  "welcomeText": "欢迎进群,我们会在这里同步服务进展。"
+}
+```
+
+如有多个初始成员,传 `memberList` 代替 `customerUserId`。
+
+## 迁移说明
+
+原 Qiwei 项目中的 Express 路由、SQLite 业务表和本地任务记录没有接管目标包已有运行形态。本迁移保留核心企微动作,并改为 Claude Code MCP 工具:
+
+- `qiwei_batch_add_friends`
+- `qiwei_check_friend_status`
+- `qiwei_get_customer_profile`
+- `qiwei_auto_create_group`
+
+这样可以复用目标包已有登录、订阅、鉴权、错误分层和 catalog 驱动能力,同时避免改动已经写好的流程。

+ 55 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-customer-transfer/SKILL.md

@@ -0,0 +1,55 @@
+---
+name: qiwei-customer-transfer
+description: 迁移自 Qiwei 项目的客户交接能力:预览交接包、执行群成员变更完成客户交接。数据保存在 outputs/transfers/。
+---
+
+# 客户交接
+
+## 使用边界
+
+本 skill 负责:
+
+- 根据 fromUserId/toUserId 和 roomIds/externalUserIds 生成交接包预览;
+- 执行交接:邀请新顾问入群、可选移除旧顾问、发送欢迎语。
+
+## 数据存储
+
+- 预览包:`outputs/transfers/preview-<timestamp>.json`
+- 执行日志:`outputs/transfers/execution-<timestamp>.json`
+
+## 标准流程
+
+### 预览交接包
+
+```json
+{
+  "fromUserId": "old-broker",
+  "toUserId": "new-broker",
+  "roomIds": ["r-1", "r-2"]
+}
+```
+
+系统会读取 `outputs/groups/confirmed-mapping.json` 补充客户信息,生成交接包。
+
+### 执行交接
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "removeOldBroker": true,
+  "welcomeText": "您好,我是您的新服务顾问,后续由我为您服务。"
+}
+```
+
+不传 `previewFile` 时默认使用最新的预览包。
+
+## 迁移说明
+
+原 Qiwei 项目中的 `BrokerTransferPackage` SQLite 表改为文件化:
+
+- 预览包/执行日志 → `outputs/transfers/`
+
+MCP 工具:
+
+- `qiwei_preview_transfer_package`
+- `qiwei_execute_transfer`

+ 110 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-dashboard/SKILL.md

@@ -0,0 +1,110 @@
+---
+name: qiwei-dashboard
+description: 启动并管理 Qiwei Dashboard(本地 Web 操作界面)。必须在项目根目录 claude-code/claude-code-qiwe-assistant 下运行 npm run dashboard,确保正确读取 .env.local 中的 QIWEI_AUTH_TOKEN、QIWEI_UID 和 QIWEI_API_BASE。
+---
+
+# Qiwei Dashboard
+
+## 使用边界
+
+本 skill 只负责:
+
+- 启动本地 Dashboard Web 服务;
+- 确认服务健康、登录状态和订阅状态;
+- 在登录失效时引导用户恢复登录;
+- 告诉用户访问地址 `http://127.0.0.1:4320/`。
+
+业务操作(群管理、客户运营、画像标签等)由用户在浏览器里完成,或通过其他专门 skill 调用 MCP 工具完成。本 skill 不替代登录、订阅、业务工具 skill。
+
+## 前置条件
+
+1. 必须位于项目目录 `claude-code/claude-code-qiwe-assistant`;
+2. 必须已经配置 Fmode 鉴权 token(`.env.local` 或环境变量 `QIWEI_AUTH_TOKEN` / `FMODE_API_KEY`);
+3. 可选但推荐配置 `QIWEI_UID`;未配置时会自动生成并写入 `.env.local`。
+
+## 启动流程
+
+**必须**先切换工作目录到项目根目录,再执行启动命令:
+
+```bash
+cd claude-code/claude-code-qiwe-assistant
+npm run dashboard
+```
+
+服务启动后会输出:
+
+```text
+Qiwei Dashboard 已启动:http://127.0.0.1:4320/
+```
+
+## 端口与环境变量
+
+- 默认端口:`4320`
+- 可通过环境变量覆盖:`QIWEI_DASHBOARD_PORT=4320`
+- 服务绑定在 `127.0.0.1`,仅本机可访问
+
+## 健康检查
+
+启动后调用:
+
+```bash
+curl -s http://127.0.0.1:4320/api/health
+```
+
+期望返回:
+
+```json
+{"status":"ok"}
+```
+
+## 状态检查
+
+```bash
+curl -s http://127.0.0.1:4320/api/status
+```
+
+返回示例:
+
+```json
+{
+  "status": "ok",
+  "summary": {
+    "authConfigured": true,
+    "online": true,
+    "subscribed": true
+  }
+}
+```
+
+如果 `online: false` 且登录状态码为 `0`,Dashboard 会自动尝试免扫码恢复登录;否则需要用户在浏览器中点击「恢复登录」按钮完成扫码。
+
+## 常见错误
+
+### Dashboard 显示「网络请求失败」但浏览器能访问 server.fmode.cn
+
+原因:Dashboard 启动时的工作目录不对,没有读取到 `.env.local` 中的 token。
+
+解决:
+
+```bash
+cd claude-code/claude-code-qiwe-assistant
+npm run dashboard
+```
+
+### 端口被占用
+
+```bash
+# Windows
+netstat -ano | findstr 4320
+taskkill /PID <PID> /F
+
+# macOS / Linux
+lsof -i :4320
+kill -9 <PID>
+```
+
+## 文件位置
+
+- 服务入口:`scripts/start-dashboard.js`
+- HTTP 桥接服务:`mcp/src/dashboard/server.js`
+- 前端 SPA:`mcp/src/dashboard/index.html`、`app.js`、`styles.css`

+ 204 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-group-management/SKILL.md

@@ -0,0 +1,204 @@
+---
+name: qiwei-group-management
+description: 迁移自 Qiwei 项目的群管理能力:同步外部群列表、按关键词分析/识别客户群、确认/手动添加外部群、同步群历史消息。支持自建群与非自建群扫描,所有调用通过 Fmode 网关,状态以文件形式保存在 outputs/groups/ 和 outputs/messages/。
+---
+
+# 企微群管理
+
+## 使用边界
+
+本 skill 负责群的发现、识别、确认和消息同步:
+
+- 同步群列表(支持自建群、最近会话群、消息流反推三种来源);
+- 列出已同步群,并按状态/关键词/来源过滤;
+- 分析群详情,按可配置关键词自动识别客户群;
+- 手动确认某个群为客户群,并关联客户;
+- 手动添加同步列表之外的 roomId;
+- 手动拒绝某个群;
+- 配置客户群识别关键词与匹配模式;
+- 同步已确认群的历史消息。
+
+不要在这里重新实现登录、订阅、通用 API 检索流程。遇到未登录、订阅不足、token 缺失时,转用已有工具:
+
+- `qiwei_login_status`
+- `qiwei_login_start`
+- `qiwei_subscription_status`
+- `qiwei_subscribe`
+- `qiwei_api_search`
+- `qiwei_api_doc`
+
+## 接口规范
+
+业务工具必须符合 `mcp/catalog/qiwei-endpoints.json`:
+
+- 同步自建群列表使用 `/room/getRoomList`;
+- 扫描最近会话群使用 `/session/getSessionPage`,`sessionType=1` 表示群;
+- 消息流反推群使用 `/msg/syncMsg` + `/room/batchGetRoomDetail`;
+- 分析群详情使用 `/room/batchGetRoomDetail`;
+- 同步消息使用 `/msg/syncMsg`;
+- 请求信封固定为 `{ "uid": "...", "method": "...", "params": {...} }`;
+- 鉴权固定走 `Authorization: Bearer <Fmode token>`;
+- `guid` 必须来自已登录设备。
+
+## 标准流程
+
+### 第 0 步:确认群创建方式
+
+先询问用户:"您要识别的客户群,是您自己创建的吗?"
+
+- **是** → 调用 `qiwei_sync_external_groups`,`scope: "self"`(仅扫描 `/room/getRoomList`)。
+- **否 / 不确定** → 调用 `qiwei_sync_external_groups`,`scope: "all"`;如需深度兜底,可附加 `scanFromMessages: true`。
+
+### 第 1 步:同步外部群
+
+示例(用户不确定是否自建):
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "scope": "all",
+  "scanFromMessages": false,
+  "maxPages": 50,
+  "autoClassify": true,
+  "matchMode": "threshold",
+  "threshold": 2,
+  "previewMessages": 10,
+  "previewOnlyForUnsure": true
+}
+```
+
+- `previewMessages`:对不确定的群拉取最近 N 条消息辅助识别,默认 `0` 表示不拉取;
+- `previewOnlyForUnsure`:仅对初筛不是 `AUTO_CONFIRMED` 的群拉消息,默认 `true`。
+
+结果写入:
+
+- `outputs/groups/2026-07-15/142030-sync-external-groups/rooms-<timestamp>.json`
+- `outputs/groups/rooms-latest.json`
+- `outputs/groups/group-scan-manifest.json`
+
+群记录包含:
+
+- `source` / `sources`:群来自哪个扫描来源(`roomList`、`session`、`messages`)
+- `roomExtType`:`0`=内部群,`2`=外部群
+- `reviewStatus`:`IMPORTED` / `SUGGESTED` / `AUTO_CONFIRMED`
+- `confidence`:置信度
+- `reason`:识别原因
+- `matchedKeywords`:命中关键词
+
+### 第 2 步:列出并识别客户群
+
+```json
+{
+  "status": "AUTO_CONFIRMED"
+}
+```
+
+返回最近一次同步的群,每个群附带:
+
+- `reviewStatus`: `IMPORTED` / `SUGGESTED` / `AUTO_CONFIRMED` / `CONFIRMED` / `REJECTED`
+- `reason`: 识别原因
+- `matchedKeywords`: 命中关键词
+
+也可调用 `qiwei_analyze_group_members` 对指定 roomId 做更详细的群详情分析:
+
+```json
+{
+  "guid": "<已登录设备 guid>",
+  "roomIds": ["r-1", "r-2"],
+  "previewMessages": 10,
+  "updateSnapshot": true
+}
+```
+
+### 第 3 步:确认或拒绝客户群
+
+对最近一次同步列表中的 roomId 确认:
+
+```json
+{
+  "roomId": "r-1",
+  "externalUserId": "wx-u-1",
+  "customerName": "张三"
+}
+```
+
+映射写入 `outputs/groups/confirmed-mapping.json`。
+
+如需拒绝:
+
+```json
+{
+  "roomId": "r-2",
+  "reason": "内部通知群"
+}
+```
+
+写入 `outputs/groups/rejected-mapping.json`。
+
+如需添加同步列表之外的群,使用 `qiwei_add_external_group`:
+
+```json
+{
+  "roomId": "r-99",
+  "roomName": "李四服务群",
+  "externalUserId": "wx-u-2"
+}
+```
+
+### 第 4 步:配置关键词(可选)
+
+当自动分类效果不佳时,调优关键词:
+
+```json
+{
+  "keywords": ["客户群", "服务群", "售后群", "VIP群"],
+  "highConfidenceTerms": ["客户群", "服务群"],
+  "matchMode": "threshold",
+  "threshold": 2
+}
+```
+
+配置持久化到 `outputs/groups/customer-keywords.json`,会立即影响后续 `qiwei_sync_external_groups` 和 `qiwei_list_external_groups` 的分类结果。
+
+### 第 5 步:同步群消息
+
+对已确认的客户群同步历史消息:
+
+```json
+{
+  "guid": "<已登录设备 guid>"
+}
+```
+
+不传 `roomIds` 时默认同步所有已确认映射中的群。结果按 room 写入 `outputs/messages/room-<roomId>-<timestamp>.json`。
+
+## 状态说明
+
+- `IMPORTED`:已扫描但未命中关键词
+- `SUGGESTED`:低置信候选,建议人工确认
+- `AUTO_CONFIRMED`:自动命中关键词或高置信度词
+- `CONFIRMED`:经纪人手动确认
+- `REJECTED`:经纪人手动拒绝
+
+## 迁移说明
+
+原 Qiwei 项目中的 `ExternalGroup` SQLite 表、群状态机和关键词识别逻辑改为文件化:
+
+- 群列表 → `outputs/groups/rooms-*.json`
+- 最新快照 → `outputs/groups/rooms-latest.json`
+- 确认映射 → `outputs/groups/confirmed-mapping.json`
+- 拒绝映射 → `outputs/groups/rejected-mapping.json`
+- 关键词配置 → `outputs/groups/customer-keywords.json`
+- 扫描摘要 → `outputs/groups/group-scan-manifest.json`
+- 群消息 → `outputs/messages/room-*.json`
+
+MCP 工具:
+
+- `qiwei_sync_external_groups`
+- `qiwei_list_external_groups`
+- `qiwei_analyze_group_members`
+- `qiwei_confirm_external_group`
+- `qiwei_add_external_group`
+- `qiwei_reject_external_group`
+- `qiwei_configure_group_keywords`
+- `qiwei_sync_group_messages`

+ 118 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md

@@ -0,0 +1,118 @@
+---
+name: qiwei-portrait-tags
+description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更新/保存/批量/导出客户画像、本地客户标签管理、企微个人标签同步与增删改及应用。画像和标签数据保存在 outputs/portraits/ 和 outputs/tags/。
+---
+
+# 客户画像与标签
+
+## 使用边界
+
+本 skill 负责:
+
+- 基于群消息准备客户画像分析上下文;
+- 关键词模式快速生成简版画像;
+- 保存 Agent 分析后的画像;
+- 批量更新/保存/导出画像;
+- 本地客户标签增删查;
+- 企微个人标签同步、创建、更新、删除、应用到客户。
+
+## 数据存储
+
+- 画像文件:`outputs/portraits/<externalUserId>.json`
+- 画像上下文:`outputs/portraits/<timestamp>/context-<externalUserId>.json`
+- 画像导出:`outputs/portraits/export-<timestamp>.xlsx`
+- 本地标签:`outputs/tags/<externalUserId>.json`
+
+## 标准流程
+
+### 准备客户画像
+
+```json
+{
+  "externalUserId": "wx-u-1",
+  "roomIds": ["r-1"]
+}
+```
+
+返回分析上下文,包含消息样本和完整文本预览。分析后调用 `qiwei_save_customer_portrait` 保存。
+
+### 关键词模式更新画像
+
+```json
+{
+  "externalUserId": "wx-u-1",
+  "aiMode": "keyword",
+  "roomIds": ["r-1"]
+}
+```
+
+直接基于消息中的关键词生成简版画像并保存。
+
+### 保存画像
+
+```json
+{
+  "externalUserId": "wx-u-1",
+  "portrait": {
+    "intent": "购房",
+    "budgetRange": "200-300万",
+    "aiSummary": "..."
+  }
+}
+```
+
+### 批量更新/保存
+
+- `qiwei_batch_update_customer_portrait`:对多个 externalUserId 执行 `qiwei_update_customer_portrait`。
+- `qiwei_batch_save_customer_portrait`:批量保存画像 items。
+
+### 导出画像
+
+```json
+{
+  "externalUserIds": ["wx-u-1", "wx-u-2"]
+}
+```
+
+不传则导出全部已保存画像。
+
+### 本地标签
+
+- `qiwei_add_customer_tags`: `{ "externalUserId": "wx-u-1", "tags": ["高意向", "学区"] }`
+- `qiwei_remove_customer_tags`: 同上
+- `qiwei_list_customer_tags`: `{ "externalUserId": "wx-u-1" }`
+- `qiwei_list_all_tags`: 无入参
+
+### 企微个人标签
+
+- `qiwei_sync_personal_labels`: 同步个人标签列表
+- `qiwei_create_personal_label`: `{ "labelName": "高意向" }`
+- `qiwei_update_personal_label`: `{ "labelId": "...", "labelName": "高意向客户" }`
+- `qiwei_delete_personal_label`: `{ "labelId": "..." }`
+- `qiwei_apply_personal_labels`: `{ "externalUserId": "wx-u-1", "addLabels": ["..."] }`
+
+## 迁移说明
+
+原 Qiwei 项目中的 `CustomerPortrait`、`Customer.tags`、`QiwePersonalLabel` SQLite 表改为文件化:
+
+- 画像分析 → `outputs/portraits/`
+- 本地标签 → `outputs/tags/`
+- 企微个人标签 → 直接通过 Fmode 网关操作
+
+MCP 工具:
+
+- `qiwei_prepare_customer_portrait`
+- `qiwei_update_customer_portrait`
+- `qiwei_save_customer_portrait`
+- `qiwei_batch_update_customer_portrait`
+- `qiwei_batch_save_customer_portrait`
+- `qiwei_export_customer_portraits`
+- `qiwei_add_customer_tags`
+- `qiwei_remove_customer_tags`
+- `qiwei_list_customer_tags`
+- `qiwei_list_all_tags`
+- `qiwei_sync_personal_labels`
+- `qiwei_create_personal_label`
+- `qiwei_update_personal_label`
+- `qiwei_delete_personal_label`
+- `qiwei_apply_personal_labels`

+ 50 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-voice/SKILL.md

@@ -0,0 +1,50 @@
+---
+name: qiwei-voice
+description: 迁移自 Qiwei 项目的语音转写能力:接收语音 URL、本地文件或 base64,保存到 outputs/voice/,可选解码 silk 并转写。
+---
+
+# 语音转写
+
+## 使用边界
+
+本 skill 负责:
+
+- 下载或接收语音文件;
+- 保存到 `outputs/voice/`;
+- 解码 silk 为 mp3(需可选依赖 `@binsee/wx-voice`);
+- 调用 fmode-listen 转写服务(如配置)。
+
+## 数据存储
+
+- 源文件:`outputs/voice/<timestamp>-transcribe/source.<ext>`
+- 解码后音频:`outputs/voice/<timestamp>-transcribe/audio.mp3`
+- 清单:`outputs/voice/<timestamp>-transcribe/manifest.json`
+
+## 标准流程
+
+```json
+{
+  "filePath": "C:/temp/voice.silk",
+  "transcribe": true
+}
+```
+
+或:
+
+```json
+{
+  "base64Audio": "...",
+  "transcribe": false
+}
+```
+
+## 依赖
+
+- `@binsee/wx-voice`:可选,用于 silk 解码;
+- fmode-listen 服务:通过 `FMODE_LISTEN_URL` 或从 `QIWEI_API_BASE` 推断。
+
+## 迁移说明
+
+原 Qiwei 项目中的语音转写 worker 移除,改为显式调用。MCP 工具:
+
+- `qiwei_transcribe_voice`

+ 143 - 0
claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

@@ -0,0 +1,143 @@
+---
+name: qiwei-webhook-relay
+description: 企微 Webhook 与 Relay:支持本地 webhook 接收、中央 Relay 模式自动注册/配置、Relay 长轮询客户端。
+---
+
+# Webhook 与 Relay
+
+## 使用边界
+
+本 skill 负责:
+
+- 查询本地 webhook server 状态;
+- 启动/停止本地 HTTP server 接收企微回调(开发调试);
+- 在中央 Relay 服务端注册租户、保存凭证;
+- 自动/手动配置企微回调地址到 Relay;
+- 管理 Relay 配置。
+
+## 模式说明
+
+### 本地 Webhook 模式
+
+启动本地 HTTP server(默认绑定 `127.0.0.1`),直接把企微事件写入 `outputs/webhook/`。适合本机能被公网访问或本地调试。
+
+### 中央 Relay 模式(推荐)
+
+企微平台把事件推送到 Fmode 中央 Relay 服务器(`http://8.138.37.248:4000`),本地 Skill 通过长轮询主动取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
+
+## 数据存储
+
+- Webhook 配置:`outputs/webhook/webhook-config.json`
+- Relay 配置:`outputs/webhook/relay-config.json`
+- 回调事件:`outputs/webhook/<date>/<time>-<source>/event-...json`
+
+## 标准流程
+
+### 首次使用自动配置(推荐)
+
+1. 确保 `.env.local` 已配置 `QIWEI_AUTH_TOKEN`。
+2. 调用 `qiwei_login_start` → 用户扫码登录。
+3. 登录成功后,系统会自动:
+   - 提取并保存设备 `guid`;
+   - 在中央 Relay 注册租户(如未注册);
+   - 保存租户凭证到 `.env.local`;
+   - 调用 `/client/setCallback` 把回调地址设为 `{RELAY_BASE_URL}/api/webhook/ingest/{tenantId}/{guid}`;
+   - 返回配置结果,并提示启动 Relay 轮询客户端。
+4. 在服务器上执行 `npm run relay` 启动长轮询(或使用 systemd/pm2 持久化运行)。
+
+### 手动注册 Relay 租户
+
+```json
+{
+  "name": "qiwei_relay_register",
+  "arguments": {
+    "description": "本地 Skill",
+    "force": false
+  }
+}
+```
+
+返回 `tenantId`、`apiKey`、`apiSecret`、`privateKey`,并自动保存到 `.env.local`。
+
+### 手动配置 Relay 回调
+
+```json
+{
+  "name": "qiwei_relay_connect",
+  "arguments": {
+    "guid": "已登录设备 guid"
+  }
+}
+```
+
+### 手动配置本地 Webhook
+
+```json
+{
+  "name": "qiwei_webhook_server_start",
+  "arguments": {
+    "port": 8080
+  }
+}
+```
+
+不传 port 时由系统分配。
+
+```json
+{
+  "name": "qiwei_webhook_auto_setup",
+  "arguments": {
+    "callbackUrl": "https://your-server.com/callback"
+  }
+}
+```
+
+## Relay 客户端持久化运行
+
+### systemd
+
+```ini
+[Unit]
+Description=Qiwei Relay Client
+After=network.target
+
+[Service]
+Type=simple
+User=ubuntu
+WorkingDirectory=/opt/qiwei-assistant
+EnvironmentFile=/opt/qiwei-assistant/.env.local
+ExecStart=/usr/bin/node /opt/qiwei-assistant/scripts/start-relay-client.js
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+```
+
+启动:
+
+```bash
+sudo systemctl daemon-reload
+sudo systemctl enable qiwei-relay
+sudo systemctl start qiwei-relay
+```
+
+### pm2
+
+```bash
+pm2 start scripts/start-relay-client.js --name qiwei-relay
+pm2 save
+```
+
+## MCP 工具
+
+- `qiwei_webhook_status` — 查询 webhook 状态
+- `qiwei_webhook_discover` — 获取本地 webhook 回调地址
+- `qiwei_webhook_server_start` — 启动本地 webhook server
+- `qiwei_webhook_server_stop` — 停止本地 webhook server
+- `qiwei_webhook_auto_setup` — 自动配置企微回调(优先 Relay 模式)
+- `qiwei_webhook_setup` — 手动配置企微回调
+- `qiwei_relay_config` — 读取 Relay 配置
+- `qiwei_relay_save_config` — 保存 Relay 公钥/租户配置
+- `qiwei_relay_register` — 注册 Relay 租户并保存凭证
+- `qiwei_relay_connect` — 配置 Relay 回调地址