commit cd8d36fd0037b015cb517187344c7827531a9cae Author: 浅念 Date: Sat Aug 1 10:53:28 2026 +0800 docs: initial design spec, data dictionary, implementation plan Co-Authored-By: Claude diff --git a/docs/superpowers/plans/2026-08-01-yunke-data-mirror.md b/docs/superpowers/plans/2026-08-01-yunke-data-mirror.md new file mode 100644 index 0000000..e89a3d2 --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-yunke-data-mirror.md @@ -0,0 +1,244 @@ +# 云客数据本地化系统 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 持续抓取云客 CRM(微信+WhatsApp+电话+短信)数据到本地 MySQL + 对象存储,提供自有查看后台与增量同步。 + +**Architecture:** `yunke-server/`(Python/FastAPI)含会话层、抓取层(每渠道一个 scraper,混合 HTTP 重放 + 浏览器兜底)、存储层(MySQL 分表 + 对象存储哈希去重)、调度层(水位增量同步)、API 层;`yunkes/`(Vue3+TS)查看后台。先做 WeChat 端到端骨架,再横向扩展渠道与纵向补全实体。 + +**Tech Stack:** Python 3.11, FastAPI, SQLAlchemy 2.0 + Alembic, MySQL(asyncmy)/Redis, httpx, Playwright, APScheduler, Pydantic v2, pytest(+asyncio/respx) | Vue3 + Vite + TS + Pinia + Vue Router + Element Plus + axios + +**配套文档:** [设计文档](../specs/2026-08-01-yunke-data-mirror-design.md)、[数据字典](../specs/2026-08-01-yunke-data-dictionary.md) + +**说明:** 项目非 git 仓库;每个任务结尾的"commit"步骤在初始化 git 后执行(Phase 0 之后)。 + +--- + +## 文件结构 + +``` +yunke-server/ + pyproject.toml .env.example alembic.ini README.md + app/ + config.py # 环境配置(pydantic-settings) + db/base.py # engine/session/Base + db/models.py # 全部 ORM 模型 + storage/object_store.py # 对象存储抽象(本地磁盘/可换 MinIO) + session/login.py # getSecureKey -> pcLogin + session/yunke_client.py # 带鉴权头的 HTTP 客户端 + 自动续期 + scraper/base.py # 分页/游标/限速 基类 + scraper/parsers.py # 原始 type 码 -> 统一枚举 + scraper/wechat.py # 微信 scraper + scraper/whatsapp.py # WhatsApp scraper + scraper/call.py # 电话+短信 scraper + scraper/media.py # 媒体下载器(每渠道策略) + sync/watermarks.py # sync_state 读写 + sync/orchestrator.py # 全量+增量编排 + sync/scheduler.py # 定时任务 + api/main.py # FastAPI app + api/deps.py + api/routers/{customer,group,message,call,sms,media,search,sync}.py + api/schemas.py # 响应模型 + migrations/ # alembic + tests/ # 镜像结构, +fixtures/cassettes(录制的真实响应) +yunkes/ # Vue3 前端(见 M5) +``` + +--- + +## Phase 0:侦察收尾(实现期首批,前置依赖) + +用浏览器抓包方法(见数据字典 Phase 0)确认以下,结果写入 `docs/superpowers/specs/phase0-findings.md`: +- [ ] 聊天媒体:微信 `file` URL 形态;WhatsApp `mediaName` 下载接口(OSS 桶已确认 `yunke-pcfile`) +- [ ] 媒体类型码:微信/WhatsApp image/voice/video/file/transfer/redpacket/system 的 `type` 枚举 +- [ ] 语音通话字段:`callRecord/queryWeChatVoiceList`、WhatsApp `wavoicecalllist` +- [ ] WhatsApp 联系人 `businessFriendList` 字段 +- [ ] 登录加密:`getSecureKey`/`pcLogin` 请求响应 +- [ ] 群成员昵称:`memberIds` 的 wxid 是否需另调接口取群昵称 +- [ ] 限速阈值观察 +- [ ] 初始化 git:`cd d:\refactor\yunke && git init`(此后所有 commit 步骤生效) + +> Phase 0 产出 `phase0-findings.md` 后,scraper 任务据此填实精确字段;架构不依赖它。 + +--- + +## Milestone 1:地基 + 微信端到端骨架(Walking Skeleton) + +**目标:** 登录 → 抓微信客户列表 + 单聊消息 → 存 MySQL → FastAPI 暴露 → 跑通。证明整条链路。 + +### Task 1.1:项目脚手架与依赖 + +**Files:** Create `yunke-server/pyproject.toml`, `yunke-server/.env.example`, `yunke-server/app/__init__.py`, `yunke-server/app/config.py` + +- [ ] **Step 1:** 写 `pyproject.toml`(依赖:fastapi, uvicorn, sqlalchemy[asyncio], asyncmy, alembic, redis, httpx, playwright, apscheduler, pydantic, pydantic-settings, cryptography; dev: pytest, pytest-asyncio, respx, ruff) +- [ ] **Step 2:** 写 `.env.example`(`YUNKE_BASE_URL`, `YUNKE_USERNAME`, `YUNKE_PASSWORD`, `DB_URL=mysql+asyncmy://...`, `REDIS_URL`, `MEDIA_DIR`, `ENCRYPTION_KEY`) +- [ ] **Step 3:** 写 `app/config.py`(pydantic-settings `Settings`,从环境读取,单例 `get_settings()`) +- [ ] **Step 4:** `pip install -e ".[dev]"` 并 `playwright install chromium` +- [ ] **Step 5:** commit `chore: scaffold yunke-server` + +### Task 1.2:数据库与配置(TDD) + +**Files:** Create `app/db/base.py`, `tests/conftest.py`, `tests/test_config.py` + +- [ ] **Step 1: 写失败测试** `tests/test_config.py`:断言 `get_settings().db_url` 能从环境读取(用 `monkeypatch.setenv`) +- [ ] **Step 2: 跑测试确认失败** `pytest tests/test_config.py -v` → FAIL +- [ ] **Step 3: 实现** `app/config.py` 的 Settings;`app/db/base.py`:`async_engine`、`async_session_maker`、`Base = DeclarativeBase` +- [ ] **Step 4: 跑测试确认通过** +- [ ] **Step 5:** commit + +### Task 1.3:核心 ORM 模型 + +**Files:** Create `app/db/models.py` + +实现(按数据字典):`Department`、`Staff`、`WechatAccount`、`WhatsappAccount`、`Customer`、`ChatGroup`、`GroupMember`、`Message`(按月分表命名规范预留)、`Media`、`PhoneCall`、`Sms`、`SyncState`、`YkAccount`、`SyncRun`。每表带 `channel`、`version`、`raw_json(JSON)`、`fetched_at`、`updated_at`。Message 主键 `msg_id`,联合索引 `(channel, owner_account_id, talker_id, timestamp)`。 + +- [ ] **Step 1: 写测试** `tests/test_models.py`:建内存 SQLite,插入/查询一条 Customer,断言字段映射与 `raw_json` 往返(测试用 SQLite,生产 MySQL) +- [ ] **Step 2: 跑确认失败** +- [ ] **Step 3: 实现 models.py** +- [ ] **Step 4: 跑确认通过** +- [ ] **Step 5:** `alembic init migrations`,生成首版迁移并 `alembic upgrade head`(对 MySQL);commit `feat: core ORM models` + +### Task 1.4:会话层——登录与鉴权客户端(TDD + 录制响应) + +**Files:** Create `app/session/login.py`, `app/session/yunke_client.py`, `tests/cassettes/login.json`, `tests/test_yunke_client.py` + +- [ ] **Step 1:** 把 Phase 0 抓到的登录请求/响应存为 `tests/cassettes/login.json` +- [ ] **Step 2: 写失败测试** `test_yunke_client.py`:用 respx mock `/usercenter/login/pcLogin` 返回 cassette,断言 `YunkeClient` 登录后 `userid`/`company` 头被注入;再 mock 一个数据接口,断言请求头含鉴权 +- [ ] **Step 3: 跑确认失败** +- [ ] **Step 4: 实现** `login.py`(`getSecureKey`→`pcLogin`,按 Phase 0 加密密码;返回 cookie+userid+company);`yunke_client.py`(httpx.AsyncClient 封装,自动注入鉴权头,`post()`/`get()`,侦测 401→重新登录续期) +- [ ] **Step 5: 跑确认通过** +- [ ] **Step 6:** commit `feat: session login + authed client` + +### Task 1.5:微信客户列表 scraper(TDD + 录制响应) + +**Files:** Create `app/scraper/base.py`, `app/scraper/wechat.py`, `tests/cassettes/wxFriends_page1.json`, `tests/test_scrape_wechat_friends.py` + +- [ ] **Step 1:** 存录制响应 `wxFriends_page1.json`(来自 `friend_list/es`) +- [ ] **Step 2: 写失败测试**:respx mock 分页(2 页),断言 `scrape_wechat_friends(client)` 返回 N 条归一化 dict(含 friend_wx_id/real_name/phone/version),分页正确 +- [ ] **Step 3: 跑确认失败** +- [ ] **Step 4: 实现** `base.py`(页码分页 helper、时间游标 helper、限速);`wechat.py` 的 `scrape_wechat_friends`(调 `/wechatweb/wxFriends/friend_list/es`,翻页,字段映射) +- [ ] **Step 5: 跑确认通过** +- [ ] **Step 6:** commit `feat: wechat friend scraper` + +### Task 1.6:微信消息 scraper(TDD + 录制响应) + +**Files:** Modify `app/scraper/wechat.py`; Create `tests/cassettes/msg_chats_*.json`, `tests/test_scrape_wechat_messages.py` + +- [ ] **Step 1:** 存录制响应(含文字 + 一条带媒体占位 + 日期符 `-2`) +- [ ] **Step 2: 写失败测试**:断言 `scrape_wechat_messages(client, wechat_id, friend_id, start, end)` 返回消息,`type=-2` 被过滤,字段映射(msgSvrId→msg_id、type 经 parsers 归一化、roomid 保留) +- [ ] **Step 3: 跑确认失败** +- [ ] **Step 4: 实现** `parsers.py`(`normalize_wechat_type(raw)->Enum`);`wechat.py` 的消息抓取(`/wechatweb/msg/chats`,时间游标 `end`/`hasLast` 向过去翻页) +- [ ] **Step 5: 跑确认通过** +- [ ] **Step 6:** commit `feat: wechat message scraper` + +### Task 1.7:存储层——幂等 upsert(TDD) + +**Files:** Create `app/storage/repos.py`, `tests/test_repos.py` + +- [ ] **Step 1: 写失败测试**:插入同一 customer 两次(不同 version),断言只 1 行且 version 更新;插入重复 media(同 hash) 两次,断言 1 行 +- [ ] **Step 2: 跑确认失败** +- [ ] **Step 3: 实现** `repos.py`:`upsert_customer`、`upsert_messages`(按主键 `ON DUPLICATE KEY`/merge)、`ObjectStore.put_by_hash`(本地磁盘按 hash 存,exists 判重);实现**消息分表路由器** `message_table(channel, timestamp)` 选表(`message__YYYYMM`),并预留跨分表查询 helper(按时间/会话扇出多表合并) +- [ ] **Step 4: 跑确认通过** +- [ ] **Step 5:** commit `feat: idempotent upsert repos + object store` + +### Task 1.8:FastAPI 最小 API(TDD) + +**Files:** Create `app/api/main.py`, `app/api/routers/customer.py`, `app/api/schemas.py`, `tests/test_api_customer.py` + +- [ ] **Step 1: 写失败测试**:`GET /api/customers?channel=wechat&page=1` 返回分页列表(seed 测试数据) +- [ ] **Step 2: 跑确认失败** +- [ ] **Step 3: 实现** FastAPI app、customer router(分页、筛选 channel/部门)、schemas +- [ ] **Step 4: 跑确认通过**;`uvicorn app.api.main:app` 手测 +- [ ] **Step 5:** commit `feat: customer list API` + +### Task 1.9:端到端冒烟脚本 + +**Files:** Create `scripts/smoke_wechat.py` + +- [ ] **Step 1:** 写脚本:登录 → 抓 1 个微信号的好友(限 1 页) + 该号 1 个会话最近消息 → upsert → 打印统计。用真实账号小范围跑(只读) +- [ ] **Step 2:** 跑通,确认数据落库 +- [ ] **Step 3:** commit `chore: wechat end-to-end smoke`;**Milestone 1 完成可演示** + +--- + +## Milestone 2:微信全量补全 + +每个实体同 M1 模式(录制响应→TDD→实现→commit): + +- [ ] **Task 2.0 部门树**:`scrape_departments`(`POST /usercenter/dept/tree`)→ upsert Department(含 `parent_id`/`path` 层级)。这是组织树的唯一来源,必须最先抓(Task 5.2 依赖它) +- [ ] **Task 2.1 微信员工+账号**:`scrape_wechat_accounts`(`wxUser/list`),顺带 upsert Staff。**Staff 的 dept/phone 来源**:`wxUser/list` 仅给 `userId`/`userName`,无 dept;dept 关系从该账号下客户的 `deptRoute`/`userId` 反推(无客户账号的员工暂无 dept)。若需员工完整字段(手机/角色/离职)→ Phase 0 确认 `/pc/crm_t_portaluser/...` 后补一个 staff 详情 scraper +- [ ] **Task 2.2 微信群+成员**:`scrape_wechat_groups`(`chatroom/getChatroomList`),解析 `memberIds` → 批量 upsert ChatGroup + GroupMember +- [ ] **Task 2.3 群消息**:扩展消息 scraper 支持 `friendId=chatRoomId`(群聊),`talker` 为群内成员 +- [ ] **Task 2.4 微信语音通话**:`scrape_wechat_voice_calls`(`callRecord/queryWeChatVoiceList`,按 Phase 0 字段) +- [ ] **Task 2.5 会话列表**:`scrape_wechat_talkers`(`talkerList`)用于发现哪些会话有消息 +- [ ] **Task 2.6 媒体下载(微信)**:`app/scraper/media.py` —— 抓到带 `file` 的消息即下载存对象存储(按 md5 去重);失败的入重试队列不阻塞。TDD:mock OSS 直链下载 + 去重 +- [ ] **Task 2.7 水位增量**:`sync/watermarks.py` + `sync/orchestrator.py`。**消息**按时间水位(每会话 `last_timestamp`,查 `[水位, now]`);**非消息实体**(客户/群/员工/电话/短信)按 `version` 水位推进(查 `version > 水位`)。推进水位、断点续抓。TDD:模拟两轮增量,断言只取新增 +- [ ] **Task 2.8 媒体读取 API**:`api/routers/media.py` —— 从对象存储流式返回媒体(Range 请求、Content-Type、inline/attachment)。与 JSON 列表模式不同,需独立实现。TDD:seed 一个 media 记录 + 本地文件,`GET /api/media/{hash}` 断言 200 + Content-Type + Range 支持 +- [ ] **Task 2.9 后端鉴权**:加一个简单共享 token 中间件(与 Task 5.10 前端配对);本期定位本地/内网工具,可默认信任网络边界,但显式写明信任模型,避免实现者猜测 + +--- + +## Milestone 3:WhatsApp 渠道 + +(与微信同模式;复用 base/storage/sync/api,加 `channel='whatsapp'`) + +- [ ] **Task 3.1 WhatsApp 登录**:复用 YunkeClient(同会话),确认 `/qqweb/WhatsAppUser/` 也用同样鉴权头 +- [ ] **Task 3.2 员工+账号**:`businessSalesList`(42) +- [ ] **Task 3.3 客户**:`businessFriendList`(按 Phase 0 字段) +- [ ] **Task 3.4 消息**:`chatRecords`(keyId 主键、时间游标、`type` 归一化) +- [ ] **Task 3.5 媒体下载(WhatsApp)**:按 Phase 0 找到的 `mediaName` 下载接口实现专属 downloader +- [ ] **Task 3.6 语音通话**:`wavoicecalllist`(Phase 0) +- [ ] **Task 3.7**:扩展 customer/message API 支持跨渠道 `channel` 过滤与聚合 + +--- + +## Milestone 4:电话 + 短信 + +- [ ] **Task 4.1 电话记录 scraper**:`/pc/callLog/list`(6047,含 `recordFile` OSS 录音 URL),归一化到 PhoneCall +- [ ] **Task 4.2 录音下载**:复用对象存储,下载 `recordFile` mp3(直链 OSS),按内容哈希去重 +- [ ] **Task 4.3 短信 scraper**:`/pc/customer/smsLogsList`(53),归一化到 Sms +- [ ] **Task 4.4 现场录音**:Phase 0 确认后补 +- [ ] **Task 4.5**:call/sms API + 时间范围增量同步 + +--- + +## Milestone 5:前端查看后台(yunkes) + +**Files:** `yunkes/`(Vite 初始化 + Element Plus + Pinia + Router + axios) + +- [ ] **Task 5.1 脚手架**:`npm create vite@latest yunkes -- --template vue-ts`;装 element-plus/pinia/vue-router/axios;配 axios 指向后端 +- [ ] **Task 5.2 布局**:左侧组织树(部门→人员→账号)+ 顶栏 + 主区(Element Plus 后台布局) +- [ ] **Task 5.3 客户列表页**:分页表格 + 筛选 → 点昵称进时间轴 +- [ ] **Task 5.4 消息时间轴**:按时间正序渲染文字/语音(播放+转文字)/图片/视频/文件/转账红包;单聊+群聊(群显示发送成员) +- [ ] **Task 5.5 群列表 + 群成员** +- [ ] **Task 5.6 通话/短信页** +- [ ] **Task 5.7 全文搜索**:跨渠道/日期/人员/客户/群(MVP 应用层扇出,见设计 §8) +- [ ] **Task 5.8 媒体查看**:图片预览、语音/视频播放、文件下载 +- [ ] **Task 5.9 同步状态面板**:各实体上次同步时间/进度/错误 +- [ ] **Task 5.10 简单鉴权**:单一账号密码(本地工具) + +--- + +## Milestone 6:调度与稳健化 + +- [ ] **Task 6.1 定时调度**:`sync/scheduler.py`(APScheduler + Redis):每日全实体增量 + 手动触发接口 +- [ ] **Task 6.2 重试与限速**:失败指数退避重试队列;请求限速器(避免风控) +- [ ] **Task 6.3 登录态自动续期**:401 自动重新登录 +- [ ] **Task 6.4 监控日志**:SyncRun 记录每次运行统计与错误 +- [ ] **Task 6.5 数据脱敏/留底校验**:确认 raw_json 全留底 +- [ ] **Task 6.6 部署文档**:docker-compose(MySQL/Redis/app/yunkes)+ README + +--- + +## 测试策略贯穿 + +- 所有 scraper 用 `tests/cassettes/` 录制响应 + respx,**不依赖线上** +- storage/sync 用 SQLite 内存库做单测 +- API 用 FastAPI TestClient +- 每个 Milestone 结束做一次真实账号小范围**只读冒烟** + +## 风险锚点 + +- 媒体外链过期 → 抓到即下载(M2.6/M3.5/M4.2) +- 云客改版 → scraper 集中封装 + raw_json 留底 + 浏览器兜底(base.py 预留 `BrowserFallback`) +- 大规模 → 消息分表 + 增量水位 + 媒体哈希去重 diff --git a/docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md b/docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md new file mode 100644 index 0000000..6829afa --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md @@ -0,0 +1,154 @@ +# 云客 CRM 数据字典(全系统扫描结果) + +- **日期**:2026-08-01 +- **来源**:登录云客 CRM(crm2.yunkecn.com)实地抓包,覆盖全部模块 +- **状态标记**:✅ 已确认字段 | ⏳ 接口已找到,字段待 Phase 0 | 🔭 仅知入口 + +> 本文档是[设计文档](2026-08-01-yunke-data-mirror-design.md)的配套数据字典,列出系统中**所有数据实体**及其接口、字段、规模、抓取优先级。 + +--- + +## 0. 鉴权与接口约定 + +- **鉴权**:自定义请求头 `userid` + `company` + `source:yunkepc` + `channel:4` + 会话 cookie;登录链 `getSecureKey → pcLogin`(账号密码,无验证码,可全自动) +- **接口前缀**: + - `/pc/...` — CRM 通用(客户/电话/短信/统计) + - `/usercenter/...` — 用户/部门 + - `/wechatweb/...` — 微信 + - `/qqweb/WhatsAppUser/...` — WhatsApp +- **通用响应**:`{success, code:"10000", message, data, traceId}` +- **媒体存储**:OSS 桶 `yunke-pcfile.oss-cn-beijing.aliyuncs.com`(已由通话录音确认);头像在 `wx.qlogo.cn` / `mmhead.c2c.wechat.com` / `pc.oss.yunkecn.com` + +--- + +## 1. 组织与人员 + +| 实体 | 接口 | 状态 | 关键字段 | 规模 | +|---|---|---|---|---| +| 部门/分组 department | `POST /usercenter/dept/tree` | ✅ | dept_id, parent_id, name, path | 6 个分组 | +| 员工 staff | 嵌入账号列表(`userId`+`userName`) | ✅ | user_id, name, phone, sale_phone, dept_id, is_on_job | 与账号同源 | +| 员工管理(完整) | `/pc/crm_t_portaluser/...` | 🔭 | 姓名/性别/手机/角色/离职 | 设置→部门管理 | + +--- + +## 2. 微信(prefix `/wechatweb/`) + +| 实体 | 接口 | 状态 | 关键字段 | 规模 | +|---|---|---|---|---| +| 员工微信号+员工 `wxUser/list` | `POST /wechatweb/wxUser/list` | ✅ | wechatId, nickName, alias, phone, userId, userName(员工真名), contactsNum, groupNum, sendNum/receiveNum, isOnJob, dbSize, 在线状态, lastUpdate | **55** | +| 客户/好友 customer | `POST /wechatweb/wxFriends/friend_list/es` | ✅ | friendWxId, wxId(归属), friendWxNickname/Remark, realName, phone/wechatPhone(完整), gender, region, description, saleName, userId, deptRoute/deptName, friendHeadImageUrl/headImg, lastChatTime, sendNum/receiveNum/messageCount, version, deleteFlag/isBlock/friendState | **8152** | +| 会话列表 talker | `POST /wechatweb/wxFriends/talkerList` | ✅ | friendWechatId, nickName, remarkName, messageCount, lastChatTime, headUrl, memberCount, analyseResult | 按员工 | +| **消息** message | `POST /wechatweb/msg/chats` | ✅ | msgSvrId(主键), type/messageType, text, **file/fileTh/fileHd(媒体URL)**, **md5/md5Th/md5Hd**, duration, **transcriptionText(语音转文字)**, imgPath/originalImage/picType, paymsgid, referMsg, talker/oriTalker, wechatId, **roomid(群,空=单聊)**, mine/isSend, timestamp/createTime, version, isDel, name/headUrl | 百万级 | +| ↳ 参数 | — | ✅ | `wechatId`+`friendId`+`types`+`start/end`(ms 时间范围);游标 `end`/`hasLast` | — | +| **群 chatroom** | `POST /wechatweb/chatroom/getChatroomList` | ✅ | chatRoomId(`@chatroom`), chatRoomName, memberCount, **memberIds(分号分隔全部成员wxid)**, headImageUrl, wechatId/wechatNickName(归属), userName/userPhone(群主员工), messageCount/ownMessageCount, lossMembers/addMembers, lastChatTime/updateTime | **1515** | +| 语音通话 voice_call | `POST /wechatweb/callRecord/queryWeChatVoiceList` | ⏳ | 接口已确认,字段 Phase 0(含录音) | 待测 | +| 通话统计 | `POST /wechatweb/callRecord/todayStatistics` | 🔭 | 统计聚合 | — | + +**消息类型码(微信)**:`1`=文字、`-2`=日期分隔符(非真实消息);图片/语音/视频/文件/转账/红包/引用/系统 各有码(Phase 0 枚举)。 + +--- + +## 3. WhatsApp(prefix `/qqweb/WhatsAppUser/`) + +| 实体 | 接口 | 状态 | 关键字段 | 规模 | +|---|---|---|---|---| +| 员工+账号 `businessSalesList` | `POST /qqweb/WhatsAppUser/businessSalesList` | ✅ | userId, userName(员工真名), uid(`手机号@s.whatsapp.net`), nickName, phone/salePhone/salesBindPhone, contactsNum, groupNum, talkerCount, friendAddNum, isOnJob, lastUpdate | **42** | +| 客户/联系人 friend | `POST /qqweb/WhatsAppUser/businessFriendList` | ⏳ | 接口已确认,字段 Phase 0 | 待测 | +| 客户/群信息 | `POST /qqweb/WhatsAppUser/getFriendOrTroopInfo` | ⏳ | 单个客户/群详情 | — | +| **消息** message | `POST /qqweb/WhatsAppUser/chatRecords` | ✅ | keyId(主键), type/msgType, content, **mediaName(媒体文件名)**, nickName, uid(员工), friendUid(客户), isSend, createTime/dateTime, timezone, companyCode, quotedId/quotedMessage | 百万级 | +| ↳ 参数 | — | ✅ | `start/end` 时间游标;`hasNext`/`hasLast` | — | +| 会话统计 | `getBusinessChatOverView` / `getBusinessChatDailyStatistics` / `getBusinessChatDetailList` | 🔭 | 概览/日统计/明细 | — | +| 语音通话 voice_call | 页面 `waBusiness/wavoicecalllist` | 🔭 | Phase 0 | — | + +**消息类型码(WhatsApp)**:`0`=文字、`1`=图片/媒体、`-2`=日期符。 +**ID 格式**:`手机号@s.whatsapp.net`。 +**媒体差异**:仅给 `mediaName`(文件名),需专门下载接口(Phase 0)。 + +--- + +## 4. 电话与短信(客户→沟通记录,prefix `/pc/`) + +| 实体 | 接口 | 状态 | 关键字段 | 规模 | +|---|---|---|---|---| +| **电话记录** phone_call | `POST /pc/callLog/list` | ✅ | id, startCallTime, callDuration/callSeconds, ringTime/ringSecond, callStatus(0未接/2接通), incomingCall(0呼出/1呼入), userId/userIdName(员工), departmentId, callNumber, simPhone, simNumber, imei, **recordFile(录音OSS URL)**, isUpload, downloadCount, aiAsr, qcStatus, version, planCustomerName | **6047** | +| 录音下载 | `/pc/customer/downloadMp3/1`、`/downloadMp3/2` | ✅ | MP3 下载(客户/通话两类) | — | +| 通话导出 | `/pc/callLog/exportXls` | 🔭 | Excel 导出 | — | +| **短信记录** sms | `POST /pc/customer/smsLogsList` | ✅ | id, customerPhone, **sms(全文)**/smsSub(预览), sender, direction(0收/1发), userId, senderDepartment/senderDepartmentId, receiveName, createTime, simPhone/simNumber, source | **53** | +| 现场录音 sales_recording | `/cms/customer/salesRecording` | 🔭 | 工牌/手机设备录音 | 待测 | + +**录音 URL 形态**(关键,媒体下载用): +`https://yunke-pcfile.oss-cn-beijing.aliyuncs.com/callFile/{companyCode}/{userId}/YYYY/MMDD/{callNumber}_{timestamp}.mp3` + +--- + +## 5. CRM 客户模块(prefix `/pc/newcustomer/`,独立于微信好友) + +| 实体 | 接口 | 状态 | 备注 | +|---|---|---|---| +| CRM 客户 | `POST /pc/newcustomer/customers` | ✅(空) | **本账号 total=0**——该公司未用 CRM 客户,客户全在微信/WhatsApp 好友里 | +| 客户分组/公海/统计/导出 | `/pc/newcustomer/...` | 🔭 | 接口存在,数据为空 | + +--- + +## 6. 其他模块数据(来自全系统权限地图 `GET /pc/authority/permissions`) + +> 权限接口一次返回所有模块/子页/操作 URL,已据此盘点。下列多为**统计/管理/配置**类,非沟通主数据;标注是否建议抓取。 + +### 6.1 微信风控 / 设置(沟通模块下) +- 微信财务统计、敏感操作统计、敏感词统计/设置、微信登录日志、**好友操作日志**(`/cms/wechat/friendDeletionLog`,含导出)、重复好友、微信验证消息、微信号解绑、聊天标签设置 +- **建议抓**:好友操作日志(好友增删变更,便于追溯) + +### 6.2 首页统计 +- 数据统计/统计图表/通话/微信/短信/工作量统计/手机统计/打卡统计/自定义报表(均带导出) +- 多为聚合统计,可后期按需抓 + +### 6.3 AI 分析(`/pc/ai/...`) +- AI 客户洞察(客户分析/异议/问题/痛点/情绪/沟通关键词)、AI 员工赋能(优秀话术/竞品/战败)、AI 智能质检(敏感词质检/员工情绪/飞单预警/沟通质检) +- **派生数据**(由聊天/通话经 AI 生成),可后期抓;消息里也有 `analyseResult`/`transcriptionText` 字段 + +### 6.4 微小助 / 营销(`/pc/wxTask/...`、`/cms/wechatAssistantModules/...`) +- 微信营销任务(发圈/群发好友/群发群/批量加好友/视频号/客户唤醒)、企微任务、抖音/小红书营销、辅助营销(文章库/营销素材/快捷回复/话术库/微信活码)、拓客 +- 多为**任务/素材配置**,非客户沟通数据;按需 + +### 6.5 设备(`/cms/device/...`) +- 设备统计、**手机截屏**(`/cms/device/phonescreenshot`)、**手机相册**(`/cms/settings/device/albumList`)、插拔卡统计、设备定位/地理围栏、设备管控 +- **含媒体数据**(截屏/相册图片),如需要可抓;默认不在沟通范围 + +### 6.6 设置(`/pc/crm_t_portaluser/...`、`/cms/settings/...`) +- 部门管理/已离职用户/角色权限、自定义客户字段/标签、业务属性、系统设置(操作日志等)、聊天工作台 +- 配置类,按需 + +--- + +## 7. 规模总览(实测) + +| 数据 | 规模 | +|---|---| +| 微信员工账号 | 55 | +| WhatsApp 员工账号 | 42 | +| 微信客户/好友 | 8152 | +| 微信群 | 1515(含成员列表) | +| 电话记录 | 6047 | +| 短信记录 | 53 | +| 消息量 | 日均 ~2.7 万条(长期百万级 + 多媒体 TB 级) | +| CRM 客户 | 0(未使用) | + +--- + +## 8. 抓取优先级建议 + +- **P0 核心(必抓)**:员工/账号(微信+WA)、客户/好友(微信+WA)、群+群成员、消息(单聊+群聊)、电话记录+录音、短信、多媒体(图片/语音/视频/文件/语音通话) +- **P1 建议**:语音通话(微信+WA)、WhatsApp 联系人明细、好友操作日志、现场录音、会话统计 +- **P2 后期/按需**:AI 分析结果、营销素材/话术、设备截屏/相册、各类统计报表、CRM 客户(空) + +--- + +## 9. Phase 0 待确认(实现期首批,浏览器抓包) + +1. **聊天媒体下载**:微信 `file/fileTh/fileHd` 的 URL 形态与鉴权;WhatsApp `mediaName` 的下载接口(OSS 桶已知为 `yunke-pcfile`) +2. **媒体类型码**:微信/WhatsApp 各渠道 image/voice/video/file/transfer/redpacket/system 的 `type` 枚举 +3. **语音通话字段**:`callRecord/queryWeChatVoiceList`、WhatsApp `wavoicecalllist` 的响应结构 + 录音下载 +4. **WhatsApp 联系人**:`businessFriendList` 完整字段 +5. **登录自动化**:`getSecureKey`/`pcLogin` 请求响应(密码加密方式) +6. **群成员昵称**:`memberIds` 给的是 wxid,群内显示名/头像是否需另调接口 +7. **限速/反爬阈值**:高频请求风控观察 diff --git a/docs/superpowers/specs/2026-08-01-yunke-data-mirror-design.md b/docs/superpowers/specs/2026-08-01-yunke-data-mirror-design.md new file mode 100644 index 0000000..6039e36 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-yunke-data-mirror-design.md @@ -0,0 +1,326 @@ +# 云客数据本地化系统(yunke)设计文档 + +- **日期**:2026-08-01 +- **状态**:草案(待评审 + 用户确认) +- **数据源**:云客 CRM(`https://crm2.yunkecn.com`),网页后台,无官方开放 API +- **仓库结构**:`yunke-server/`(后端:抓取 + 存储 + 调度 + API)、`yunkes/`(前端:查看后台) +- **配套数据字典**:[2026-08-01-yunke-data-dictionary.md](2026-08-01-yunke-data-dictionary.md)(全系统所有数据实体/接口/字段/规模的完整盘点) + +--- + +## 1. 目标与范围 + +### 1.1 目标 +把云客 CRM 后台的数据**持续抓取到本地**,提供**自有查看后台**随时查看,支持**增量同步**持续更新,后期可做**数据分析与报表**。 + +### 1.2 在范围内的数据 +- **组织**:部门/公司分组树、人员(员工) +- **账号**:微信号(员工挂载)、WhatsApp 账号 +- **客户**:微信好友 / WhatsApp 联系人(含完整字段) +- **聊天记录**: + - 渠道:**微信**、**WhatsApp**(外加短信、电话通话) + - 形态:**单聊** + **群聊** + - 消息类型:文字、语音、语音通话、图片、视频、文件、转账、红包、引用回复、系统消息等 +- **多媒体**:以上所有语音/图片/视频/文件**真实文件全部下载到本地** +- **通话**:电话通话、语音通话(含录音) + +### 1.3 渠道清单(已确认无其他) +微信、WhatsApp、短信、电话通话。 + +### 1.4 不在范围(本期) +- 双向同步/回写云客(**全程只读**,绝不写云客) +- 自动化营销/AI 应答(仅做数据沉淀与查看) +- 云客"设备/营销/AI分析"等非沟通类模块的数据(后期可选) + +--- + +## 2. 侦察结论(基于真实接口抓取) + +> 以下结论均通过浏览器实地登录云客后台、抓取真实网络请求得出,非推测。 + +### 2.1 鉴权机制 +- 登录链路:`GET /newuc/login/getSecureKey` → `POST /usercenter/login/pcLogin`(账号密码登录,无验证码/短信/滑块,**可全自动**) +- 已登录后,数据接口通过**自定义请求头** + 会话 cookie 鉴权: + - `userid: <用户ID哈希>`(如 `BED5AE9E562C44C0AA920E9C8D139262`) + - `company: <公司码>`(如 `8m0atd`) + - `source: yunkepc` + - `channel: 4` + - `content-type: application/json` +- **结论**:接口极易重放——复用登录态并带上上述头即可。**方案 C(混合)的"快通道"完全可行**,无需全程开浏览器。 + +### 2.2 接口前缀(按模块) +| 模块 | 前缀 | 备注 | +|---|---|---| +| 通用(首页/统计/权限) | `/pc/...` | POST JSON | +| 用户/部门 | `/usercenter/...` | 部门树 `/usercenter/dept/tree` | +| 微信 | `/wechatweb/...` | 后端 Elasticsearch | +| WhatsApp | `/qqweb/WhatsAppUser/...` | 注意前缀与微信不同 | + +### 2.3 已确认的关键接口与结构 + +#### 微信 +| 用途 | 接口 | 关键参数/字段 | +|---|---|---| +| 客户/好友列表 | `POST /wechatweb/wxFriends/friend_list/es` | 返回 `total`、分页 `data[]`(实测 total=8152) | +| 会话列表 | `POST /wechatweb/wxFriends/talkerList` | 按员工微信拉会话,含 `messageCount`/`lastChatTime` | +| 消息列表 | `POST /wechatweb/msg/chats` | `wechatId`+`friendId`+`types`+`start/end`(毫秒时间范围);游标翻页 `end`/`hasLast` | +| **员工微信列表** | `POST /wechatweb/wxUser/list` | 实测 total=55;含 `wechatId`/`nickName`/`userId`/`userName`(员工真名)/`contactsNum`/`groupNum`/`isOnJob` | +| 语音通话列表 | `POST /wechatweb/callRecord/queryWeChatVoiceList` | 语音通话记录(字段待 Phase 0) | +| 通话统计 | `POST /wechatweb/callRecord/todayStatistics` | 通话统计 | + +- **客户字段**(已完整确认):`friendWxId`、`wxId`(归属员工微信)、`friendWxNickname`/`friendWxRemark`、`realName`、`phone`/`wechatPhone`(完整)、`gender`、`region`、`description`、`saleName`、`userId`(归属员工)、`deptRoute`/`deptName`、`friendHeadImageUrl`/`headImg`(头像外链)、`lastChatTime`、`sendNum`/`receiveNum`/`messageCount`、`version`(更新戳)、`deleteFlag`/`isBlock`/`friendState` 等 +- **消息字段**(已完整确认): + - `msgSvrId`(**消息唯一 ID,主键**)、`type`/`messageType`(`1`=文字、`-2`=日期分隔符、图片/语音/视频/文件/转账/红包各有码)、`text`、`file`/`fileTh`/`fileHd`(**媒体 URL**)、`md5`/`md5Th`/`md5Hd`(媒体哈希)、`duration`(语音/视频时长)、`transcriptionText`(**语音转文字**)、`imgPath`/`originalImage`/`picType`、`paymsgid`(转账/红包)、`referMsg`(引用回复)、`talker`/`oriTalker`(客户 wxid)、`wechatId`(员工微信)、`roomid`(群 ID,空=单聊)、`mine`/`isSend`(方向)、`timestamp`/`createTime`、`version`、`isDel`、`name`/`headUrl` + +#### WhatsApp +| 用途 | 接口 | 关键参数/字段 | +|---|---|---| +| 客户/群信息 | `POST /qqweb/WhatsAppUser/getFriendOrTroopInfo` | — | +| **员工列表** | `POST /qqweb/WhatsAppUser/businessSalesList` | 实测 total=42;含 `userId`/`userName`(员工真名)/`uid`/`nickName`/`contactsNum`/`groupNum` | +| 好友列表 | `POST /qqweb/WhatsAppUser/businessFriendList` | WhatsApp 联系人列表 | +| 消息列表 | `POST /qqweb/WhatsAppUser/chatRecords` | `start/end` 时间游标;`hasNext`/`hasLast` | +| 会话统计 | `POST /qqweb/WhatsAppUser/getBusinessChatOverView` / `getBusinessChatDailyStatistics` / `getBusinessChatDetailList` | WhatsApp 会话概览/统计 | + +- **消息字段**(已完整确认):`keyId`(**唯一 ID,主键**)、`type`/`msgType`(`0`=文字、`1`=图片/媒体、`-2`=日期符)、`content`、`mediaName`(**媒体文件名**,如 `{uid}_{keyId}.jpg`)、`nickName`、`uid`(员工 `手机号@s.whatsapp.net`)、`friendUid`(客户)、`isSend`、`createTime`/`dateTime`、`timezone`、`companyCode`、`quotedId`/`quotedMessage`(引用回复) + +### 2.4 两渠道关键差异(影响设计) +| 维度 | 微信 | WhatsApp | +|---|---|---| +| 接口前缀 | `/wechatweb/` | `/qqweb/WhatsAppUser/` | +| ID 格式 | `wxid_*` | `手机号@s.whatsapp.net` | +| 消息类型码 | 文字=`1` | 文字=`0` | +| 消息唯一 ID | `msgSvrId` | `keyId` | +| **媒体获取** | 消息内带 `file`/`fileTh`/`fileHd` URL | 仅 `mediaName` 文件名,**需专门下载接口** | + +### 2.5 规模(实测) +- 客户/好友:**8152** 条 +- 微信号(员工挂载):**55** 个 +- 消息量:6 天约 16 万条 → **日均约 2.7 万条** → 长期累积**百万级消息 + TB 级多媒体** +- **结论**:中大规模,必须可扩展(MySQL 分表/索引 + 对象存储去重 + 高效增量) + +### 2.6 多媒体观察 +- 头像/媒体多为**外链**(`wx.qlogo.cn`、`mmhead.c2c.wechat.com`、`yunke-pcfile.oss-cn-beijing.aliyuncs.com`) +- **风险**:外链可能过期/失效 → **必须抓取时趁会话有效立刻下载**,不能只存 URL +- WhatsApp 媒体在聊天视图**不内嵌 URL**(受保护/按需加载)→ 确切下载接口待 Phase 0 + +--- + +## 3. 总体架构 + +``` +┌──────────────────────────────────────────────────────────────┐ +│ yunke-server/ (Python:所有重活,一个代码库两个进程) │ +│ │ +│ ① 会话层 自动登录云客、拿登录态、侦测失效自动续期 │ +│ ② 抓取层 混合引擎:HTTP 快通道(重放) + 浏览器兜底(方案C) │ +│ └─ 按渠道:微信 scraper / WhatsApp scraper / 短信 / 通话 │ +│ └─ 按对象:分组·人员·账号·客户·群·消息·媒体·通话 │ +│ ③ 存储层 MySQL(结构化,带channel) + 对象存储(媒体,按哈希去重) │ +│ ④ 调度层 定时/增量同步、任务队列、断点续抓、重试 │ +│ ⑤ API 层 FastAPI:把存好的数据提供给前端 │ +└──────────────────────────────────────────────────────────────┘ + │ HTTP API + ▼ +┌──────────────────────────────────────────────────────────────┐ +│ yunkes/ (Vue3 + TS:查看后台) │ +│ 组织树下钻 · 客户/群列表 · 消息时间轴(跨渠道) · 全文搜索 · │ +│ 媒体播放/下载 · 通话/短信 · 同步状态 · (后期)统计图表 │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 两条数据流 +- **抓取流**:调度层 → 会话层(拿登录态) → 抓取层(快通道/兜底) → 存储层(MySQL + 对象存储) +- **查看流**:浏览器(yunkes) → API 层(FastAPI) → MySQL + 对象存储 → 页面 + +### 关键设计原则 +1. **各层职责单一、互不耦合**:抓取层只"拿数据",存储层只"存好",API 层只"对外提供"。 +2. **抓取与查看彻底分离**:前端永远只读本地库,绝不直接碰云客。云客挂了/改版,本地数据照常查看。 +3. **渠道维度贯穿**:所有数据带 `channel`,新增渠道只需加 scraper,不动主干。 +4. **幂等写入**:消息按 `msgSvrId`/`keyId`、媒体按内容哈希去重,重复抓取只更新不重复。 +5. **原始 JSON 留底**:每条记录存 `raw_json`,云客字段变了不丢信息,利于后期分析。 +6. **全程只读**:绝不向云客写任何数据。 + +--- + +## 4. 数据模型 + +> 所有业务表带 `channel` 字段。**沟通类**表(customer/message/media/group):`wechat` / `whatsapp` / `sms`。**通话表**(call)的 `channel` 表示通话类型:`phone_call` / `voice_call`(通话不绑定聊天渠道)。每张表另含 `raw_json`(原始留底)、`version`(更新水位)、`fetched_at`(抓取时间)。 + +### 4.1 组织与账号 +- **department(部门/分组)**:`dept_id` · `parent_id` · `name` · `path` · `version` + - 来源 `/usercenter/dept/tree` +- **staff(人员/员工)**:`user_id`(主键) · `name`(真实姓名) · `phone` · `sale_phone` · `dept_id` · `is_on_job` · `version` + - **数据来源**:员工信息嵌在账号列表里(微信 `wxUser/list` 的 `userId`/`userName`、WhatsApp `businessSalesList` 的 `userId`/`userName`),抓账号时顺带 upsert 员工。 +- **wechat_account(员工微信号,共 55)**:`wx_id`(主键) · `owner_staff_id`(userId) · `nickname` · `alias` · `phone` · `head_image_url` · `contacts_num`(好友数) · `group_num`(群数) · `send_num`/`receive_num` · `is_on_job` · `db_size` · 在线状态 · `last_update` · `version` + - 来源 `POST /wechatweb/wxUser/list`(字段已确认) +- **whatsapp_account(员工 WhatsApp 账号,共 42)**:`wa_id`(主键, 手机号@s.whatsapp.net) · `owner_staff_id`(userId) · `nickname` · `phone` · `sale_phone` · `contacts_num` · `group_num` · `talker_count` · `friend_add_num` · `is_on_job` · `version` + - 来源 `POST /qqweb/WhatsAppUser/businessSalesList`(字段已确认) + +### 4.2 客户与群 +- **customer(客户/联系人)**:`channel` · `friend_id`(wxid / @s.whatsapp.net) · `owner_account_id` · `owner_staff_id` · `dept_id` · `nickname` · `remark` · `real_name` · `phone` · `gender` · `region` · `description` · `avatar_url` · `last_chat_time` · `send_num` · `receive_num` · `message_count` · `version` · 删除/拉黑/状态标记 +- **chat_group(群聊)**:`channel` · `room_id` · `name` · `member_count` · `owner_account_id` · `announcement` · `version` +- **group_member(群成员)**:主键 `(channel, room_id, member_id)` · `display_name` · `role` · `join_time` · `version` + +### 4.3 消息(核心) +- **message(聊天消息)**:按渠道/时间**分表**(如 `message_wechat_202608`) + - `msg_id`(归一主键:微信=`msgSvrId`、WhatsApp=`keyId`)· `channel` + - `owner_account_id`(员工微信/WhatsApp)· `talker_id`(客户/群成员)· `room_id`(群,空=单聊) + - `type_raw`(原始类型码)· `type`(**归一化枚举**:text/voice/voice_call/image/video/file/transfer/redpacket/system/quote/divider) + - `direction`(收/发)· `text`(文字/转录)· `media_hash`(关联媒体,可空) + - `duration` · `pay_msg_id` · `refer_msg`(JSON) · `transcription`(语音转文字) + - `timestamp` · `version` · `is_del` · `raw_json` + - 索引:主键 `msg_id`;联合 `(owner_account_id, talker_id, timestamp)`;`(channel, timestamp)` + +### 4.4 媒体 +- **media(多媒体文件)**:`media_hash`(主键,内容哈希去重) · `channel` · `type`(image/voice/video/file) · `source_url`/`source_name` · `local_path` · `size` · `mime` · `duration` · `width`/`height` · `fetched_at` · `status`(ok/failed/pending) + - 文件存对象存储,按哈希命名,同文件只存一份 + +### 4.5 通话与短信 +- **call(通话)**:`channel`(取值 `phone_call`=电话通话 / `voice_call`=语音通话)· `call_id` · `owner_staff_id` · `customer_phone` · `type` · `direction` · `duration` · `start_time` · `result` · `recording_media_hash` · `version` + - **语音通话的存储归属(明确)**:通话的**完整记录**(时长/结果/录音)一律存 `call` 表(来自"通话""语音通话列表"模块)。若某次语音通话也以内联事件出现在聊天流里,`message` 表那条记录用 `type=voice_call` + `call_id` **引用** `call` 表,不重复存元数据。Phase 0 确认语音通话是否出现在消息流后据此实现。 +- **sms(短信)**:`sms_id` · `owner_staff_id` · `customer_phone` · `direction` · `content` · `timestamp` · `version` + +### 4.6 系统表 +- **sync_state(同步水位)**:`entity_type` · `channel` · `scope`(部门/账号) · `last_version`/`last_timestamp` · `last_synced_at` · `status` · `last_error` +- **yk_account(云客登录账号)**:`username` · `password_encrypted`(加密存储) · `userid` · `company` · `session_token` · `session_expires` + - 密码用对称加密(如 Fernet/AES-GCM)加密入库,密钥放在**环境变量 / 配置**中,不入库、不进代码仓库。 +- **sync_run(同步运行日志)**:`run_id` · `entity_type` · `scope` · `started_at` · `finished_at` · `fetched` · `failed` · `status` · `error` + +--- + +## 5. 存储策略 + +- **MySQL(结构化)**:所有业务表。消息表按 `渠道 + 年月` 分表(百万级不能单表)。关键字段建索引(见 4.3)。字符集 utf8mb4(俄语等多语言 + emoji)。 +- **对象存储(二进制媒体)**: + - 默认**本地磁盘目录**(按 `channel/type/哈希前两位/哈希` 组织) + - 封装 `ObjectStore` 接口(put/get/exists),后期可无缝换 MinIO/S3/OSS + - 按**内容哈希**去重,同图/同语音只存一份 +- **Redis**:任务队列 + 会话缓存 + 限速计数 + 去重布隆(可选) + +--- + +## 6. 抓取层(方案 C:混合) + +### 6.1 会话层(SessionManager) +- 自动登录(账号密码 → `getSecureKey` → `pcLogin`),拿到 cookie + `userid`/`company` 等头 +- 持久化会话,**侦测 401/失效自动重新登录续期** +- 对外提供"带鉴权的 HTTP 客户端",所有 scraper 复用 + +### 6.2 抓取引擎(混合) +- **默认快通道**:用会话层的 HTTP 客户端**直接重放**云客接口(POST + 鉴权头),解析 JSON。快、可并发。 +- **浏览器兜底**:遇到动态签名/反爬/无法重放的接口,回退 Playwright 驱动真实浏览器取数。 +- 每个**渠道**一个 scraper(`wechat_scraper` / `whatsapp_scraper` / `sms_scraper` / `call_scraper`),实现统一的 `fetch_X` 接口。 + +### 6.3 分页与全量首扫 +- 消息接口是**时间游标向前翻页**(`end`/`hasLast` 或 `start`/`hasNext`) +- **全量首扫**:从当前向过去翻页,直到 `hasLast=false` 翻完历史 +- 列表接口是**页码分页**(`pageNum`/`pageSize`),逐页拉满 + +### 6.4 媒体下载(每渠道独立) +- **微信**:消息含 `file`/`fileTh`/`fileHd` URL → 用会话下载,按 `md5` 去重存储 +- **WhatsApp**:消息含 `mediaName` → 调专门下载接口(Phase 0 确认)→ 按哈希去重 +- **抓到即下载**(外链会过期),下载失败入重试队列,不阻塞消息抓取 +- 语音转文字(`transcriptionText`)一并入库,便于搜索 + +--- + +## 7. 增量同步策略 + +1. 每个 `(entity, channel, scope)` 在 `sync_state` 维护一个**时间/版本水位**。 +2. 同步时查询 `[水位, 现在]` 的时间段(消息)或 `version > 水位`(客户/群),按主键幂等 upsert。 +3. 推进水位到本批最大 `timestamp`/`version`。 +4. **调度**: + - 每日定时:全实体增量 + - 手动触发:单实体/单账号/单会话按需刷新 + - 失败:指数退避重试,断点续抓(水位持久化,挂了重跑不重复) +5. **限速**:控制请求频率,避免触发云客风控/封号。 + +--- + +## 8. 查看后台(yunkes) + +- **左侧组织树**:公司/分组 → 人员 → 微信/WhatsApp 账号(与云客一致下钻) +- **客户列表**:搜索/筛选/分页 → 点客户进**聊天时间轴**(单聊) +- **群聊列表** → 点群进**群聊时间轴 + 群成员** +- **消息时间轴**:按时间正序;按类型渲染(文字/语音播放+转文字/图片/视频/文件/转账红包);**跨渠道**聚合同一客户 +- **全文搜索**:关键词 / 日期 / 人员 / 客户 / 群 + - **跨分表搜索策略**:消息按 `渠道+年月` 分表,搜索需跨分表。MVP 用应用层**扇出查询**(对各分表发查询 + 合并排序,利用 `(owner_account_id, talker_id, timestamp)` 索引收敛);规模增大后引入 **Elasticsearch** 建搜索索引(云客后端本身用 ES,思路一致)。实现期在计划阶段定稿。 +- **鉴权**:本期定位为**本地/内网工具**,采用简单鉴权(单一账号 + 密码 / 内网可信即可),不做复杂多租户权限。FastAPI 暴露按实体的查询/搜索/媒体读取接口(列表/详情/时间轴/搜索/媒体流)。 +- **通话/语音通话/短信**:各自列表 + 详情 +- **多媒体查看**:在线播放语音、看图/视频、下载文件 +- **同步状态面板**:各实体上次同步时间、进度、错误 +- **(后期)数据统计**:消息量、活跃度、趋势图表 + +--- + +## 9. 错误处理与稳健性 + +- 抓取**幂等**(主键/哈希去重)、**断点续抓**(水位持久化) +- 失败**重试队列**(指数退避),媒体下载失败不阻塞消息 +- 登录态**自动侦测 + 续期** +- **限速**防封;异常监控与告警 +- 每条**原始 JSON 留底**,字段变更不丢信息 +- **全程只读**,绝不写云客 + +--- + +## 10. 测试策略 + +- **抓取层**:用录制的真实接口响应做**固定数据测试**(不依赖线上) +- **存储层**:幂等/去重/分表单元测试 +- **同步层**:水位推进、增量正确性、断点续抓测试 +- **API 层**:接口契约测试 +- **端到端**:小范围真实账号冒烟测试(只读) + +--- + +## 11. 技术栈 + +| 层 | 选型 | 理由 | +|---|---|---| +| 抓取 + 后端 | Python(httpx + Playwright + FastAPI) | 抓取生态最强;抓取与服务同语言 | +| 元数据库 | MySQL | 用户指定;可扩展(分表/索引) | +| 多媒体存储 | 本地磁盘 / 对象存储(MinIO 兼容) | 二进制不入库;按哈希去重 | +| 缓存/队列 | Redis + 任务队列(RQ/Dramatiq) | 可靠抓取任务 + 增量调度 | +| 前端 | Vue 3 + TypeScript | 查看后台 | + +--- + +## 12. Phase 0 侦察任务(实现期首批,用浏览器抓包完成) + +> 架构不依赖以下项;它们是 scraper 实现前的"接口确认"清单。用本次验证过的浏览器抓包方法逐一确认。 + +1. **媒体下载机制(最高优先)** + - ✅ OSS 桶已确认:`yunke-pcfile.oss-cn-beijing.aliyuncs.com`(由通话录音 `recordFile` 得知,路径 `/callFile/{company}/{userId}/YYYY/MMDD/{号}_{ts}.mp3`) + - 微信:抓一条含 `file` 非空的消息(图片/语音/文件),确认 `file`/`fileTh`/`fileHd` 是否也是该 OSS 直链 + - WhatsApp:`mediaName` → 文件 的下载接口(确认是否同 OSS 桶) +2. **精确消息类型码**:枚举微信/WhatsApp 各渠道下 image/voice/video/file/transfer/redpacket/system 的 `type` 码 +3. ~~**群聊**~~ ✅ 已确认:`/wechatweb/chatroom/getChatroomList`(1515 群),**成员 wxid 列表嵌在 `memberIds` 字段**(分号分隔);群消息用 `msg/chats` 传 `chatRoomId`;待确认:成员群昵称/头像是否需另调接口 +4. **语音通话**:接口已确认 `/wechatweb/callRecord/queryWeChatVoiceList`;待确认其字段与录音下载方式 +5. **电话通话 / 短信**:`通话` / `短信` 模块接口与字段 +6. **登录自动化**:`getSecureKey`/`pcLogin` 的请求/响应(密码是否 RSA 加密、所需参数),确认全自动登录可行 +7. ~~**人员/账号列表**~~ ✅ 已确认:微信 `wxUser/list`(55)、WhatsApp `businessSalesList`(42),员工信息(`userId`/`userName`)嵌在账号列表里 +8. **WhatsApp 客户列表**:`businessFriendList`(接口已确认)/ `getFriendOrTroopInfo` 的完整响应字段 +9. **限速/反爬阈值**:观察高频请求是否触发风控,确定安全速率 + +--- + +## 13. 风险 + +| 风险 | 应对 | +|---|---| +| 云客改版导致接口失效 | 原始 JSON 留底;scraper 集中封装,便于改;浏览器兜底 | +| 媒体外链过期 | 抓到即下载,不只存 URL | +| 登录态过期 | 会话层自动侦测 + 续期 | +| 触发风控/封号 | 限速 + 模拟正常节奏;只读不写降低风险 | +| 大规模性能 | 消息分表 + 对象存储去重 + 增量同步 | +| 多渠道字段差异 | 归一化枚举 + raw_json 留底 | + +--- + +## 14. 后期扩展(本期不做) + +- 数据分析与 BI 报表(消息量、活跃度、客户画像、销售漏斗) +- 跨渠道客户统一视图(同人微信+WhatsApp 合并) +- AI 分析(云客已有 `analyseResult` 字段,可对接) +- 增量实时化(Webhook/轮询频率提升)