From cd4ac5feb3459544650a7848b918b649a8a5ca5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Fri, 7 Aug 2026 15:13:34 +0800 Subject: [PATCH] =?UTF-8?q?docs(spec):=20=E5=BD=95=E9=9F=B3=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=9C=AC=E5=9C=B0=E5=8C=96=E8=AE=BE=E8=AE=A1=E2=80=94?= =?UTF-8?q?=E2=80=94=E5=A4=8D=E7=94=A8=E7=8E=B0=E6=9C=89=20media=20?= =?UTF-8?q?=E7=AE=A1=E9=81=93=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit phone_call.record_media_hash + download_call_recordings + sync_call_recordings 独立 pass + PhoneCallOut 字段 + CallList 本地优先/OSS 回退。范围仅通话录音; 语音消息/voice_clip/转写后续。路线图第 1 个 plan(4c 长语音只读已搁置)。 Co-Authored-By: Claude --- ...-07-yunke-recording-localization-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-07-yunke-recording-localization-design.md diff --git a/docs/superpowers/specs/2026-08-07-yunke-recording-localization-design.md b/docs/superpowers/specs/2026-08-07-yunke-recording-localization-design.md new file mode 100644 index 0000000..5d11e6c --- /dev/null +++ b/docs/superpowers/specs/2026-08-07-yunke-recording-localization-design.md @@ -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。