Files
yunke/docs/superpowers/specs/2026-08-07-yunke-recording-localization-design.md
T
浅念 d3fd0a5425 docs(spec): 录音本地化 spec——drawer 播放也切本地 + media URL 约定 + sync 前提
用户 review:把详情 drawer 录音播放纳入本地化范围(一致性+全本地);
补 media URL 前缀对齐现有渲染、sync pass 须实跑的运维前提。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 15:55:12 +08:00

128 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 录音批量本地化(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` + **详情 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`);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 录音也切本地**:`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)
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 级可接受。
- **全本地依赖 sync 实跑**:本 plan 的「全本地」只有 `call_recordings` sync 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_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。