feat(calls): voice_clip 手动匹配(voice_clip↔customer 本地关联写操作)

- VoiceClip: 新增 matched_friend_id 列 (String(128), nullable) + alembic 迁移 b3c4d5e6f7a8
- PATCH /api/voice-clips/{id}/match: friend_id 关联 / null 取消 (本地写, 不回写云客)
- GET /api/voice-clips: 批量解析 matched_friend_name (remark>nickname>real>id)
- VoiceClipModal: 已匹配客户列 + 匹配/取消匹配按钮 + 嵌套客户选择 picker (默认按员工列客户, 名搜索走 /search)
- 测试: 17 passed (新增 7 个 match 用例覆盖 set/clear/name 解析/404)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-11 10:43:34 +08:00
parent f1b5861b35
commit 9da272b992
9 changed files with 533 additions and 21 deletions
+91 -8
View File
@@ -1,9 +1,15 @@
"""GET /api/voice-clips — 长语音 (voice_clip) list.
"""GET /api/voice-clips + PATCH /api/voice-clips/{id}/match — 长语音 (voice_clip).
Read-only query over the mirrored ``voice_clip`` table. Powers the
``VoiceClipModal`` (微信长语音 / WA wavoicecalllist 共用的只读展示 modal).
``GET /voice-clips`` is a read-only query over the mirrored ``voice_clip``
table. Powers the ``VoiceClipModal`` (微信长语音 / WA wavoicecalllist 共用的
只读展示 modal).
Filters (all optional):
``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
@@ -21,15 +27,16 @@ the 云客 modal's default ordering.
from __future__ import annotations
from typing import Annotated
from typing import Annotated, Any
import sqlalchemy as sa
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, 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 VoiceClip
from app.db.models import Customer, VoiceClip
router = APIRouter(prefix="/api", tags=["voice_clips"])
@@ -38,6 +45,42 @@ 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,
@@ -85,5 +128,45 @@ async def list_voice_clips(
.offset((page - 1) * page_size)
)
rows = (await session.execute(list_stmt)).scalars().all()
items = [VoiceClipOut.model_validate(row) for row in rows]
# 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,
) -> 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.
"""
clip = await session.get(VoiceClip, voice_clip_id)
if not clip:
raise HTTPException(status_code=404, detail="voice_clip not found")
clip.matched_friend_id = body.friend_id # None clears the match
await session.commit()
return {
"voice_clip_id": voice_clip_id,
"matched_friend_id": body.friend_id,
}
+7
View File
@@ -277,6 +277,13 @@ class VoiceClipOut(BaseModel):
oss_file_name: str | None = None
mp3_duration: int | None = None
duration_format: str | None = None
# LOCAL-only 手动匹配 (spec §7): the customer.friend_id this clip has been
# associated with, or None when unmatched. ``matched_friend_name`` is NOT a
# stored column — it's resolved by the GET handler via a Customer lookup
# (remark > nickname > real_name > friend_id) so the modal can render the
# 「已匹配客户」 column without a second round-trip per row.
matched_friend_id: str | None = None
matched_friend_name: str | None = None
raw_json: dict[str, Any] | None = None