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>
This commit is contained in:
浅念
2026-08-07 16:06:56 +08:00
parent a2f6b023fa
commit 90e614e2d7
@@ -134,7 +134,11 @@ 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" 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): async def test_download_call_recordings_localizes_and_links(monkeypatch, tmp_path):
engine, session = await _make_engine() # 复用 test_media_downloader 的 in-memory engine helper # 注意:_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 # 种一条 phone_call:有 record_file、无 record_media_hash
session.add(PhoneCall(call_id="c1", channel="voice_call", record_file=OSS_MP3)) session.add(PhoneCall(call_id="c1", channel="voice_call", record_file=OSS_MP3))
await session.commit() await session.commit()
@@ -153,17 +157,17 @@ async def test_download_call_recordings_localizes_and_links(monkeypatch, tmp_pat
assert media.media_type == "audio" assert media.media_type == "audio"
assert media.mime_type == "audio/mpeg" assert media.mime_type == "audio/mpeg"
assert media.original_url == OSS_MP3 assert media.original_url == OSS_MP3
assert (tmp_path / media.local_path.split(str(tmp_path))[-1].lstrip("/")).exists() or tmp_path.exists()
async def test_idempotent_skip(monkeypatch, tmp_path): async def test_idempotent_skip(tmp_path):
# 已有 record_media_hash 的行不被重复处理(fetched 集合不含它) # 已有 record_media_hash 的行不被处理(WHERE record_media_hash IS NULL)
...种一条 record_media_hash='existing';跑 → attempted==0 ...种一条 record_media_hash='existing';跑 → attempted==0
async def test_resilient_on_failure(monkeypatch, tmp_path): async def test_resilient_on_failure(tmp_path):
# download_url 抛 httpx.HTTPError → 该条计入 attempted 不 localized,不抛 # download 持续失败 → 计入 attempted 不 localized、不抛、且【不死循环】
# (tried 集合排除已试 call_id,循环终止)
...respx.get(OSS_MP3).mock(return_value=httpx.Response(500));attempted==1, localized==0 ...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: 跑测试确认失败** - [ ] **Step 2: 跑测试确认失败**
`cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_recording_downloader.py -v` → 期望 ImportError/AttributeError(`download_call_recordings` 未定义)。 `cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_recording_downloader.py -v` → 期望 ImportError/AttributeError(`download_call_recordings` 未定义)。
@@ -190,14 +194,17 @@ async def download_call_recordings(
Returns ``(attempted, localized)``. Resilient: a per-row download/store Returns ``(attempted, localized)``. Resilient: a per-row download/store
failure is logged and counted as attempted-but-not-localized; no exception failure is logged and counted as attempted-but-not-localized; no exception
escapes. Idempotent: rows already carrying ``record_media_hash`` are escapes. Idempotent within a run: a per-run ``tried`` set excludes already-
skipped; identical bytes are stored once. 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 attempted = 0
localized = 0 localized = 0
tried: set[str] = set()
while True: while True:
rows = ( stmt = (
await session.execute(
sa.select(PhoneCall.call_id, PhoneCall.record_file, PhoneCall.channel) sa.select(PhoneCall.call_id, PhoneCall.record_file, PhoneCall.channel)
.where( .where(
PhoneCall.record_file.is_not(None), PhoneCall.record_file.is_not(None),
@@ -206,11 +213,14 @@ async def download_call_recordings(
) )
.limit(batch_size) .limit(batch_size)
) )
).all() if tried:
stmt = stmt.where(PhoneCall.call_id.not_in(tried))
rows = (await session.execute(stmt)).all()
if not rows: if not rows:
break break
for call_id, record_file, channel in rows: for call_id, record_file, channel in rows:
attempted += 1 attempted += 1
tried.add(call_id)
try: try:
data = await download_url(client, record_file) data = await download_url(client, record_file)
except (httpx.HTTPError, OSError) as exc: except (httpx.HTTPError, OSError) as exc:
@@ -397,7 +407,11 @@ function playRecord(row: PhoneCall): void {
recordDialogVisible.value = true 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` 注入本地源: - [ ] **Step 3: drawer 本地优先** `callFields.ts::toDisplayField`:在 `AUDIO_KEYS.has(camelKey)` 分支前,为 `recordFile` 注入本地源:
```ts ```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'` 行。 - [ ] `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}` 播放录音(200 + Range 206)。
- [ ] 前端:已下载通话播放走 `/api/media/{hash}`(列表 + drawer);未下载的回退 OSS。 - [ ] 前端:已下载通话播放走 `/api/media/{hash}`(列表 + drawer);未下载的回退 OSS。
- [ ] 幂等:再跑 `sync --entity call_recordings` → attempted=0(已全部 localize)。 - [ ] 幂等:再跑 `sync --entity call_recordings` → 若上轮全部成功则 attempted=0;上轮失败行会被重试(tried 集合只单轮内有效),且单轮内不死循环。
## 不在本 plan(后续) ## 不在本 plan(后续)
- 聊天语音消息(type=voice)本地化(复用同机制,另 plan)。 - 聊天语音消息(type=voice)本地化(复用同机制,另 plan)。