# 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`(`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 内内联 `