feat(api): 长语音 voice_clip 后端(model+migration+scraper+sync+endpoint)

- VoiceClip model (BigInteger PK voice_clip_id, structured cols + raw_json)
- Alembic migration a1b2c3d4e5f6 (chains from 8c4f1d2a9e6b)
- scrape_voice_clips(client, user_id, *, channel, page_size) per-employee scraper
  over POST /wechatweb/call/list {pageNum, pageSize, userId}; channel stamped
  locally (wechat/whatsapp), startTime/endTime ms-epoch string -> int
- upsert_voice_clip repo + start_time/end_time/mp3_duration int coercion
- sync_voice_clips orchestrator: iterates distinct user_id across
  WechatAccount + WhatsappAccount, stamps channel per account table
- scheduler ENTITY_FUNCS['voice_clips'] + added to daily 'all' job
- GET /api/voice-clips (user_id exact beats user_real_name fuzzy; channel;
  company_code; start/end window on end_time; ordered end_time desc)
- VoiceClipOut schema + router registered in main.py
- tests: scraper (5) + API (11) = 16 new; full suite 370 green

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-10 18:12:28 +08:00
parent a0db4535f1
commit d9060b6920
12 changed files with 1271 additions and 1 deletions
+30
View File
@@ -63,6 +63,7 @@ from app.db.models import (
PhoneCall,
Sms,
Staff,
VoiceClip,
WechatAccount,
WhatsappAccount,
)
@@ -79,6 +80,7 @@ __all__ = [
"upsert_staff",
"upsert_staff_from_account",
"upsert_voice_call",
"upsert_voice_clip",
"upsert_wechat_account",
"upsert_whatsapp_account",
]
@@ -108,6 +110,12 @@ _INT_FIELDS: frozenset[str] = frozenset(
"start_call_time",
"qc_status",
"create_time",
# VoiceClip (voice_clip): ms-epoch + duration fields arrive as strings
# or ints depending on the capture; coerce to int defensively. The
# scraper already emits ints, but a hand-built record may not.
"start_time",
"end_time",
"mp3_duration",
}
)
@@ -565,6 +573,28 @@ async def upsert_sms(session: AsyncSession, rec: dict[str, Any]) -> None:
await _upsert_one(session, Sms, rec)
# ---------------------------------------------------------------------------
# VoiceClip (长语音) upsert
# ---------------------------------------------------------------------------
async def upsert_voice_clip(session: AsyncSession, rec: dict[str, Any]) -> None:
"""Idempotently upsert one :class:`VoiceClip` (PK = ``voice_clip_id``).
Same ``voice_clip_id`` twice -> one row, with non-PK fields refreshed from
the later record. Applies the documented int coercions (``start_time`` /
``end_time`` / ``mp3_duration`` -> int when they arrive as numeric strings;
the voice-clip scraper already emits ints via :func:`coerce_int_or_none`,
but a hand-built record may not). ``is_send`` is a bool field and goes
through :func:`_coerce_bool` like the other bool columns.
``channel`` is stamped locally by the sync orchestrator (``wechat`` /
``whatsapp``) based on which staff-account table owns the scraped
``user_id``; the endpoint does not carry it.
"""
await _upsert_one(session, VoiceClip, rec)
# ---------------------------------------------------------------------------
# Media upsert (M2, Task 2.6)
# ---------------------------------------------------------------------------