"""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