feat(sync): sync_call_recordings + ENTITY_FUNCS['call_recordings'] 独立下载 pass

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-07 16:40:06 +08:00
parent 1e399ff8c0
commit 38071e40ba
3 changed files with 270 additions and 1 deletions
+59 -1
View File
@@ -48,7 +48,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Department, SyncRun
from app.scraper.call import scrape_phone_calls, scrape_sms
from app.scraper.media import download_message_media
from app.scraper.media import download_call_recordings, download_message_media
from app.scraper.wechat import (
scrape_departments,
scrape_wechat_accounts,
@@ -75,8 +75,10 @@ from app.sync.watermarks import get_last_timestamp, set_watermark
logger = logging.getLogger(__name__)
__all__ = [
"ENTITY_CALL_RECORDINGS",
"SyncResult",
"sync_all_wechat",
"sync_call_recordings",
"sync_departments",
"sync_phone_calls",
"sync_sms",
@@ -106,6 +108,11 @@ ENTITY_MESSAGE = "message"
# wechat-family entities; they track their own SyncRun / SyncState rows.
ENTITY_PHONE_CALL = "phone_call"
ENTITY_SMS = "sms"
# Recording-localization pass: not a scrape — reads already-mirrored
# ``phone_call`` rows and downloads their OSS ``record_file`` into the local
# object store (records a SyncRun with this entity_type; no SyncState row
# because the phone_call table itself is the cursor).
ENTITY_CALL_RECORDINGS = "call_recordings"
CHANNEL_WECHAT = "wechat"
@@ -588,6 +595,57 @@ async def sync_wa_voice_calls(
)
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.
Unlike the other ``sync_*`` functions (which defer the commit to the
caller), this one commits its own ``SyncRun`` row: the underlying
:func:`download_call_recordings` commits per batch as it advances, so the
run bookkeeping must land in the same transaction boundary to stay
consistent with what was actually persisted.
Failure policy mirrors every other ``sync_*``: a downloader exception is
recorded as a ``status='failed'`` ``SyncRun`` and a failed
:class:`SyncResult` is returned (no re-raise), so a broken recording pass
does NOT abort a multi-entity batch.
"""
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),
)
async def sync_phone_calls(
session: AsyncSession, client: YunkeClient
) -> SyncResult: