a92fa2927c
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>
166 lines
5.6 KiB
Python
166 lines
5.6 KiB
Python
"""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
|