Files
yunke/yunke-server/app/api/routers/search.py
T
浅念 a92fa2927c 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>
2026-08-05 11:46:21 +08:00

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