docs(spec): Plan 4c 设计——长语音列表(voice_clip)本地化 + 只读展示
数据来源真机确认(POST /wechatweb/call/list 请求/响应);voice_clip 表 + scraper/sync + /api/voice-clips + 只读 VoiceClipModal + CallList 接入。 匹配/转写/统计卡片等留 4d+。 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
# Plan 4c 设计:长语音列表(voice_clip)本地化 + 只读展示
|
||||
|
||||
> 日期:2026-08-07|分支:`feat/yunke-data-mirror`|前置:Plan 4a(WA/微信语音抓取,voice_call/wa_voice_call 进库)+ 4b(CallList el-tabs + 云客列对齐)
|
||||
> 数据来源:**全部真机确认**(Playwright 登录 crm2.yunkecn.com 抓 `/wechatweb/call/list` 真实请求/响应,非推测)—— 见 §2。
|
||||
|
||||
## 1. 目标与范围
|
||||
|
||||
**目标**:把云客「微信长语音列表」(微信 voice-call-list 通话录音「编辑」、WA wavoicecalllist「手动匹配」共用的同一个 modal)的数据**抓到本地 `voice_clip` 表**,并在 yunkes 后台提供一个**只读展示 modal**(筛选 + 表格 + 播放),接入 CallList 两个语音 tab。运行时后台/前端**不访问云客**——数据提前 sync 入库(遵循 [[yunke-verify-data-in-browser]] / 全本地原则)。
|
||||
|
||||
**在本 plan 范围(4c)**:
|
||||
- 后端:抓 `/wechatweb/call/list` → 新 `voice_clip` 模型/表 + Alembic 迁移 + `scrape_voice_clips` + `sync_voice_clips` + scheduler `ENTITY_FUNCS['voice_clips']`。
|
||||
- API:`GET /api/voice-clips`(带 modal 筛选)。
|
||||
- 前端:`VoiceClipModal` 只读组件(筛选/表格/播放/关闭)+ CallList 接入(微信语音「通话录音」列 + WA 语音「操作」列加「查看长语音」入口 → 按 `user_id` 打开 modal)。
|
||||
|
||||
**不在本 plan 范围(留 4d+)**:
|
||||
- **匹配(手动匹配/录音编辑的写操作)**——本期 modal 只读,不关联 voice_clip↔phone_call、不回写。
|
||||
- **语音转文字(ASR)**——需要音频本地化 + ASR 引擎,整块延后;届时一并把录音/长语音音频下载到本地(当前播放仍用 OSS URL,见 §7 决策)。
|
||||
- 统计卡片(todayStatistics)、录音异常检查(`/call/check/{id}`)、批量语音下载/全部导出/高级筛选工具栏、微信好友/群二级 tab(isRoom client 筛)、列内可点元素(客户/员工昵称→聊天/详情)。
|
||||
|
||||
## 2. 数据来源(真机确认)
|
||||
|
||||
云客「微信长语音列表」modal 的数据接口:
|
||||
|
||||
**`POST https://crm2.yunkecn.com/wechatweb/call/list`**(微信 + WA 共享同一端点)
|
||||
|
||||
**请求体**(已实测两通道):
|
||||
```json
|
||||
{ "pageNum": 1, "pageSize": 10, "userId": "<员工userId>", "startTime": "1785945600000?", "endTime": "1786204799999?" }
|
||||
```
|
||||
- `userId`:**员工 ID,通用**(微信员工 userId=`832EC...`、WA 员工 userId=`F33DA...` 实测均可),是必需的员工维度过滤——modal 按当前通话所属员工打开。
|
||||
- `startTime` / `endTime`:**可选**,ms-epoch 字符串(modal 的「录音结束时间」筛选)。**不传 = 该员工全部长语音片段**(WA 实测不传时间窗返回 total=8)。sync 全量抓取时**不传时间窗**。
|
||||
|
||||
**响应体**(实测,已脱敏路径):
|
||||
```json
|
||||
{ "message":"调用成功", "code":"10000",
|
||||
"data": { "pageNum":1, "pageSize":10, "total":8,
|
||||
"data":[
|
||||
{ "id":66734028, "userId":"F33DA...", "userRealName":"聂玉婷", "wxId":null,
|
||||
"talker":null, "status":null, "isSend":null, "companyCode":"8m0atd",
|
||||
"startTime":"1786072733378", "endTime":"1786072926741",
|
||||
"ossFileName":"https://yunke-pcfile.oss-cn-beijing.aliyuncs.com/wechat-call/8m0atd/.../1786072733378_1786072926741.mp3",
|
||||
"mp3Duration":193, "durationFormat":"00:03:13" }
|
||||
]},
|
||||
"traceId":"...", "success":true }
|
||||
```
|
||||
- 分页 envelope `{pageNum, pageSize, total, data[]}` 与现有 `queryWeChatVoiceList` 等一致。
|
||||
- 每条字段:`id`(PK, bigint) / `userId` / `userRealName`(员工姓名) / `wxId`(WA=null) / `talker`(WA=null) / `status` / `isSend` / `companyCode`(企业串码) / `startTime`(ms-epoch 串) / `endTime`(ms-epoch 串) / `ossFileName`(文件地址/录音 URL) / `mp3Duration`(秒) / `durationFormat`(HH:MM:SS)。
|
||||
- 与云客 modal 列对应:员工姓名=`userRealName`、企业串码=`companyCode`、文件地址=`ossFileName`、录音开始=`startTime`、录音结束=`endTime`、录音时长=`mp3Duration`/`durationFormat`。
|
||||
|
||||
## 3. 数据模型 `voice_clip`
|
||||
|
||||
新表 `voice_clip`(镜像 `phone_call` 风格:结构列 + `raw_json` 原样):
|
||||
|
||||
| 列 | 类型 | 源字段 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `voice_clip_id` | BigInteger PK | `id` | 云客长语音片段 ID |
|
||||
| `user_id` | String | `userId` | 员工 ID(关联 staff) |
|
||||
| `user_real_name` | String nullable | `userRealName` | 员工姓名 |
|
||||
| `wx_id` | String nullable | `wxId` | WA=null |
|
||||
| `talker` | String nullable | `talker` | |
|
||||
| `status` | String nullable | `status` | |
|
||||
| `is_send` | Boolean nullable | `isSend` | |
|
||||
| `company_code` | String nullable | `companyCode` | 企业串码 |
|
||||
| `start_time` | BigInteger nullable | `startTime` | ms-epoch(normalize 串→int) |
|
||||
| `end_time` | BigInteger nullable | `endTime` | ms-epoch |
|
||||
| `oss_file_name` | String nullable | `ossFileName` | 录音文件 URL |
|
||||
| `mp3_duration` | Integer nullable | `mp3Duration` | 秒 |
|
||||
| `duration_format` | String nullable | `durationFormat` | HH:MM:SS |
|
||||
| `channel` | String | (抓取时按员工通道标记) | `wechat` / `whatsapp` |
|
||||
| `raw_json` | JSON nullable | 整条原样 | 前端 drawer / 未来字段 |
|
||||
| `updated_at` | DateTime | | upsert 时间戳 |
|
||||
|
||||
- PK 用 `id`(bigint,跨员工全局唯一)。
|
||||
- Alembic 迁移建表。
|
||||
- `PhoneCallOut` 风格的 `VoiceClipOut` schema(`from_attributes`)。
|
||||
|
||||
## 4. 抓取 + sync
|
||||
|
||||
- `scrape_voice_clips(client, department_id?)`:遍历本地员工表所有员工 `user_id`(微信 + WA 员工),逐员工 `POST /wechatweb/call/list` body=`{pageNum, pageSize, userId, channel-tag}`,**不传时间窗**(全量),分页至 `total`,按 `id` upsert `voice_clip`,`channel` 按该员工所属通道(wechat/whatsapp)标记。
|
||||
- 复用 4a 的 `_build_voice_body` / 分页 / coerce 模式;client 走现有 `YunkeClient`。
|
||||
- 员工→通道映射:复用 staff 表(`user_id` + channel 信息)。
|
||||
- `sync_voice_clips(engine, ...)`:照 4a `sync_wa_voice_calls` 模式(建表/ensure + 调 scraper + `SyncRun` 记录)。
|
||||
- `app/sync/scheduler.py` `ENTITY_FUNCS['voice_clips'] = sync_voice_clips`,CLI `sync --entity voice_clips` 可跑。
|
||||
- 测试:照 4a(cassette fixture + scraper 单测断言 `voice_clip_id/user_id/oss_file_name` 映射)。
|
||||
|
||||
## 5. API
|
||||
|
||||
`GET /api/voice-clips`(新 router,镜像 `/api/calls`)
|
||||
- query:`user_id?`(员工过滤,对应云客 modal 按员工开)、`start?`/`end?`(ms-epoch,录音结束时间窗)、`company_code?`(企业串码)、`page?`/`page_size?`。
|
||||
- 返回 `Page<VoiceClipOut>`(`items/total/page/page_size`)。
|
||||
- 前端 `getVoiceClips({user_id, start, end, company_code, page, page_size})`。
|
||||
|
||||
## 6. 前端
|
||||
|
||||
**`VoiceClipModal.vue`**(新组件,只读):
|
||||
- props:`userId`(打开时限定员工)、`visible` v-model。
|
||||
- 顶部筛选:录音结束时间范围(el-date-picker) / 员工姓名(user_real_name 模糊) / 企业串码(company_code)。
|
||||
- 表格列(对齐云客 modal):员工姓名 / 企业串码 / 文件地址(截断 + tooltip 全 URL)/ 录音开始时间 / 录音结束时间 / 录音时长(duration_format)/ 操作(播放)。
|
||||
- 播放:复用 CallList 的录音播放 dialog 思路(audio + `oss_file_name`);或 modal 内联 audio。
|
||||
- 底部:关闭(**无「确定」/「匹配」**)。
|
||||
- 数据:`getVoiceClips({user_id: userId, start, end, company_code, page, page_size})`。
|
||||
|
||||
**CallList 接入**(`CallList.vue`):
|
||||
- 微信语音 tab「通话录音」列:加「查看长语音」按钮(对齐云客 `anticon-edit` 位置),`@click.stop="openVoiceClip(row.user_id)"`。
|
||||
- WA 语音 tab「操作」区:加「查看长语音」文字入口(对齐云客「手动匹配」位置),同样按 `row.user_id` 打开。
|
||||
- 新增 `openVoiceClip(userId)` → 设 `voiceClipUserId` + `voiceClipVisible=true` → 渲染 `<VoiceClipModal>`。
|
||||
- 标签统一用中性「查看长语音」(不暗示匹配,因本期只读)。
|
||||
|
||||
## 7. 关键决策(已与用户确认 2026-08-07)
|
||||
|
||||
1. **不做匹配**:modal 只读,不关联 voice_clip↔phone_call、不回写云客/本地。匹配留 4d。
|
||||
2. **不做语音转文字**:ASR 整块延后(4d+),届时音频一并本地化。
|
||||
3. **播放暂用 OSS URL**:`oss_file_name` 是云客 OSS(`yunke-pcfile.oss-cn-beijing.aliyuncs.com`)直链,播放直接用它(与 4b 录音播放一致)。**严格讲播放时浏览器从云客 CDN 拉音频**——这是已知妥协,随「转写 + 音频本地化」一起解决(记为 gap,不在 4c 修)。
|
||||
4. **接入点标签中性**:「查看长语音」(非云客的「编辑/手动匹配」),诚实反映只读。
|
||||
5. **抓取遍历员工 userId**:`/wechatweb/call/list` 按员工 `userId` 过滤(两通道通用),sync 遍历员工表全量抓。
|
||||
|
||||
## 8. 风险 / 待定
|
||||
|
||||
- **音频 OSS URL 播放**触碰「全本地」原则(见 §7.3),已知妥协,随转写修。
|
||||
- 抓取量:员工数 × 人均片段数;全量 sync 耗时取决于员工数(照 4a 量级,可接受)。
|
||||
- 微信/WA 员工→通道映射依赖 staff 表正确性(已有)。
|
||||
|
||||
## 9. 文件清单(预期)
|
||||
|
||||
**后端**:
|
||||
- 新 `app/db/models.py`:`VoiceClip` 模型
|
||||
- 新 `app/api/schemas.py`:`VoiceClipOut`
|
||||
- 新 `app/api/routers/voice_clips.py`:`/api/voice-clips`
|
||||
- 新 `app/scraper/voice_clip.py`(或并入 `call.py`):`scrape_voice_clips`
|
||||
- 新 `app/sync/...`:`sync_voice_clips` + scheduler ENTITY_FUNC
|
||||
- 新 Alembic 迁移
|
||||
- 新测试 + cassette fixture
|
||||
|
||||
**前端**:
|
||||
- 新 `yunkes/src/components/VoiceClipModal.vue`
|
||||
- 改 `yunkes/src/api/index.ts` + `types.ts`:`getVoiceClips` / `VoiceClip` / `VoiceClipQuery`
|
||||
- 改 `yunkes/src/views/CallList.vue`:两个语音 tab 加「查看长语音」入口 + 挂载 modal
|
||||
|
||||
## 10. 验收
|
||||
|
||||
- `vue-tsc -b` exit 0;后端测试全绿(含新 voice_clip scraper/router 单测)。
|
||||
- `sync --entity voice_clips` 实跑进 smoke.db(员工长语音片段入库,total>0)。
|
||||
- `/api/voice-clips?user_id=XXX` 返回数据。
|
||||
- CallList 微信语音/WA 语音 tab 点「查看长语音」→ modal 按 `user_id` 列片段、筛选/播放可用、无匹配按钮。
|
||||
Reference in New Issue
Block a user