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:
浅念
2026-08-07 14:32:23 +08:00
parent 81f5a31864
commit fd33e82d62
@@ -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` 列片段、筛选/播放可用、无匹配按钮。