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,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)。