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