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

11 KiB
Raw Blame History

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 共享同一端点)

请求体(已实测两通道):

{ "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 风格的 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 列片段、筛选/播放可用、无匹配按钮。