Files
yunke/docs/superpowers/plans/2026-08-07-yunke-recording-localization.md
T
浅念 90e614e2d7 docs(plan): 录音本地化 plan 修订——修复下载死循环 + 测试 session + WA v-if
plan review 反馈:while 循环加 tried 集合排除已试 call_id(持续失败行不死循环);
_make_engine 只返回 engine 需自建 sessionmaker;WA 录音按钮 v-if 用 ossFileName。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-07 16:06:56 +08:00

486 lines
24 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)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把通话录音(`phone_call.record_file` 的云客 OSS mp3 URL)在 sync 时下载到本地 media 存储,运行时前端播放/详情 drawer **优先本地**、未下载完回退 OSS。
**Architecture:** 复用现有 media 管道(`ObjectStore` + `Media` 表 + `download_url` + `upsert_media` + `GET /api/media/{hash}`,原作者专为录音预留的扩展点),只补接线:①`phone_call` 加 `record_media_hash` 列;②`download_call_recordings` 下载器;③`sync_call_recordings` 独立 sync pass + scheduler `ENTITY_FUNCS['call_recordings']`;④`PhoneCallOut`/前端类型加字段;⑤CallList `playRecord` + callFields drawer 本地优先。
**Tech Stack:** 后端 Python 3.12 + FastAPI + SQLAlchemy 2.0 async + Alembic + httpx(respx 测试);前端 Vue3 + TS + Element Plus + axios(`baseURL:'/api'`)。
**Spec:** `docs/superpowers/specs/2026-08-07-yunke-recording-localization-design.md`(复用主张已 spec-review 核验)。
**事实依据(已实测):**
- 录音 OSS URL 公开免登录(voice_call + wa_voice_call `record_file` 均 `HTTP 200`/`AliyunOSS`/`audio/mpeg`,无需云客 cookie)。
- 复用文件:`app/storage/object_store.py::ObjectStore.put_by_hash(data,*,ext)`、`app/scraper/media.py::download_url/_ext_from_url/guess_mime_type`、`app/storage/repos.py::upsert_media(...)`、`app/api/routers/media.py::GET /api/media/{media_hash}`。
- sync 模式:`app/sync/orchestrator.py` 的 `_start_run/_finish_run` + `SyncResult`;`app/sync/scheduler.py` 的 `_run_<entity>(session, client, store)` wrapper + `ENTITY_FUNCS`。
- 前端:`yunkes/src/api/client.ts` axios `baseURL:'/api'`(Vite 代理→:8000),故浏览器里 `/api/media/{hash}` 直接可用。
**运行命令(验证用):**
- 后端测试:`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_media_downloader.py tests/test_api_calls.py -v`
- 迁移:`cd yunke-server && .venv/Scripts/python.exe -m alembic upgrade head`
- 实跑下载:`cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m app.sync.cli sync --entity call_recordings`
- 类型检查:`cd yunkes && npx vue-tsc -b`
---
## File Map
**后端:**
- Modify `yunke-server/app/db/models.py` — `PhoneCall` 加 `record_media_hash` 列
- Create `yunke-server/migrations/versions/<new>_phone_call_record_media_hash.py` — Alembic 加列
- Modify `yunke-server/app/scraper/media.py` — 加 `download_call_recordings`(复用本文件 helper)
- Modify `yunke-server/app/sync/orchestrator.py` — `ENTITY_CALL_RECORDINGS` 常量 + `sync_call_recordings`
- Modify `yunke-server/app/sync/scheduler.py` — `_run_call_recordings` wrapper + `ENTITY_FUNCS['call_recordings']`
- Modify `yunke-server/app/api/schemas.py` — `PhoneCallOut.record_media_hash`
- Create/Modify tests:`tests/test_recording_downloader.py`(新)、`tests/test_api_calls.py`(字段断言)、orchestrator sync 测试
**前端:**
- Modify `yunkes/src/api/types.ts` — `PhoneCall.record_media_hash`
- Modify `yunkes/src/views/CallList.vue` — `playRecord` 本地优先 + 录音按钮 v-if
- Modify `yunkes/src/utils/callFields.ts` — recordFile audio 字段本地优先(drawer)
---
## Task 1: 后端模型 + Alembic 迁移(phone_call.record_media_hash)
> 事实:`PhoneCall` 模型在 `app/db/models.py:442-468`,列止于 `raw_json/fetched_at/updated_at`,无 `record_media_hash`。迁移模板见 `migrations/versions/3892dbfab839_customer_add_time.py`(`batch_alter_table` + `add_column`)。
**Files:**
- Modify `yunke-server/app/db/models.py`(`PhoneCall` 类,~L458 `record_file` 行下方)
- Create `yunke-server/migrations/versions/<new>_phone_call_record_media_hash.py`
- Test: `yunke-server/tests/test_migration_phone_call_record_media_hash.py`(新,可选;或靠现有迁移 round-trip 测试覆盖)
- [ ] **Step 1: 加列到模型**
在 `app/db/models.py` 的 `PhoneCall` 类内,`record_file`(L458)下方加:
```python
# sha256 media_hash of the localized recording blob (FK -> Media.media_hash);
# NULL until sync_call_recordings downloads it. Populated by the
# call_recordings sync pass; playback prefers /api/media/{this} over the
# OSS record_file URL.
record_media_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
```
(`String` 已在文件顶部 import。)
- [ ] **Step 2: 生成/手写迁移**
手写迁移文件 `migrations/versions/<新rev>_phone_call_record_media_hash.py`(照 `3892dbfab839` 模板;`down_revision` = 当前 head `3892dbfab839`,可用 `alembic heads` 确认):
```python
"""phone_call record_media_hash
Revision ID: <生成的新 id>
Revises: 3892dbfab839
Create Date: 2026-08-07 ...
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = '<新 id>'
down_revision: str | Sequence[str] | None = '3892dbfab839'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
with op.batch_alter_table('phone_call', schema=None) as batch_op:
batch_op.add_column(sa.Column('record_media_hash', sa.String(length=64), nullable=True))
def downgrade() -> None:
with op.batch_alter_table('phone_call', schema=None) as batch_op:
batch_op.drop_column('record_media_hash')
```
> 也可 `cd yunke-server && .venv/Scripts/python.exe -m alembic revision --autogenerate -m "phone_call record_media_hash"` 自动生成再核对(只应含 phone_call 加列)。
- [ ] **Step 3: 跑迁移 + 既有测试确认不破**
```bash
cd yunke-server && .venv/Scripts/python.exe -m alembic upgrade head
cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_repos.py tests/test_api_calls.py -q
```
期望:迁移 exit 0;既有测试仍绿。
- [ ] **Step 4: Commit**
```bash
git add yunke-server/app/db/models.py yunke-server/migrations/versions/*phone_call_record_media_hash*.py
git commit -m "$(cat <<'EOF'
feat(db): phone_call.record_media_hash 列 + 迁移(录音本地化 FK→Media)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 2: 下载器 download_call_recordings(TDD)
> 事实:`app/scraper/media.py` 已有 `download_url(client,url)->bytes`、`_ext_from_url(url)`、`guess_mime_type(ext)`、`_EXT_MIME`(含 `.mp3→audio/mpeg`)、`logger`、`httpx`、`upsert_media` import、`ObjectStore`、`AsyncSession`、`Media`。需补 import `PhoneCall`。测试风格见 `tests/test_media_downloader.py`(respx mock OSS + tmp_path ObjectStore + in-memory SQLite)。
**Files:**
- Modify `yunke-server/app/scraper/media.py`(加 `download_call_recordings` + export)
- Test: `yunke-server/tests/test_recording_downloader.py`(新)
- [ ] **Step 1: 写失败测试** `tests/test_recording_downloader.py`
照 `test_media_downloader.py` 的 fixture(`_primed_client`、`_make_engine`、tmp_path `ObjectStore`、respx mock)。核心用例:
```python
import respx, httpx
from sqlalchemy import select
from app.db.models import Media, PhoneCall
from app.scraper.media import download_call_recordings
from app.storage.object_store import ObjectStore
OSS_MP3 = "https://yunke-pcfile.oss-cn-beijing.aliyuncs.com/wechat-call/8m0atd/x/ossCallback/a_b.mp3"
async def test_download_call_recordings_localizes_and_links(monkeypatch, tmp_path):
# 注意:_make_engine() 只返回 engine(见 test_media_downloader.py:69-75),
# 需自建 async_sessionmaker + 开 session(照 test_media_downloader 每个用例的写法)。
engine = await _make_engine()
Session = async_sessionmaker(engine, expire_on_commit=False)
async with Session() as session:
# 种一条 phone_call:有 record_file、无 record_media_hash
session.add(PhoneCall(call_id="c1", channel="voice_call", record_file=OSS_MP3))
await session.commit()
store = ObjectStore(base_dir=tmp_path)
client = _primed_client()
with respx.mock:
respx.get(OSS_MP3).mock(return_value=httpx.Response(200, content=b"ID3" + b"\x00" * 10))
attempted, localized = await download_call_recordings(client, store, session)
assert (attempted, localized) == (1, 1)
call = (await session.execute(select(PhoneCall))).scalar_one()
assert call.record_media_hash # 回写
media = (await session.execute(select(Media))).scalar_one()
assert media.media_hash == call.record_media_hash
assert media.media_type == "audio"
assert media.mime_type == "audio/mpeg"
assert media.original_url == OSS_MP3
async def test_idempotent_skip(tmp_path):
# 已有 record_media_hash 的行不被处理(WHERE record_media_hash IS NULL)
...种一条 record_media_hash='existing';跑 → attempted==0
async def test_resilient_on_failure(tmp_path):
# download 持续失败 → 计入 attempted 不 localized、不抛、且【不死循环】
# (tried 集合排除已试 call_id,循环终止)
...respx.get(OSS_MP3).mock(return_value=httpx.Response(500));attempted==1, localized==0
```
(`_make_engine`/`_primed_client` 是 `test_media_downloader.py` 的**模块级函数**,非 conftest fixture——推荐:把它们提到 `tests/conftest.py` 作共享 fixture/函数,本文件 import;或照搬。需 `from sqlalchemy.ext.asyncio import async_sessionmaker`。)
- [ ] **Step 2: 跑测试确认失败**
`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_recording_downloader.py -v` → 期望 ImportError/AttributeError(`download_call_recordings` 未定义)。
- [ ] **Step 3: 实现 `download_call_recordings`**
`app/scraper/media.py`:①顶部 import 加 `PhoneCall`(`from app.db.models import Media, PhoneCall`);②`__all__` 加 `"download_call_recordings"`;③实现:
```python
async def download_call_recordings(
client: YunkeClient,
store: ObjectStore,
session: AsyncSession,
*,
batch_size: int = 50,
) -> tuple[int, int]:
"""Download call recordings (``phone_call.record_file`` OSS URLs) into the
local object store and link them via ``phone_call.record_media_hash``.
Iterates ``phone_call`` rows that carry an http(s) ``record_file`` but no
``record_media_hash`` yet. For each: fetch bytes (:func:`download_url`),
store content-addressed (:meth:`ObjectStore.put_by_hash`, sha256 dedup),
upsert a ``Media`` row (``media_type='audio'``), and write
``record_media_hash`` back onto the call.
Returns ``(attempted, localized)``. Resilient: a per-row download/store
failure is logged and counted as attempted-but-not-localized; no exception
escapes. Idempotent within a run: a per-run ``tried`` set excludes already-
attempted call_ids from later batches so a persistently-failing row is NOT
re-fetched infinitely (it stays ``record_media_hash IS NULL`` and is retried
on the NEXT run, which is desirable). Rows already carrying
``record_media_hash`` are skipped; identical bytes are stored once.
"""
attempted = 0
localized = 0
tried: set[str] = set()
while True:
stmt = (
sa.select(PhoneCall.call_id, PhoneCall.record_file, PhoneCall.channel)
.where(
PhoneCall.record_file.is_not(None),
PhoneCall.record_file.like("http%"),
PhoneCall.record_media_hash.is_(None),
)
.limit(batch_size)
)
if tried:
stmt = stmt.where(PhoneCall.call_id.not_in(tried))
rows = (await session.execute(stmt)).all()
if not rows:
break
for call_id, record_file, channel in rows:
attempted += 1
tried.add(call_id)
try:
data = await download_url(client, record_file)
except (httpx.HTTPError, OSError) as exc:
logger.warning("recording download failed for %s: %s", call_id, exc)
continue
ext = _ext_from_url(record_file)
sha256_hex, path = await store.put_by_hash(data, ext=ext)
await upsert_media(
session,
media_hash=sha256_hex,
channel=channel or "wechat",
media_type="audio",
original_url=record_file,
local_path=str(path),
size_bytes=len(data),
mime_type=guess_mime_type(ext),
)
await session.execute(
sa.update(PhoneCall)
.where(PhoneCall.call_id == call_id)
.values(record_media_hash=sha256_hex)
)
localized += 1
await session.commit()
return attempted, localized
```
(`sa` 已 import;`PhoneCall` 见①。)
- [ ] **Step 4: 跑测试确认通过**
`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_recording_downloader.py -v` → 全绿。
- [ ] **Step 5: Commit**
```bash
git add yunke-server/app/scraper/media.py yunke-server/tests/test_recording_downloader.py
git commit -m "$(cat <<'EOF'
feat(scraper): download_call_recordings 下载通话录音到本地 Media + 回写 record_media_hash
复用 download_url/put_by_hash/upsert_media;幂等+resilient;media_type='audio'。
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 3: sync_call_recordings + scheduler ENTITY_FUNC(TDD)
> 事实:`app/sync/orchestrator.py` 有 `_start_run(session, entity_type, *, channel=None, scope_id=None)`、`_finish_run(session, run, *, status, fetched, upserted, error=None)`、`SyncResult(entity,status,fetched,upserted,error)`、ENTITY 常量集(~L92-106)。`sync_wechat_messages(session, client, store, ...)` 是带 store 的 sync 先例。scheduler `_run_<entity>(session, client, store)` wrapper + `ENTITY_FUNCS` dict(~L219-233)。
**Files:**
- Modify `yunke-server/app/sync/orchestrator.py`(常量 + `sync_call_recordings` + import `download_call_recordings`)
- Modify `yunke-server/app/sync/scheduler.py`(`_run_call_recordings` + `ENTITY_FUNCS` + import)
- Test: `yunke-server/tests/test_orchestrator.py`(加 call_recordings 用例)或新 `tests/test_sync_call_recordings.py`
- [ ] **Step 1: 写失败测试**
`tests/test_sync_call_recordings.py`(或并入 test_orchestrator.py):种一条无 media 的 phone_call + respx mock OSS → 调 `sync_call_recordings(session, client, store)` → 断言返回 `SyncResult(status='success', upserted=1)`,且 `SyncRun` 行(entity_type='call_recordings')存在。
```python
from app.sync.orchestrator import sync_call_recordings, ENTITY_CALL_RECORDINGS
from app.db.models import SyncRun
# ... 断言 SyncResult.entity==ENTITY_CALL_RECORDINGS, status=='success', upserted==1
# (await session.execute(select(SyncRun).where(SyncRun.entity_type==ENTITY_CALL_RECORDINGS))).scalar_one()
```
- [ ] **Step 2: 跑确认失败**(`sync_call_recordings`/`ENTITY_CALL_RECORDINGS` 未定义)。
- [ ] **Step 3: 实现**
`app/sync/orchestrator.py`:
① 顶部 import:`from app.scraper.media import download_call_recordings`(与既有 `from app.scraper...` 同区)。
② ENTITY 常量区(~L92-106)加:`ENTITY_CALL_RECORDINGS = "call_recordings"`。
③ 加函数(放在 `sync_wa_voice_calls` 之后):
```python
async def sync_call_recordings(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> SyncResult:
"""Download localized copies of call recordings for already-mirrored calls.
Not a scrape: reads local ``phone_call`` rows whose ``record_file`` (OSS
URL) has no ``record_media_hash`` yet, downloads each via the public OSS
URL, and links it. Records a ``SyncRun`` (entity_type='call_recordings');
``fetched`` = calls needing download, ``upserted`` = calls localized.
"""
run = await _start_run(session, ENTITY_CALL_RECORDINGS)
try:
attempted, localized = await download_call_recordings(client, store, session)
await _finish_run(session, run, status="success", fetched=attempted, upserted=localized)
await session.commit()
return SyncResult(entity=ENTITY_CALL_RECORDINGS, status="success", fetched=attempted, upserted=localized)
except Exception as exc:
logger.exception("sync_call_recordings failed")
await _finish_run(session, run, status="failed", fetched=0, upserted=0, error=str(exc))
await session.commit()
return SyncResult(entity=ENTITY_CALL_RECORDINGS, status="failed", fetched=0, upserted=0, error=str(exc))
```
`app/sync/scheduler.py`:
① import:`from app.sync.orchestrator import (... sync_call_recordings, ENTITY_CALL_RECORDINGS)`(按既有 import 风格补)。
② 加 wrapper(挨着其它 `_run_*`):
```python
async def _run_call_recordings(
session: AsyncSession, client: YunkeClient, store: ObjectStore
) -> list[SyncResult]:
return [await sync_call_recordings(session, client, store)]
```
③ `ENTITY_FUNCS`(~L221-232)加:`"call_recordings": _run_call_recordings,`。
- [ ] **Step 4: 跑测试确认通过** `pytest tests/test_sync_call_recordings.py -v`(+ 既有 orchestrator/scheduler 测试不破)。
- [ ] **Step 5: Commit**
```bash
git add yunke-server/app/sync/orchestrator.py yunke-server/app/sync/scheduler.py yunke-server/tests/test_sync_call_recordings.py
git commit -m "$(cat <<'EOF'
feat(sync): sync_call_recordings + ENTITY_FUNCS['call_recordings'] 独立下载 pass
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 4: API PhoneCallOut 字段(TDD)
> 事实:`PhoneCallOut` 在 `app/api/schemas.py:192-220`,`from_attributes=True`,列在 L207-220。测试 `tests/test_api_calls.py`。
**Files:**
- Modify `yunke-server/app/api/schemas.py`(`PhoneCallOut` 加字段)
- Test: `yunke-server/tests/test_api_calls.py`
- [ ] **Step 1: 写失败测试** 在 `test_api_calls.py` 加:种一条 `record_media_hash='abc'` 的 phone_call → GET `/api/calls` → 断言 `items[0]['record_media_hash']=='abc'`。
- [ ] **Step 2: 跑确认失败**(字段不在响应)。
- [ ] **Step 3: 实现** `PhoneCallOut` 内(L219 `record_file` 下方)加:
```python
# sha256 media_hash of the localized recording (NULL until downloaded).
record_media_hash: str | None = None
```
- [ ] **Step 4: 跑确认通过** `pytest tests/test_api_calls.py -v`。
- [ ] **Step 5: Commit**
```bash
git add yunke-server/app/api/schemas.py yunke-server/tests/test_api_calls.py
git commit -m "$(cat <<'EOF'
feat(api): PhoneCallOut 暴露 record_media_hash
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 5: 前端——类型 + playRecord 本地优先 + drawer(TDD/manual)
> 事实:`PhoneCall` 类型 `yunkes/src/api/types.ts:123-147`;`playRecord` `yunkes/src/views/CallList.vue`(~L92-106,`playRecord` 读 `cell(row,'recordFile')`/`row.record_file`);录音按钮 v-if 在 voice/wa/phone 三 tab 的录音列(`row.record_file || cell(row,'recordFile')`)。callFields.ts `toDisplayField`:`AUDIO_KEYS={'recordFile'}` → `render='audio', value=readField(...)`,drawer 模板 `<audio :src="String(f.value)">`。前端 axios `baseURL:'/api'`+Vite 代理 → 浏览器 `/api/media/{hash}` 可用。
**Files:**
- Modify `yunkes/src/api/types.ts`(`PhoneCall` 加字段)
- Modify `yunkes/src/views/CallList.vue`(`playRecord` + 录音按钮 v-if)
- Modify `yunkes/src/utils/callFields.ts`(recordFile audio 本地优先)
- [ ] **Step 1: 类型** `types.ts` `PhoneCall` 内(`record_file` 下方)加:
```ts
/** sha256 media_hash of the localized recording (NULL until downloaded). */
record_media_hash?: string | null
```
- [ ] **Step 2: `playRecord` 本地优先** `CallList.vue` `playRecord`:
```ts
function playRecord(row: PhoneCall): void {
const local = row.record_media_hash ? `/api/media/${row.record_media_hash}` : null
const file =
local ??
(typeof cell(row, 'recordFile') === 'string' ? (cell(row, 'recordFile') as string) : null) ??
row.record_file
if (!file) {
ElMessage.info('该通话无录音文件')
return
}
recordSrc.value = file
const who = row.user_id_name || row.user_id || row.call_id
const when = formatTime(row.start_call_time)
recordTitle.value = `录音 · ${who}${when !== '--' ? ` · ${when}` : ''}`
recordDialogVisible.value = true
}
```
录音按钮 v-if(三处录音列,**注意 WA 用 ossFileName 不是 recordFile**)各加 `row.record_media_hash ||` 前缀:
- 微信语音 tab 录音列(~L188):`row.record_media_hash || row.record_file || cell(row, 'recordFile')`
- WA 语音 tab 录音列(~L218,现 `row.record_file || cell(row, 'ossFileName')`):`row.record_media_hash || row.record_file || cell(row, 'ossFileName')`
- 电话 tab 录音列(~L254):`row.record_media_hash || row.record_file || cell(row, 'recordFile')`
(已下载/有 OSS 任一即显示按钮。)
- [ ] **Step 3: drawer 本地优先** `callFields.ts::toDisplayField`:在 `AUDIO_KEYS.has(camelKey)` 分支前,为 `recordFile` 注入本地源:
```ts
// 录音:优先本地化的 media_hash(/api/media/{hash}),否则原 OSS URL
if (camelKey === 'recordFile') {
const local = c.record_media_hash ? `/api/media/${c.record_media_hash}` : null
const url = local ?? (typeof value === 'string' && value ? value : null)
if (url) {
return { key: camelKey, label, value: url, render: 'audio', text: '' }
}
}
```
(`c` 是 `toDisplayField(c, camelKey)` 的 `PhoneCall` 参数;`value` 已在函数前段由 `readField` 算出。)
- [ ] **Step 4: 类型检查**
`cd yunkes && npx vue-tsc -b` → exit 0。
- [ ] **Step 5: Commit**
```bash
git add yunkes/src/api/types.ts yunkes/src/views/CallList.vue yunkes/src/utils/callFields.ts
git commit -m "$(cat <<'EOF'
feat(calls): 前端录音播放本地优先(/api/media/{hash}) + OSS 回退 + drawer
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"
```
---
## Task 6: 端到端验证(实跑 + 浏览器)
- [ ] **Step 1: 实跑下载 pass**
```bash
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" .venv/Scripts/python.exe -m app.sync.cli sync --entity call_recordings
```
期望:日志显示 attempted≈localized(voice_call 1159 + wa_voice_call 102,约 1261);无未捕获异常。
- [ ] **Step 2: 数据核验**
```bash
cd yunke-server && python -c "import sqlite3; c=sqlite3.connect('smoke.db'); print('localized:', c.execute('SELECT COUNT(*) FROM phone_call WHERE record_media_hash IS NOT NULL').fetchone()); print('media audio rows:', c.execute(\"SELECT COUNT(*) FROM media WHERE media_type='audio'\").fetchone())"
ls yunke-server/media # 出现 mp3 文件(sha256 分桶)
```
- [ ] **Step 3: 本地播放核验** 取一个 hash:`curl -sI "http://127.0.0.1:8000/api/media/<hash>"` → `200`;`curl -s -r 0-1 ...` → `206`。
- [ ] **Step 4: 浏览器** 起后端(:8000)+前端(:5173),`/calls` 微信语音 tab:点已下载通话「播放」→ 录音 dialog 的 audio `src` 指向 `/api/media/{hash}`(本地);点开详情 drawer,录音 audio 也走本地。Network 面板确认走 `/api/media/` 而非 yunke-pcfile OSS(已下载的)。
- [ ] **Step 5: 全量回归**
```bash
cd yunke-server && .venv/Scripts/python.exe -m pytest -q
cd yunkes && npx vue-tsc -b
```
全绿。无需额外 commit(本 task 仅验证)。
---
## 收尾验收
- [ ] `alembic upgrade head` + 全后端测试绿 + `vue-tsc -b` exit 0。
- [ ] `phone_call.record_media_hash` 被 `sync --entity call_recordings` 回填;`MEDIA_DIR` 下有 mp3;`Media` 表新增 `media_type='audio'` 行。
- [ ] `/api/media/{hash}` 播放录音(200 + Range 206)。
- [ ] 前端:已下载通话播放走 `/api/media/{hash}`(列表 + drawer);未下载的回退 OSS。
- [ ] 幂等:再跑 `sync --entity call_recordings` → 若上轮全部成功则 attempted=0;上轮失败行会被重试(tried 集合只单轮内有效),且单轮内不死循环。
## 不在本 plan(后续)
- 聊天语音消息(type=voice)本地化(复用同机制,另 plan)。
- voice_clip(长语音)及其音频(待长语音 plan)。
- 语音转文字(ASR)—— 本 plan 落地音频后为其铺路。
- 用户「下载到电脑」按钮(本地化后 trivial 后续)。
- 下载并发优化(现串行分批;量大时加并发 + OSS 限流)。