334adcb712
spec review 反馈:API↔modal 员工姓名筛选对齐 + 打开语义澄清 + channel 移出 body + scraper 文件/播放方式 pin。 Co-Authored-By: Claude <noreply@anthropic.com>
11 KiB
11 KiB
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+ schedulerENTITY_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 共享同一端点)
请求体(已实测两通道):
{ "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 全量抓取时不传时间窗。
响应体(实测,已脱敏路径):
{ "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风格的VoiceClipOutschema(from_attributes)。
4. 抓取 + sync
scrape_voice_clips(client, department_id?):遍历本地员工表所有员工user_id(微信 + WA 员工),逐员工POST /wechatweb/call/listbody={pageNum, pageSize, userId}(不传时间窗 = 该员工全量片段),分页至total,按idupsertvoice_clip。channel是本地按该员工所属通道(wechat/whatsapp)打的标记,写在行上,不发给云客。- 复用 4a 的
_build_voice_body/ 分页 / coerce 模式;client 走现有YunkeClient。 - 员工→通道映射:复用 staff 表(
user_id+ channel 信息)。
- 复用 4a 的
sync_voice_clips(engine, ...):照 4async_wa_voice_calls模式(建表/ensure + 调 scraper +SyncRun记录)。app/sync/scheduler.pyENTITY_FUNCS['voice_clips'] = sync_voice_clips,CLIsync --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?(该员工姓名,用于筛选框预填)、visiblev-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)
- 不做匹配:modal 只读,不关联 voice_clip↔phone_call、不回写云客/本地。匹配留 4d。
- 不做语音转文字:ASR 整块延后(4d+),届时音频一并本地化。
- 播放暂用 OSS URL:
oss_file_name是云客 OSS(yunke-pcfile.oss-cn-beijing.aliyuncs.com)直链,播放直接用它(与 4b 录音播放一致)。严格讲播放时浏览器从云客 CDN 拉音频——这是已知妥协,随「转写 + 音频本地化」一起解决(记为 gap,不在 4c 修)。 - 接入点标签中性:「查看长语音」(非云客的「编辑/手动匹配」),诚实反映只读。
- 抓取遍历员工 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 -bexit 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列片段、筛选/播放可用、无匹配按钮。