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
|
||||
Reference in New Issue
Block a user