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:
@@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user