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

9.7 KiB
Raw Blame History

录音批量本地化(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。