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
+47
View File
@@ -477,6 +477,52 @@ class PhoneCall(Base):
)
class VoiceClip(Base):
"""长语音片段 — source: ``POST /wechatweb/call/list`` (WeChat + WhatsApp
share the same endpoint, distinguished by the employee's channel).
Distinct from :class:`PhoneCall` (which holds ``callLog/list`` phone calls
and ``queryWeChatVoiceList`` voice-call records): a voice_clip is one of the
long audio segments listed in the 云客 微信长语音 / WA wavoicecalllist modals.
PK is the source ``id`` (a cross-employee-globally-unique bigint).
``channel`` here is the **messaging channel** in {``wechat``, ``whatsapp``}
— stamped LOCALLY by the sync orchestrator based on which staff-account
table owns the scraped ``user_id`` (WechatAccount -> ``wechat``,
WhatsappAccount -> ``whatsapp``). The upstream endpoint is shared and does
NOT distinguish the channel in its response, so the channel must come from
the employee mapping, not the record.
``start_time`` / ``end_time`` arrive as ms-epoch **strings** (e.g.
``"1786072733378"``); the scraper normalizes them to ``int``.
"""
__tablename__ = "voice_clip"
voice_clip_id: Mapped[int] = mapped_column(
BigInteger, primary_key=True, autoincrement=False
)
user_id: Mapped[str] = mapped_column(String(64), nullable=False)
user_real_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
wx_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
talker: Mapped[str | None] = mapped_column(String(128), nullable=True)
status: Mapped[str | None] = mapped_column(String(32), nullable=True)
is_send: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
company_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
start_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
end_time: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
oss_file_name: Mapped[str | None] = mapped_column(String(1024), nullable=True)
mp3_duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration_format: Mapped[str | None] = mapped_column(String(32), nullable=True)
channel: Mapped[str] = mapped_column(String(16), nullable=False)
raw_json: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
fetched_at: Mapped[dt.datetime] = _fetched_at()
updated_at: Mapped[dt.datetime] = _updated_at()
__table_args__ = (Index("ix_voice_clip_user_time", "user_id", "start_time"),)
class Sms(Base):
"""短信记录 — source: ``POST /pc/customer/smsLogsList``."""
@@ -617,6 +663,7 @@ __all__ = [
"Staff",
"SyncRun",
"SyncState",
"VoiceClip",
"WechatAccount",
"WhatsappAccount",
"YkAccount",