feat: full-text search (messages + customers)
Backend (yunke-server): - GET /api/search?q=&type=messages|customers|all&channel=&start=&end=&page=&page_size= LIKE '%q%' scan over message.text + customer friend_nickname/real_name/ phone/wechat_phone. type=all returns both sections; each is its own Page[T]. - type=messages orders by timestamp desc; type=customers orders by last_chat_time desc (nulls last). - q required: missing/empty/whitespace -> HTTP 400. - 14 new tests in tests/test_api_search.py (TDD red -> green). Frontend (yunkes): - New /search route + 搜索 nav item with Search icon. - SearchView.vue: search bar, two stacked sections (消息结果 / 客户结果), counts + independent pagination per section, keyword highlighting (escaped), click message -> /chat/:channel/:ownerId/:talkerId, click customer -> all-fields drawer (CustomerList pattern reuse). - search() wrapper in src/api/index.ts with SearchQuery/SearchPayload types. - Empty state: "输入关键词搜索消息和客户". Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user