From d9060b6920abfb6263a9fa900d6388835eb21c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 10 Aug 2026 18:12:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E9=95=BF=E8=AF=AD=E9=9F=B3=20voic?= =?UTF-8?q?e=5Fclip=20=E5=90=8E=E7=AB=AF(model+migration+scraper+sync+endp?= =?UTF-8?q?oint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- yunke-server/app/api/main.py | 3 + yunke-server/app/api/routers/voice_clips.py | 89 ++++++ yunke-server/app/api/schemas.py | 31 ++ yunke-server/app/db/models.py | 47 +++ yunke-server/app/scraper/voice_clip.py | 183 +++++++++++ yunke-server/app/storage/repos.py | 30 ++ yunke-server/app/sync/orchestrator.py | 139 +++++++- yunke-server/app/sync/scheduler.py | 13 + .../versions/a1b2c3d4e5f6_voice_clip_table.py | 49 +++ .../tests/cassettes/voiceClip_fixture.py | 152 +++++++++ yunke-server/tests/test_api_voice_clips.py | 239 ++++++++++++++ yunke-server/tests/test_scrape_voice_clips.py | 297 ++++++++++++++++++ 12 files changed, 1271 insertions(+), 1 deletion(-) create mode 100644 yunke-server/app/api/routers/voice_clips.py create mode 100644 yunke-server/app/scraper/voice_clip.py create mode 100644 yunke-server/migrations/versions/a1b2c3d4e5f6_voice_clip_table.py create mode 100644 yunke-server/tests/cassettes/voiceClip_fixture.py create mode 100644 yunke-server/tests/test_api_voice_clips.py create mode 100644 yunke-server/tests/test_scrape_voice_clips.py diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index 2d028d5..3fee548 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -15,6 +15,7 @@ Includes: (see :mod:`app.api.routers.groups`). * ``GET /api/calls`` (see :mod:`app.api.routers.calls`). * ``GET /api/sms`` (see :mod:`app.api.routers.sms`). +* ``GET /api/voice-clips`` (see :mod:`app.api.routers.voice_clips`). * ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`). * ``GET /api/search`` (see :mod:`app.api.routers.search`). * ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`). @@ -49,6 +50,7 @@ from app.api.routers import ( sms, staff_chat_stats, sync, + voice_clips, ) app = FastAPI(title="yunke data mirror", dependencies=[Depends(require_auth)]) @@ -72,6 +74,7 @@ app.include_router(sync.router) app.include_router(search.router) app.include_router(sessions.router) app.include_router(export.router) +app.include_router(voice_clips.router) @app.get("/api/health") diff --git a/yunke-server/app/api/routers/voice_clips.py b/yunke-server/app/api/routers/voice_clips.py new file mode 100644 index 0000000..258423b --- /dev/null +++ b/yunke-server/app/api/routers/voice_clips.py @@ -0,0 +1,89 @@ +"""GET /api/voice-clips — 长语音 (voice_clip) list. + +Read-only query over the mirrored ``voice_clip`` table. Powers the +``VoiceClipModal`` (微信长语音 / WA wavoicecalllist 共用的只读展示 modal). + +Filters (all optional): + +* ``user_id`` — exact employee-id match (modal opens from a CallList + row keyed on the employee). Takes PRECEDENCE over ``user_real_name`` when + both are given (the id is more precise). +* ``user_real_name`` — employee-name ILIKE / contains match (the 云客 modal's + 员工姓名 fuzzy filter). Ignored when ``user_id`` is present. +* ``channel`` — messaging channel (``wechat`` / ``whatsapp``). +* ``company_code`` — exact 企业串码 match. +* ``start`` / ``end`` — ms-epoch window compared against ``end_time`` (录音结束时间, + inclusive) — mirrors the 云客 modal's 录音结束时间 range filter. + +Ordered by ``end_time desc`` (nulls last) — most recent clips first, matching +the 云客 modal's default ordering. +""" + +from __future__ import annotations + +from typing import Annotated + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_session +from app.api.schemas import Page, VoiceClipOut +from app.db.models import VoiceClip + +router = APIRouter(prefix="/api", tags=["voice_clips"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +PageParam = Annotated[int, Query(ge=1)] +PageSizeParam = Annotated[int, Query(ge=1, le=100)] + + +@router.get("/voice-clips") +async def list_voice_clips( + session: SessionDep, + page: PageParam = 1, + page_size: PageSizeParam = 20, + user_id: Annotated[str | None, Query()] = None, + user_real_name: Annotated[str | None, Query()] = None, + channel: Annotated[str | None, Query()] = None, + company_code: Annotated[str | None, Query()] = None, + start: Annotated[int | None, Query(ge=0)] = None, + end: Annotated[int | None, Query(ge=0)] = None, +) -> Page[VoiceClipOut]: + """List voice clips with optional filters, newest by recording end first.""" + conditions = [] + # user_id takes precedence over user_real_name (id is the more precise + # filter; the 云客 modal switches between them based on whether the user + # edited the staff-name box). + if user_id is not None: + conditions.append(VoiceClip.user_id == user_id) + elif user_real_name is not None: + conditions.append(VoiceClip.user_real_name.ilike(f"%{user_real_name}%")) + if channel is not None: + conditions.append(VoiceClip.channel == channel) + if company_code is not None: + conditions.append(VoiceClip.company_code == company_code) + if start is not None: + conditions.append(VoiceClip.end_time >= start) + if end is not None: + conditions.append(VoiceClip.end_time <= end) + + total_stmt = sa.select(sa.func.count()).select_from(VoiceClip) + if conditions: + total_stmt = total_stmt.where(*conditions) + total = (await session.execute(total_stmt)).scalar_one() + + list_stmt = sa.select(VoiceClip) + if conditions: + list_stmt = list_stmt.where(*conditions) + list_stmt = ( + list_stmt.order_by( + sa.nullslast(VoiceClip.end_time.desc()), + VoiceClip.voice_clip_id.asc(), + ) + .limit(page_size) + .offset((page - 1) * page_size) + ) + rows = (await session.execute(list_stmt)).scalars().all() + items = [VoiceClipOut.model_validate(row) for row in rows] + return Page(items=items, total=total, page=page, page_size=page_size) diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index 961cc76..8f5b0bc 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -249,6 +249,37 @@ class SmsOut(BaseModel): raw_json: dict[str, Any] | None = None +class VoiceClipOut(BaseModel): + """长语音 (voice_clip) row — a long audio segment from ``/wechatweb/call/list``. + + The structured columns power the VoiceClipModal table (员工姓名 / + 企业串码 / 文件地址 / 录音开始时间 / 录音结束时间 / 录音时长); the verbatim + upstream record is exposed as ``raw_json`` so the frontend can render any + extra fields without a second endpoint. Picked up automatically via + ``from_attributes``. + """ + + model_config = ConfigDict(from_attributes=True) + + voice_clip_id: int + user_id: str + channel: str + user_real_name: str | None = None + wx_id: str | None = None + talker: str | None = None + status: str | None = None + is_send: bool | None = None + company_code: str | None = None + start_time: int | None = None + end_time: int | None = None + # OSS recording URL — surfaced so the modal can render an inline + # ``