From 9da272b99281d0d29add5c5bff88a40d84f798b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 11 Aug 2026 10:43:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(calls):=20voice=5Fclip=20=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E5=8C=B9=E9=85=8D(voice=5Fclip=E2=86=94customer=20?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=85=B3=E8=81=94=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- yunke-server/app/api/routers/voice_clips.py | 99 +++++++- yunke-server/app/api/schemas.py | 7 + yunke-server/app/db/models.py | 6 + ...c4d5e6f7a8_voice_clip_matched_friend_id.py | 35 +++ yunke-server/tests/test_api_voice_clips.py | 128 +++++++++- yunkes/src/api/client.ts | 13 + yunkes/src/api/index.ts | 23 +- yunkes/src/api/types.ts | 12 + yunkes/src/components/VoiceClipModal.vue | 231 +++++++++++++++++- 9 files changed, 533 insertions(+), 21 deletions(-) create mode 100644 yunke-server/migrations/versions/b3c4d5e6f7a8_voice_clip_matched_friend_id.py diff --git a/yunke-server/app/api/routers/voice_clips.py b/yunke-server/app/api/routers/voice_clips.py index 258423b..7645b15 100644 --- a/yunke-server/app/api/routers/voice_clips.py +++ b/yunke-server/app/api/routers/voice_clips.py @@ -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, + } diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index 8f5b0bc..f735217 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -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 diff --git a/yunke-server/app/db/models.py b/yunke-server/app/db/models.py index 4afd5cd..7d44255 100644 --- a/yunke-server/app/db/models.py +++ b/yunke-server/app/db/models.py @@ -516,6 +516,12 @@ class VoiceClip(Base): duration_format: Mapped[str | None] = mapped_column(String(32), nullable=True) channel: Mapped[str] = mapped_column(String(16), nullable=False) + # LOCAL-only manual match: references customer.friend_id when an operator + # has associated this clip with a specific customer (好友). NULL = unmatched. + # The mirror never pushes this back to 云客 — it's our own bookkeeping for + # the 手动匹配 UI (spec §7). Read paths resolve the customer name via join. + matched_friend_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + 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() diff --git a/yunke-server/migrations/versions/b3c4d5e6f7a8_voice_clip_matched_friend_id.py b/yunke-server/migrations/versions/b3c4d5e6f7a8_voice_clip_matched_friend_id.py new file mode 100644 index 0000000..4a002a7 --- /dev/null +++ b/yunke-server/migrations/versions/b3c4d5e6f7a8_voice_clip_matched_friend_id.py @@ -0,0 +1,35 @@ +"""voice_clip matched_friend_id + +Revision ID: b3c4d5e6f7a8 +Revises: a1b2c3d4e5f6 +Create Date: 2026-08-10 00:00:00.000000 + +""" +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = 'b3c4d5e6f7a8' +down_revision: str | Sequence[str] | None = 'a1b2c3d4e5f6' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # LOCAL-only 手动匹配 column: references customer.friend_id. NULL = unmatched. + # batch_alter_table keeps this portable across SQLite (dev/test, table-recreate) + # and MySQL (prod, direct ALTER). See env.py render_as_batch wiring. + with op.batch_alter_table('voice_clip', schema=None) as batch_op: + batch_op.add_column( + sa.Column('matched_friend_id', sa.String(length=128), nullable=True) + ) + + +def downgrade() -> None: + """Downgrade schema.""" + with op.batch_alter_table('voice_clip', schema=None) as batch_op: + batch_op.drop_column('matched_friend_id') diff --git a/yunke-server/tests/test_api_voice_clips.py b/yunke-server/tests/test_api_voice_clips.py index b5aef3a..96e0f6e 100644 --- a/yunke-server/tests/test_api_voice_clips.py +++ b/yunke-server/tests/test_api_voice_clips.py @@ -1,15 +1,16 @@ -"""Tests for GET /api/voice-clips — 长语音 (voice_clip) list. +"""Tests for GET /api/voice-clips + PATCH /voice-clips/{id}/match. Seeds 3 clips (2 wechat for user u1 at end_time 1000/3000, 1 whatsapp for u2 -at end_time 2000) and asserts filters + ordering (``end_time desc``) + -pagination + the ``user_id``-beats-``user_real_name`` precedence rule. +at end_time 2000) and asserts GET filters + ordering (``end_time desc``) + +pagination + the ``user_id``-beats-``user_real_name`` precedence rule, plus +the 手动匹配 PATCH (set + clear + name resolution + 404 path). """ from __future__ import annotations from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import VoiceClip +from app.db.models import Customer, VoiceClip async def _seed(session: AsyncSession) -> None: @@ -237,3 +238,122 @@ async def test_raw_json_serializes_verbatim(make_client): r_all = await client.get("/api/voice-clips") by_id = {it["voice_clip_id"]: it for it in r_all.json()["items"]} assert by_id[1]["raw_json"] is None + + +# --------------------------------------------------------------------------- +# 手动匹配 — PATCH /api/voice-clips/{id}/match +# --------------------------------------------------------------------------- + + +async def _seed_with_customer(session: AsyncSession) -> None: + """Seed 1 clip + 1 matched customer (remark='张三').""" + await _seed(session) + session.add( + Customer( + friend_id="fid_remark", + channel="wechat", + owner_account_id="wx1", + friend_remark="张三", + friend_nickname="三三", + real_name="张三真名", + ) + ) + await session.commit() + + +async def test_match_returns_payload_and_persists(make_client): + """PATCH /match sets matched_friend_id; payload echoes it; GET sees it.""" + client = await make_client(_seed_with_customer) + + r = await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_remark"}) + assert r.status_code == 200 + body = r.json() + assert body == {"voice_clip_id": 1, "matched_friend_id": "fid_remark"} + + # The GET list now reflects the match (cross-request persistence — + # requires the PATCH to commit, not just flush). + r2 = await client.get("/api/voice-clips", params={"user_id": "u1"}) + by_id = {it["voice_clip_id"]: it for it in r2.json()["items"]} + assert by_id[1]["matched_friend_id"] == "fid_remark" + # Resolved via Customer lookup: remark > nickname > real_name > friend_id. + assert by_id[1]["matched_friend_name"] == "张三" + # The other wechat clip for u1 is still unmatched. + assert by_id[3]["matched_friend_id"] is None + assert by_id[3]["matched_friend_name"] is None + + +async def test_match_name_falls_back_through_nickname_real_name(make_client): + """Name resolution: no remark -> nickname; no nickname -> real_name.""" + + async def seed(session: AsyncSession) -> None: + await _seed(session) + session.add_all( + [ + Customer( + friend_id="fid_nick", + channel="wechat", + friend_nickname="尼克", + ), + Customer( + friend_id="fid_real", + channel="wechat", + real_name="王五", + ), + ] + ) + await session.commit() + + client = await make_client(seed) + await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_nick"}) + await client.patch("/api/voice-clips/3/match", json={"friend_id": "fid_real"}) + + r = await client.get("/api/voice-clips", params={"user_id": "u1"}) + by_id = {it["voice_clip_id"]: it for it in r.json()["items"]} + assert by_id[1]["matched_friend_name"] == "尼克" + assert by_id[3]["matched_friend_name"] == "王五" + + +async def test_match_with_unknown_friend_id_falls_back_to_id(make_client): + """A match pointing at a deleted/unknown customer shows the id as name.""" + client = await make_client(_seed) + await client.patch( + "/api/voice-clips/1/match", json={"friend_id": "ghost_customer"} + ) + r = await client.get("/api/voice-clips", params={"user_id": "u1"}) + by_id = {it["voice_clip_id"]: it for it in r.json()["items"]} + assert by_id[1]["matched_friend_id"] == "ghost_customer" + assert by_id[1]["matched_friend_name"] == "ghost_customer" + + +async def test_unmatch_clears_friend_id(make_client): + """PATCH with friend_id=null clears an existing match.""" + client = await make_client(_seed_with_customer) + await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_remark"}) + # Now clear it. + r = await client.patch("/api/voice-clips/1/match", json={"friend_id": None}) + assert r.status_code == 200 + assert r.json() == {"voice_clip_id": 1, "matched_friend_id": None} + + r2 = await client.get("/api/voice-clips", params={"user_id": "u1"}) + by_id = {it["voice_clip_id"]: it for it in r2.json()["items"]} + assert by_id[1]["matched_friend_id"] is None + assert by_id[1]["matched_friend_name"] is None + + +async def test_match_missing_clip_returns_404(make_client): + """PATCH on a non-existent voice_clip_id -> 404.""" + client = await make_client(_seed) + r = await client.patch( + "/api/voice-clips/999999/match", json={"friend_id": "fid_remark"} + ) + assert r.status_code == 404 + + +async def test_match_default_null_body_is_treated_as_unmatch(make_client): + """Omitting friend_id from the body clears the match (default None).""" + client = await make_client(_seed_with_customer) + await client.patch("/api/voice-clips/1/match", json={"friend_id": "fid_remark"}) + # Empty body -> body.friend_id defaults to None -> unmatch. + r = await client.patch("/api/voice-clips/1/match", json={}) + assert r.status_code == 200 + assert r.json()["matched_friend_id"] is None diff --git a/yunkes/src/api/client.ts b/yunkes/src/api/client.ts index 1acc444..94da504 100644 --- a/yunkes/src/api/client.ts +++ b/yunkes/src/api/client.ts @@ -42,6 +42,19 @@ export function getJson(url: string, params?: Record): Promi return http.get(url, { params }) as unknown as Promise } +/** + * Typed JSON request helper for non-GET methods (PATCH/POST/PUT/DELETE). Same + * response-unwrapping contract as `getJson`. Used by the LOCAL write endpoints + * (e.g. voice_clip 手动匹配). + */ +export function requestJson( + method: 'patch' | 'post' | 'put' | 'delete', + url: string, + data?: unknown, +): Promise { + return http.request({ method, url, data }) as unknown as Promise +} + /** 以 blob 下载(HTML 导出等附件)。从 Content-Disposition 取文件名(绕开 http 的 (r)=>r.data 解包拦截器)。 */ export async function downloadBlob( url: string, diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index 5b22a5e..4c4924c 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -3,7 +3,7 @@ // Mirrors the FastAPI routes in yunke-server/app/api/routers/. Each wrapper // drops `undefined`/empty params so the query string only carries set filters // (the backend treats missing as "no filter"). -import { getJson } from './client' +import { getJson, requestJson } from './client' import type { Account, CallChannel, @@ -228,6 +228,27 @@ export function getVoiceClips(query: VoiceClipQuery = {}): Promise { + return requestJson( + 'patch', + `/voice-clips/${voiceClipId}/match`, + { friend_id: friendId }, + ) +} + // --------------------------------------------------------------------------- // SMS // --------------------------------------------------------------------------- diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index d9e4791..9e0f4b7 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -175,6 +175,18 @@ export interface VoiceClip { duration_format?: string | null /** `wechat` / `whatsapp` — local tag set at scrape time (not sent upstream). */ channel: string + /** + * LOCAL-only 手动匹配: the customer.friend_id this clip has been associated + * with, or null when unmatched. Set via PATCH /voice-clips/{id}/match; never + * pushed back to 云客. (spec §7) + */ + matched_friend_id?: string | null + /** + * Display name for `matched_friend_id`, resolved by the backend via Customer + * (remark > nickname > real_name > friend_id). null when unmatched. Not a + * stored column — computed by the GET handler. + */ + matched_friend_name?: string | null /** Verbatim upstream call/list record (camelCase keys). */ raw_json?: Record | null } diff --git a/yunkes/src/components/VoiceClipModal.vue b/yunkes/src/components/VoiceClipModal.vue index 533c06b..e2e9182 100644 --- a/yunkes/src/components/VoiceClipModal.vue +++ b/yunkes/src/components/VoiceClipModal.vue @@ -1,16 +1,21 @@