docs(spec): 录音批量本地化设计——复用现有 media 管道接线

phone_call.record_media_hash + download_call_recordings + sync_call_recordings
独立 pass + PhoneCallOut 字段 + CallList 本地优先/OSS 回退。范围仅通话录音;
语音消息/voice_clip/转写后续。路线图第 1 个 plan(4c 长语音只读已搁置)。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-07 15:13:34 +08:00
parent 334adcb712
commit cd4ac5feb3
@@ -0,0 +1,123 @@
# 录音批量本地化(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` + scheduler `ENTITY_FUNCS['call_recordings']` + CLI `sync --entity call_recordings`(独立 sync pass)。
- `PhoneCallOut` / 前端 `PhoneCall` 类型加 `record_media_hash`。
- CallList `playRecord`:**本地优先(`/api/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'、`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(engine, ...)`:照 `sync_wa_voice_calls` 模式(开 session、跑 `download_call_recordings`、写 `SyncRun`)。**不抓云客列表**——只读本地 DB 找缺媒体 call,从公开 OSS 下载。
- `client`:复用 `YunkeClient`(其 httpx session,下载公开 OSS 走同一 transport/ mocked 测试)。
- scheduler `ENTITY_FUNCS['call_recordings'] = sync_call_recordings`;CLI `sync --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 / 其它读 `record_file` 处不变(OSS URL 仍作元数据展示);播放源切本地。
## 8. 关键决策(已与用户确认 2026-08-07)
1. **批量本地化**(非用户下载按钮):sync 下载到镜像存储。
2. **本地优先 + OSS 回退**:未下载完回退 OSS,sync 完成后实际全本地。
3. **复用现有 media 管道**:`ObjectStore`/`Media`/`download_url`/`/api/media/{hash}`,只补接线。
4. **独立 sync pass**:`ENTITY_FUNCS['call_recordings']`,解耦可重试。
5. **范围**:仅 `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 级可接受。
- **过渡期 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
## 11. 验收
- 后端测试全绿(含 `download_call_recordings` mock 单测:成功回写 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 -b` exit 0。