diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index 583b1a7..9dd6d4d 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -16,6 +16,7 @@ Includes: * ``GET /api/calls`` (see :mod:`app.api.routers.calls`). * ``GET /api/sms`` (see :mod:`app.api.routers.sms`). * ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`). +* ``GET /api/search`` (see :mod:`app.api.routers.search`). CORS is configured permissively (wildcard origin, no credentials) so the local frontend can call the API during development. @@ -41,6 +42,7 @@ from app.api.routers import ( media, messages, organization, + search, sms, sync, ) @@ -62,6 +64,7 @@ app.include_router(groups.router) app.include_router(calls.router) app.include_router(sms.router) app.include_router(sync.router) +app.include_router(search.router) @app.get("/api/health") diff --git a/yunke-server/app/api/routers/search.py b/yunke-server/app/api/routers/search.py new file mode 100644 index 0000000..f0f2485 --- /dev/null +++ b/yunke-server/app/api/routers/search.py @@ -0,0 +1,165 @@ +"""GET /api/search — full-text search over messages + customers. + +Read-only ``LIKE '%q%'`` search across two mirrored tables: + +* **Messages** — matches against ``message.text``. Filters: ``channel``, + ``start``/``end`` (ms-epoch window compared against ``timestamp``). Ordered + by ``timestamp desc`` so the most recent matches surface first. +* **Customers** — matches against any of ``friend_nickname`` / ``real_name`` / + ``phone`` / ``wechat_phone``. Filter: ``channel``. Ordered by + ``last_chat_time desc`` (nulls last). + +The ``type`` selector picks which section(s) to run: + +* ``messages`` -> ``{"messages": Page[MessageOut]}`` +* ``customers`` -> ``{"customers": Page[CustomerOut]}`` +* ``all`` (default) -> ``{"messages": Page[MessageOut], "customers": + Page[CustomerOut]}``; both run with the supplied ``page`` / ``page_size``. + +No external search dependencies (no FTS index, no Elasticsearch) — this is a +plain SQL ``LIKE`` scan over the stored mirror data. Sufficient for the +data-mirror's typical row counts and the same portability rules as the rest +of the API (MySQL in prod, in-memory SQLite in tests). +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_session +from app.api.schemas import CustomerOut, MessageOut, Page +from app.db.models import Customer, Message + +router = APIRouter(prefix="/api", tags=["search"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +PageParam = Annotated[int, Query(ge=1)] +PageSizeParam = Annotated[int, Query(ge=1, le=100)] +SearchType = Annotated[Literal["messages", "customers", "all"], Query()] + +# Response shape: ``{"messages": Page[MessageOut], "customers": Page[CustomerOut]}`` +# where each requested section is present and unrequested sections are omitted +# entirely (not serialized as null). Returning a plain dict keeps the key set +# explicit so the frontend can branch on ``section in payload``. +SearchPayload = dict[str, Page[MessageOut] | Page[CustomerOut]] + + +async def _search_messages( + session: AsyncSession, + q: str, + page: int, + page_size: int, + channel: str | None, + start: int | None, + end: int | None, +) -> Page[MessageOut]: + """Run the message search. Caller validates ``q``.""" + conditions: list[Any] = [Message.text.like(f"%{q}%")] + if channel is not None: + conditions.append(Message.channel == channel) + if start is not None: + conditions.append(Message.timestamp >= start) + if end is not None: + conditions.append(Message.timestamp <= end) + + total = ( + await session.execute( + sa.select(sa.func.count()) + .select_from(Message) + .where(*conditions) + ) + ).scalar_one() + + list_stmt = ( + sa.select(Message) + .where(*conditions) + .order_by( + sa.nullslast(Message.timestamp.desc()), + Message.msg_id.asc(), + ) + .limit(page_size) + .offset((page - 1) * page_size) + ) + rows = (await session.execute(list_stmt)).scalars().all() + items = [MessageOut.model_validate(row) for row in rows] + return Page(items=items, total=total, page=page, page_size=page_size) + + +async def _search_customers( + session: AsyncSession, + q: str, + page: int, + page_size: int, + channel: str | None, +) -> Page[CustomerOut]: + """Run the customer search. Caller validates ``q``.""" + like = f"%{q}%" + text_conditions = [ + Customer.friend_nickname.like(like), + Customer.real_name.like(like), + Customer.phone.like(like), + Customer.wechat_phone.like(like), + ] + conditions: list[Any] = [sa.or_(*text_conditions)] + if channel is not None: + conditions.append(Customer.channel == channel) + + total = ( + await session.execute( + sa.select(sa.func.count()) + .select_from(Customer) + .where(*conditions) + ) + ).scalar_one() + + list_stmt = ( + sa.select(Customer) + .where(*conditions) + .order_by( + sa.nullslast(Customer.last_chat_time.desc()), + Customer.friend_id.asc(), + ) + .limit(page_size) + .offset((page - 1) * page_size) + ) + rows = (await session.execute(list_stmt)).scalars().all() + items = [CustomerOut.model_validate(row) for row in rows] + return Page(items=items, total=total, page=page, page_size=page_size) + + +@router.get("/search") +async def search( + session: SessionDep, + q: Annotated[str | None, Query()] = None, + type: SearchType = "all", + channel: Annotated[str | None, Query()] = None, + start: Annotated[int | None, Query(ge=0)] = None, + end: Annotated[int | None, Query(ge=0)] = None, + page: PageParam = 1, + page_size: PageSizeParam = 20, +) -> SearchPayload: + """Search messages (by ``text``) and/or customers (by name/phone). + + ``q`` is required — missing / empty / whitespace-only all map to HTTP 400 + so callers see a consistent validation response rather than the framework + default 422. Only the requested section(s) are present in the payload; + unrequested ones are omitted entirely. + """ + keyword = (q or "").strip() + if not keyword: + raise HTTPException(status_code=400, detail="q must not be empty") + + out: SearchPayload = {} + if type in ("messages", "all"): + out["messages"] = await _search_messages( + session, keyword, page, page_size, channel, start, end + ) + if type in ("customers", "all"): + out["customers"] = await _search_customers( + session, keyword, page, page_size, channel + ) + return out diff --git a/yunke-server/tests/test_api_search.py b/yunke-server/tests/test_api_search.py new file mode 100644 index 0000000..8cfce02 --- /dev/null +++ b/yunke-server/tests/test_api_search.py @@ -0,0 +1,315 @@ +"""Tests for GET /api/search — full-text search over messages + customers. + +Drives the FastAPI app over an in-process ``httpx.AsyncClient`` (no real +network). Seeds a small corpus: + +* Messages: a couple containing "报价", a couple containing other text, one + WhatsApp row to exercise the channel filter. +* Customers: nickname / real_name / phone / wechat_phone variants so each + LIKE branch is exercised. + +Coverage: +* ``q=报价`` finds matching messages. +* ``q=张三`` finds customers by nickname; ``q=138`` finds by phone. +* ``type`` selector returns only the requested section; ``all`` returns both. +* ``channel`` filter narrows both sections. +* Empty / whitespace ``q`` -> 400. +* Time window ``start``/``end`` narrows the message section. +* Pagination (``page``/``page_size``) is applied independently per section. +""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import Customer, Message + + +async def _seed(session: AsyncSession) -> None: + session.add_all( + [ + # --- messages --------------------------------------------------- + Message( + msg_id="m_quote_a", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id="t_a", + text="这是报价单,请查收", + timestamp=1000, + type="text", + type_raw=1, + ), + Message( + msg_id="m_quote_b", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id="t_b", + text="报价能不能再优惠一点", + timestamp=2000, + type="text", + type_raw=1, + ), + Message( + msg_id="m_other", + channel="wechat", + owner_account_id="wx_owner_1", + talker_id="t_a", + text="明天见", + timestamp=3000, + type="text", + type_raw=1, + ), + Message( + msg_id="m_quote_wa", + channel="whatsapp", + owner_account_id="wa_owner_1", + talker_id="t_wa", + text="quote 报价 via WhatsApp", + timestamp=4000, + type="text", + type_raw=0, + ), + # --- customers -------------------------------------------------- + Customer( + friend_id="wx_zhangsan", + channel="wechat", + owner_account_id="wx_owner_1", + friend_nickname="张三", + real_name="张三丰", + phone="13800000001", + wechat_phone="13800000001", + last_chat_time=2000, + ), + Customer( + friend_id="wx_lisi", + channel="wechat", + owner_account_id="wx_owner_1", + friend_nickname="李四", + real_name=None, + phone="13900000002", + last_chat_time=1000, + ), + Customer( + friend_id="wa_zhang", + channel="whatsapp", + owner_account_id="wa_owner_1", + friend_nickname="Zhang San", + phone="13700000003", + last_chat_time=None, + ), + ] + ) + await session.commit() + + +# --------------------------------------------------------------------------- +# q validation +# --------------------------------------------------------------------------- + + +async def test_empty_q_returns_400(make_client): + """Empty ``q`` -> HTTP 400 (the search keyword is required).""" + client = await make_client(_seed) + r = await client.get("/api/search", params={"q": ""}) + assert r.status_code == 400 + + +async def test_whitespace_q_returns_400(make_client): + """Whitespace-only ``q`` -> HTTP 400 (we reject after stripping).""" + client = await make_client(_seed) + r = await client.get("/api/search", params={"q": " "}) + assert r.status_code == 400 + + +async def test_missing_q_returns_400(make_client): + """Missing ``q`` -> HTTP 400 (the param is required).""" + client = await make_client(_seed) + r = await client.get("/api/search") + assert r.status_code == 400 + + +# --------------------------------------------------------------------------- +# message search +# --------------------------------------------------------------------------- + + +async def test_search_messages_by_text(make_client): + """``q=报价&type=messages`` -> only matching messages, ordered desc by time.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", params={"q": "报价", "type": "messages"} + ) + assert r.status_code == 200 + body = r.json() + # The wrapper exposes a ``messages`` page; ``customers`` is omitted when + # type=messages is requested. + assert "messages" in body + assert "customers" not in body + msgs = body["messages"] + assert msgs["total"] == 3 # m_quote_a, m_quote_b, m_quote_wa + ids = [it["msg_id"] for it in msgs["items"]] + assert set(ids) == {"m_quote_a", "m_quote_b", "m_quote_wa"} + # Ordered by timestamp DESC: m_quote_wa(4000) → m_quote_b(2000) → m_quote_a(1000) + assert ids == ["m_quote_wa", "m_quote_b", "m_quote_a"] + + +async def test_search_messages_channel_filter(make_client): + """``q=报价&type=messages&channel=wechat`` excludes the WhatsApp hit.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", + params={"q": "报价", "type": "messages", "channel": "wechat"}, + ) + assert r.status_code == 200 + msgs = r.json()["messages"] + assert msgs["total"] == 2 + assert {it["msg_id"] for it in msgs["items"]} == { + "m_quote_a", + "m_quote_b", + } + assert all(it["channel"] == "wechat" for it in msgs["items"]) + + +async def test_search_messages_time_window(make_client): + """``start``/``end`` narrow the message section.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", + params={"q": "报价", "type": "messages", "start": 0, "end": 2500}, + ) + assert r.status_code == 200 + msgs = r.json()["messages"] + # m_quote_a(1000) + m_quote_b(2000); m_quote_wa(4000) excluded. + assert {it["msg_id"] for it in msgs["items"]} == { + "m_quote_a", + "m_quote_b", + } + + +# --------------------------------------------------------------------------- +# customer search +# --------------------------------------------------------------------------- + + +async def test_search_customers_by_nickname(make_client): + """``q=张三&type=customers`` -> finds customers with that nickname/real_name.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", params={"q": "张三", "type": "customers"} + ) + assert r.status_code == 200 + body = r.json() + assert "customers" in body + assert "messages" not in body + custs = body["customers"] + # wx_zhangsan (friend_nickname=张三) + wa_zhang (Zhang San matches via + # substring "Zhang" — but we search "张三" so only the wx row hits). + ids = [it["friend_id"] for it in custs["items"]] + assert "wx_zhangsan" in ids + # The whatsapp "Zhang San" row should NOT match a Chinese keyword. + assert "wa_zhang" not in ids + + +async def test_search_customers_by_phone(make_client): + """``q=138`` -> customers whose phone/wechat_phone contains 138.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", params={"q": "138", "type": "customers"} + ) + assert r.status_code == 200 + custs = r.json()["customers"] + ids = [it["friend_id"] for it in custs["items"]] + assert ids == ["wx_zhangsan"] # only 13800000001 contains "138" + + +async def test_search_customers_channel_filter(make_client): + """``channel`` narrows the customer section.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", + params={"q": "Zhang", "type": "customers", "channel": "whatsapp"}, + ) + assert r.status_code == 200 + custs = r.json()["customers"] + assert custs["total"] == 1 + assert custs["items"][0]["friend_id"] == "wa_zhang" + + +# --------------------------------------------------------------------------- +# type=all +# --------------------------------------------------------------------------- + + +async def test_search_all_returns_both_sections(make_client): + """``type=all`` (default) -> response carries both ``messages`` and ``customers``.""" + client = await make_client(_seed) + r = await client.get("/api/search", params={"q": "报价"}) + assert r.status_code == 200 + body = r.json() + assert "messages" in body + assert "customers" in body + assert body["messages"]["total"] == 3 + # No customer has "报价" in their nickname/name/phone. + assert body["customers"]["total"] == 0 + + +async def test_search_all_default_type(make_client): + """Omitting ``type`` defaults to ``all``.""" + client = await make_client(_seed) + r = await client.get("/api/search", params={"q": "张三"}) + assert r.status_code == 200 + body = r.json() + assert "messages" in body + assert "customers" in body + # No message text contains "张三". + assert body["messages"]["total"] == 0 + assert body["customers"]["total"] >= 1 + + +async def test_search_all_with_channel_applies_to_both(make_client): + """``channel`` filters both sections when ``type=all``.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", params={"q": "报价", "channel": "whatsapp"} + ) + assert r.status_code == 200 + body = r.json() + # Only the WhatsApp message matches. + assert body["messages"]["total"] == 1 + assert body["messages"]["items"][0]["msg_id"] == "m_quote_wa" + # No whatsapp customer has "报价". + assert body["customers"]["total"] == 0 + + +# --------------------------------------------------------------------------- +# pagination +# --------------------------------------------------------------------------- + + +async def test_message_pagination(make_client): + """``page_size=1`` -> one message per page, total still 3.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", + params={"q": "报价", "type": "messages", "page": 1, "page_size": 1}, + ) + assert r.status_code == 200 + msgs = r.json()["messages"] + assert msgs["total"] == 3 + assert len(msgs["items"]) == 1 + # Descending by timestamp -> first item is the WhatsApp row (4000). + assert msgs["items"][0]["msg_id"] == "m_quote_wa" + + +async def test_customer_pagination(make_client): + """Customers paginate independently with their own page_size.""" + client = await make_client(_seed) + r = await client.get( + "/api/search", + params={"q": "1", "type": "customers", "page": 1, "page_size": 1}, + ) + assert r.status_code == 200 + custs = r.json()["customers"] + # All seeded phone numbers contain "1" -> all 3. + assert custs["total"] == 3 + assert len(custs["items"]) == 1 diff --git a/yunkes/src/api/index.ts b/yunkes/src/api/index.ts index 876dd41..a40e9bd 100644 --- a/yunkes/src/api/index.ts +++ b/yunkes/src/api/index.ts @@ -215,6 +215,49 @@ export function getSyncStatus(): Promise { return getJson('/sync/status') } +// --------------------------------------------------------------------------- +// Full-text search (messages + customers) +// --------------------------------------------------------------------------- + +export type SearchType = 'messages' | 'customers' | 'all' + +export interface SearchQuery { + q: string + /** which section(s) to query; defaults to `all`. */ + type?: SearchType + channel?: Channel | 'all' + /** ms-epoch inclusive window compared against message.timestamp. */ + start?: number + end?: number + page?: number + page_size?: number +} + +/** + * Combined search payload returned by `/api/search`. Each requested section is + * present; unrequested sections are omitted entirely by the backend (so the + * caller can branch on `section in payload`). + */ +export interface SearchPayload { + messages?: Page + customers?: Page +} + +export function search(query: SearchQuery): Promise { + return getJson( + '/search', + clean({ + q: query.q, + type: query.type ?? 'all', + channel: query.channel === 'all' ? undefined : query.channel, + start: query.start, + end: query.end, + page: query.page ?? 1, + page_size: query.page_size ?? 20, + }), + ) +} + export type { Account, CallChannel, diff --git a/yunkes/src/layouts/MainLayout.vue b/yunkes/src/layouts/MainLayout.vue index 2d0b446..d937481 100644 --- a/yunkes/src/layouts/MainLayout.vue +++ b/yunkes/src/layouts/MainLayout.vue @@ -143,6 +143,10 @@ onMounted(async () => { 同步 + + + 搜索 + diff --git a/yunkes/src/router/index.ts b/yunkes/src/router/index.ts index 9697daa..e80ba15 100644 --- a/yunkes/src/router/index.ts +++ b/yunkes/src/router/index.ts @@ -51,6 +51,12 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/SyncStatus.vue'), meta: { title: '同步状态' }, }, + { + path: '/search', + name: 'search', + component: () => import('@/views/SearchView.vue'), + meta: { title: '搜索' }, + }, { path: '/chat/:channel/:ownerId/:talkerId', name: 'chat', diff --git a/yunkes/src/views/SearchView.vue b/yunkes/src/views/SearchView.vue new file mode 100644 index 0000000..e3ca79a --- /dev/null +++ b/yunkes/src/views/SearchView.vue @@ -0,0 +1,767 @@ + + + + +