Files
yunke/docs/superpowers/specs/2026-08-01-yunke-data-mirror-design.md
T

327 lines
22 KiB
Markdown
Raw Normal View History

# 云客数据本地化系统(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/轮询频率提升)