Files
yunke/docs/superpowers/specs/2026-08-07-yunke-voice-clip-list-design.md
T
浅念 334adcb712 docs(spec): 4c 设计修订——补 user_real_name API 参数 + 员工姓名筛选语义/播放/scaper 文件定稿
spec review 反馈:API↔modal 员工姓名筛选对齐 + 打开语义澄清 +
channel 移出 body + scraper 文件/播放方式 pin。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 14:36:50 +08:00

147 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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}`(**不传时间窗** = 该员工全量片段),分页至 `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 从 CallList 行打开时传,对应云客按员工开)、`user_real_name?`(员工姓名**模糊**,对应云客 modal 的「员工姓名」筛选)、`start?`/`end?`(ms-epoch,录音结束时间窗)、`company_code?`(企业串码)、`page?`/`page_size?`。
- **优先级**:`user_id` 与 `user_real_name` 同时给时,以 `user_id` 为准(更精确);只给名字则按名字模糊。
- 返回 `Page<VoiceClipOut>`(`items/total/page/page_size`)。
- 前端 `getVoiceClips({user_id?, user_real_name?, start?, end?, company_code?, page, page_size})`。
## 6. 前端
**`VoiceClipModal.vue`**(新组件,只读):
- props:`userId`(从 CallList 行传入,精确限定员工)、`staffName?`(该员工姓名,用于筛选框预填)、`visible` v-model。
- 顶部筛选(对齐云客 modal 三筛):录音结束时间范围(el-date-picker → `start/end`) / 员工姓名(`user_real_name` 模糊,**打开时预填 `staffName`,可编辑**) / 企业串码(`company_code`)。
- 查询规则:员工姓名未改 → 传 `user_id`(精确);用户改了员工姓名 → 改传 `user_real_name`(模糊,可跨员工搜,镜像云客)。
- 表格列(对齐云客 modal):员工姓名 / 企业串码 / 文件地址(截断 + tooltip 全 URL)/ 录音开始时间 / 录音结束时间 / 录音时长(duration_format)/ 操作(播放)。
- 播放:**modal 内内联 `<audio controls src=oss_file_name>`**(只读最简,不另开 dialog;src 用 OSS URL,见 §7.3)。
- 底部:关闭(**无「确定」/「匹配」**)。
- 数据:`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`(独立文件,照 `whatsapp.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` 列片段、筛选/播放可用、无匹配按钮。