用户 review:把详情 drawer 录音播放纳入本地化范围(一致性+全本地); 补 media URL 前缀对齐现有渲染、sync pass 须实跑的运维前提。 Co-Authored-By: Claude <noreply@anthropic.com>
9.7 KiB
录音批量本地化(Recording Bulk Localization)设计
日期:2026-08-07|分支:
feat/yunke-data-mirror|优先级:路线图第 1 个 plan 前置:Plan 4a(voice_call/wa_voice_call 抓取,phone_call.record_file含云客 OSS URL)+ 4b(CallList 录音播放,当前直接播 OSS URL)。 关键事实(实测):录音 OSS URL(yunke-pcfile.oss-cn-beijing.aliyuncs.com/...mp3)是公开静态文件(HTTP 200,无需云客登录,AliyunOSS,支持 Range)。
1. 目标与原则
目标:把通话录音(phone_call.record_file 的 OSS URL)在 sync 时下载到本地 media 存储,运行时前端优先播放本地、未下载完回退 OSS。满足用户核心原则「把云客数据拉到本地、运行后台不访问云客」(yunke-verify-data-in-browser)——元数据本就本地,本 plan 把音频字节也落地。
最大杠杆(复用,非新建):现有 media 管道就是为录音预留的。app/scraper/media.py docstring 明写:"downloader is URL-driven and generic... call recordings and voice-call recordings can be fetched by the same path once their owning records are wired in. That wiring is OUT of scope here." 本 plan = 接线,不新建存储/端点。
复用(已存在,不改):
app/storage/object_store.py::ObjectStore— sha256 内容寻址、去重、MEDIA_DIR。app/db/models.py::Media表 +app/storage/repos.py::upsert_media。app/scraper/media.py::download_url(client, url) -> bytes— 通用 httpx 抓取(follow redirects,public OSS 直接可用)。app/api/routers/media.py::GET /api/media/{media_hash}—FileResponse+ Range(已测,test_api_media.py)。
2. 范围
在本 plan:
phone_call加record_media_hash(FK→Media.media_hash,nullable)+ Alembic 迁移。download_call_recordings:遍历本地record_file非空、record_media_hash为空的 phone_call,下载→去重存储→upsert_media→回写 hash。幂等 + resilient。sync_call_recordings+ schedulerENTITY_FUNCS['call_recordings']+ CLIsync --entity call_recordings(独立 sync pass)。PhoneCallOut/ 前端PhoneCall类型加record_media_hash。- CallList
playRecord+ 详情 drawer 录音:本地优先(/api/media/{record_media_hash}),OSS 回退(record_file)。
不在本 plan(后续):
- 聊天语音消息(message
type=voice)本地化 —— 复用同机制,另 plan。 voice_clip(长语音)及其音频 —— 待长语音 plan。- 语音转文字(ASR)—— 需本地音频,本 plan 落地音频后为其铺路。
- 用户「下载到电脑」按钮 —— 用户本轮选「批量本地化」(非下载按钮);本地化后该按钮是 trivial 后续。
3. 数据模型变更
phone_call 加一列:
| 列 | 类型 | 说明 |
|---|---|---|
record_media_hash |
String(64) nullable | 关联 Media.media_hash;下载完写入,未下载=null |
- Alembic 迁移
ADD COLUMN(nullable,回填由 sync pass 填)。 - 不复用
phone_call.download_count/is_upload(那些是云客侧原值);record_media_hash是本地化标记。
Media 行(下载时写):media_hash=sha256(bytes)(录音无 md5,用 sha256)、media_type='audio'(新 label——现有约定是 image|voice|video|file,列是 free-form String(32) 所以可用;plan 时更新 Media.media_type 注释把 audio 纳入约定)、mime_type='audio/mpeg'、original_url=record_file、local_path、size_bytes、channel=phone_call.channel、raw_json=null。
4. 下载逻辑 download_call_recordings
async def download_call_recordings(client, store, session, batch_size=50) -> int:
# 查 record_file 非空 且 record_media_hash 为空 的 phone_call(分批)
# 对每条:
# try: data = await download_url(client, record_file)
# ext = _ext_from_url(record_file) # .mp3
# sha, path = await store.put_by_hash(data, ext=ext) # 去重
# await upsert_media(media_hash=sha, media_type='audio', mime_type='audio/mpeg',
# original_url=record_file, local_path=str(path),
# size_bytes=len(data), channel=call.channel, ...)
# call.record_media_hash = sha # 回写
# except (httpx.HTTPError, OSError): log warning, continue # resilient
# 每 batch commit。返回已下载数。
- 幂等:
record_media_hash已有的跳过;put_by_hash同字节只存一份。 - resilient:单条失败 log 不中断(同
download_message_media风格)。 - 去重:同录音被多 call 引用(理论)→ 同 sha 一份文件;每 call 各自记
record_media_hash。 - ext 抽取复用
media.py::_ext_from_url;mime 复用guess_mime_type(_EXT_MIME已含.mp3→audio/mpeg)。
5. sync
sync_call_recordings(session, client, store):照sync_wa_voice_calls模式(跑download_call_recordings、写SyncRun)。注意:现有sync_*多为(session, client),但本 func 还需store: ObjectStore——scheduler 的 wrapper_run_<entity>(session, client, store)已传 store(见 scheduler.py),故 orchestrator 直接接收;若 wrapper 未传则在 func 内ObjectStore()(默认MEDIA_DIR)。不抓云客列表——只读本地 DB 找缺媒体 call,从公开 OSS 下载。client:复用YunkeClient(其 httpx session,下载公开 OSS 走同一 transport/ mocked 测试)。- scheduler
ENTITY_FUNCS['call_recordings'](wrapper_run_call_recordings委托sync_call_recordings);CLIsync --entity call_recordings可独立跑。 - 独立 pass 的理由:解耦于 call 抓取(不拖慢)、可重试/可续、量大(~1261+)单独跑不阻塞其它实体。
6. API + 类型
PhoneCallOut(schemas.py)加record_media_hash: str | None。- 前端
PhoneCall(types.ts)加record_media_hash?: string | null。
7. 前端(CallList.vue playRecord)
const localSrc = row.record_media_hash ? `/api/media/${row.record_media_hash}` : null
const file = localSrc ?? cell(row,'recordFile')?.toString() ?? row.record_file
- 本地优先:有
record_media_hash→/api/media/{hash}(本地)。 - OSS 回退:否则用
record_file(过渡期未下载完仍可播,碰云客 CDN;sync 跑完即全本地)。 - 录音按钮 v-if:
row.record_media_hash || row.record_file || cell(row,'recordFile')(任一存在即显示,未下载的也能播)。 - 详情 drawer 录音也切本地:
callFields.ts::toDisplayField/buildCallSections构建 recordFile 的 audio 字段时,若call.record_media_hash存在则 value 用/api/media/{hash},否则原 OSS URL(readField('recordFile')/record_file);record_file文本字段仍作元数据展示。 - media URL 前缀:
/api/media/{hash}必须与现有聊天媒体渲染(MessageBubble 等)的 URL 构造一致(是否带VITE_API_BASE前缀)——plan 时确认并复用同一 helper,避免本地播放 404。
8. 关键决策(已与用户确认 2026-08-07)
- 批量本地化(非用户下载按钮):sync 下载到镜像存储。
- 本地优先 + OSS 回退:未下载完回退 OSS,sync 完成后实际全本地。
- 复用现有 media 管道:
ObjectStore/Media/download_url//api/media/{hash},只补接线。 - 独立 sync pass:
ENTITY_FUNCS['call_recordings'],解耦可重试。 - 范围:仅
phone_call.record_file;语音消息/voice_clip 后续。
9. 风险
- 下载量/存储:~1261+ mp3(voice_call 1159 + wa_voice_call 102,按当前 smoke.db;phone_call 0)。sha256 去重;MEDIA_DIR 可配(.env
MEDIA_DIR)。GB 级可接受。 - 全本地依赖 sync 实跑:本 plan 的「全本地」只有
call_recordingssync pass 实际跑完才达成(否则停留 OSS 回退)。须确保该 pass 可被触发(进默认调度 或 手跑sync --entity call_recordings);批量串行下载 1261 条可能 20-60min,并发下载留后续优化(注意 OSS 限流);磁盘需有 GB 级余量。 - 过渡期 OSS 回退碰云客 CDN(已知妥协,sync 完成后消失)。
- OSS URL 公开稳定(已实测),不依赖云客登录态 → 不会因 cookie 过期失效。
10. 文件清单(预期)
后端:
- 改
app/db/models.py:phone_call.record_media_hash - 新 Alembic 迁移
- 改
app/scraper/media.py(加download_call_recordings,复用本文件 helper)或新app/scraper/recording.py - 新
app/sync/...:sync_call_recordings+scheduler.ENTITY_FUNCS - 改
app/api/schemas.py:PhoneCallOut.record_media_hash - 新/改测试:
download_call_recordings单测(mock download_url)+ sync 测试 + PhoneCallOut 字段
前端:
- 改
yunkes/src/api/types.ts:PhoneCall.record_media_hash - 改
yunkes/src/views/CallList.vue:playRecord本地优先 + 录音按钮 v-if - 改
yunkes/src/utils/callFields.ts:recordFile audio 字段本地优先(drawer 录音播放切本地) - (plan 时确认)现有 media URL 构造 helper(MessageBubble 等),复用其前缀
11. 验收
- 后端测试全绿(含
download_call_recordingsmock 单测:成功回写 hash / 幂等跳过 / 失败 resilient;sync 测试;PhoneCallOut 字段)。 sync --entity call_recordings实跑:smoke.db 中phone_call.record_media_hash被回填,MEDIA_DIR下出现 mp3 文件,Media表新增 audio 行。/api/media/{hash}能播回填的录音(200 + Range 206)。- 前端:已下载通话播放走
/api/media/{hash}(本地);未下载的回退 OSS;vue-tsc -bexit 0。