diff --git a/docs/superpowers/plans/2026-08-07-yunke-recording-localization.md b/docs/superpowers/plans/2026-08-07-yunke-recording-localization.md index f7c9a76..435e62e 100644 --- a/docs/superpowers/plans/2026-08-07-yunke-recording-localization.md +++ b/docs/superpowers/plans/2026-08-07-yunke-recording-localization.md @@ -134,36 +134,40 @@ 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): - engine, session = await _make_engine() # 复用 test_media_downloader 的 in-memory engine helper - # 种一条 phone_call:有 record_file、无 record_media_hash - session.add(PhoneCall(call_id="c1", channel="voice_call", record_file=OSS_MP3)) - await session.commit() + # 注意:_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) + 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 - assert (tmp_path / media.local_path.split(str(tmp_path))[-1].lstrip("/")).exists() or tmp_path.exists() + 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(monkeypatch, tmp_path): - # 已有 record_media_hash 的行不被重复处理(fetched 集合不含它) +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(monkeypatch, tmp_path): - # download_url 抛 httpx.HTTPError → 该条计入 attempted 不 localized,不抛 +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` 提为共享 import,或在本文件复制。) +(`_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` 未定义)。 @@ -190,27 +194,33 @@ async def download_call_recordings( Returns ``(attempted, localized)``. Resilient: a per-row download/store failure is logged and counted as attempted-but-not-localized; no exception - escapes. Idempotent: rows already carrying ``record_media_hash`` are - skipped; identical bytes are stored once. + 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: - rows = ( - await session.execute( - 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) + 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), ) - ).all() + .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: @@ -397,7 +407,11 @@ function playRecord(row: PhoneCall): void { recordDialogVisible.value = true } ``` -录音按钮 v-if(三处录音列)由 `row.record_file || cell(row, 'recordFile')` 改为 `row.record_media_hash || row.record_file || cell(row, 'recordFile')`(已下载/有 OSS 任一即显示)。 +录音按钮 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 @@ -461,7 +475,7 @@ cd yunkes && npx vue-tsc -b - [ ] `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(已全部 localize)。 +- [ ] 幂等:再跑 `sync --entity call_recordings` → 若上轮全部成功则 attempted=0;上轮失败行会被重试(tried 集合只单轮内有效),且单轮内不死循环。 ## 不在本 plan(后续) - 聊天语音消息(type=voice)本地化(复用同机制,另 plan)。