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