Files
yunke/yunke-server/app/api/routers/voice_clips.py
T
浅念 c261b2bc9a fix(security): CORS限制+SSRF防护+PATCH乐观锁+accounts安全cap(4 important bugs)
- main.py + config.py: CORS origins read from Settings.cors_origins (comma-separated).
  Empty falls back to dev origin localhost:5173; never wildcard. Methods locked
  to GET/PATCH/POST; allow_credentials=False.
- scraper/media.py: download_url now passes follow_redirects=False to httpx.
  OSS URLs return 200 directly; a 3xx is treated as a download failure (caught
  by existing except clauses) instead of opening an SSRF vector to internal hosts.
- routers/voice_clips.py: PATCH /voice-clips/{id}/match accepts an optional
  If-Match header carrying the client's last-seen updated_at. On mismatch the
  row was modified by another request and the server returns 409. Without the
  header the behaviour is unchanged (backward compatible). Response now echoes
  the post-update updated_at so the client can fold it into the next request.
- routers/organization.py: /api/accounts now UNION ALLs the wechat_account and
  whatsapp_account projections at the SQL level and pushes COUNT + pagination
  (offset/limit) down to the DB. Stable ordering on (channel, account_id).
  Removes the in-memory materialization that broke at scale.

Tests: 395 passed. Added optimistic-lock coverage (If-Match accepted / stale 409 /
absent header backward-compat). Updated existing PATCH tests for the new
updated_at response field.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-12 11:41:30 +08:00

199 lines
7.9 KiB
Python

"""GET /api/voice-clips + PATCH /api/voice-clips/{id}/match — 长语音 (voice_clip).
``GET /voice-clips`` is a read-only query over the mirrored ``voice_clip``
table. Powers the ``VoiceClipModal`` (微信长语音 / WA wavoicecalllist 共用的
只读展示 modal).
``PATCH /voice-clips/{id}/match`` is the 手动匹配 local write — associates a
clip with a customer (好友) by storing ``matched_friend_id``. Passing
``{"friend_id": null}`` clears the association. The match lives only in our
mirror; nothing is pushed back to 云客.
GET 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, Any
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel
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 Customer, 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)]
class MatchBody(BaseModel):
"""Body for PATCH /voice-clips/{id}/match.
``friend_id`` is the customer.friend_id to associate, or ``None`` to clear
the existing match. A non-null id that doesn't reference an existing
customer row is accepted (no FK is enforced across the mirror tables —
``matched_friend_name`` will simply resolve to the id itself, same fallback
as a customer whose name columns are all NULL).
"""
friend_id: str | None = None
async def _resolve_friend_names(
session: AsyncSession, friend_ids: list[str]
) -> dict[str, str]:
"""Batch-resolve display names for a set of customer.friend_ids.
Returns ``{friend_id: name}`` keyed by id. Name precedence matches the rest
of the app (see staff_chat_stats): ``remark > nickname > real_name > friend_id``.
Empty input -> empty dict (callers default to ``None``).
"""
if not friend_ids:
return {}
stmt = sa.select(
Customer.friend_id,
Customer.friend_remark,
Customer.friend_nickname,
Customer.real_name,
).where(Customer.friend_id.in_(friend_ids))
names: dict[str, str] = {}
for fid, remark, nick, real in (await session.execute(stmt)).all():
names[fid] = remark or nick or real or fid
return names
@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()
# Resolve the matched-customer display names for this page in a single
# batched query (one extra round-trip per page, not per row).
matched_ids = [r.matched_friend_id for r in rows if r.matched_friend_id]
name_map = await _resolve_friend_names(session, matched_ids)
items = []
for row in rows:
out = VoiceClipOut.model_validate(row)
if row.matched_friend_id:
# name_map always has an entry when matched_friend_id was non-null,
# but guard anyway: fall back to the id itself when the customer
# row was deleted after the match was recorded.
out.matched_friend_name = name_map.get(
row.matched_friend_id, row.matched_friend_id
)
items.append(out)
return Page(items=items, total=total, page=page, page_size=page_size)
@router.patch("/voice-clips/{voice_clip_id}/match")
async def match_voice_clip(
session: SessionDep,
voice_clip_id: int,
body: MatchBody,
if_match: Annotated[str | None, Header()] = None,
) -> dict[str, Any]:
"""Associate (or clear) the customer match on a voice_clip.
LOCAL write only — ``matched_friend_id`` is stored in the mirror's
``voice_clip`` row and never pushed to 云客. ``friend_id=None`` clears an
existing match.
Optimistic lock (optional, backward-compatible): the client may send
``If-Match: <updated_at ISO>`` with the ``updated_at`` it last read. When
present, the server compares it against the row's current ``updated_at``
and rejects with ``409`` if the row was modified by another request in
between. Without the header, behavior is unchanged.
The response echoes the post-update ``updated_at`` so the client can fold
it into its next read.
"""
clip = await session.get(VoiceClip, voice_clip_id)
if not clip:
raise HTTPException(status_code=404, detail="voice_clip not found")
# Optimistic lock: if the client sent If-Match, verify the row has not been
# modified since the value it read. The header carries the ISO 8601
# ``updated_at`` the client last saw; a mismatch means another writer
# committed first.
if if_match is not None and clip.updated_at is not None:
expected = clip.updated_at.isoformat()
if if_match != expected:
raise HTTPException(
status_code=409,
detail="voice_clip was modified by another request",
)
clip.matched_friend_id = body.friend_id # None clears the match
await session.commit()
# Refresh to pick up the server-generated ``updated_at`` from the UPDATE
# (``onupdate=func.now()``); with ``expire_on_commit=False`` the attribute
# otherwise stays at the pre-update value.
await session.refresh(clip)
return {
"voice_clip_id": voice_clip_id,
"matched_friend_id": body.friend_id,
"updated_at": clip.updated_at.isoformat() if clip.updated_at else None,
}