From fef176bd90bcd799293b323dbfbb3711fd87de37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Wed, 5 Aug 2026 18:48:59 +0800 Subject: [PATCH] feat(api): customer filters user_id / add_time_since / last_chat_since Co-Authored-By: Claude --- yunke-server/app/api/routers/customer.py | 16 ++++- yunke-server/tests/test_api_customer.py | 87 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/yunke-server/app/api/routers/customer.py b/yunke-server/app/api/routers/customer.py index 8db9a6f..37a6725 100644 --- a/yunke-server/app/api/routers/customer.py +++ b/yunke-server/app/api/routers/customer.py @@ -1,9 +1,10 @@ """GET /api/customers — paginated, filterable customer list. Read-only query over the mirrored ``customer`` table. Supports filtering by -``channel``, ``owner_account_id`` (direct columns) and ``department_id`` -(resolved through ``customer.user_id`` -> ``staff.dept_id``). Results are -ordered by ``last_chat_time desc`` (nulls last) then ``friend_id`` so the +``channel``, ``owner_account_id``, ``user_id``, ``add_time_since`` and +``last_chat_since`` (direct columns) and ``department_id`` (resolved through +``customer.user_id`` -> ``staff.dept_id``). Results are ordered by +``last_chat_time desc`` (nulls last) then ``friend_id`` so the most-recently-active contacts surface first. """ @@ -34,6 +35,9 @@ async def list_customers( channel: Annotated[str | None, Query()] = None, department_id: Annotated[str | None, Query()] = None, owner_account_id: Annotated[str | None, Query()] = None, + user_id: Annotated[str | None, Query()] = None, + add_time_since: Annotated[int | None, Query(ge=0)] = None, + last_chat_since: Annotated[int | None, Query(ge=0)] = None, ) -> Page[CustomerOut]: """List customers with optional filters and pagination.""" conditions = [] @@ -47,6 +51,12 @@ async def list_customers( Staff.dept_id == department_id ) conditions.append(Customer.user_id.in_(dept_user_ids)) + if user_id is not None: + conditions.append(Customer.user_id == user_id) + if add_time_since is not None: + conditions.append(Customer.add_time >= add_time_since) + if last_chat_since is not None: + conditions.append(Customer.last_chat_time >= last_chat_since) total_stmt = sa.select(sa.func.count()).select_from(Customer) if conditions: diff --git a/yunke-server/tests/test_api_customer.py b/yunke-server/tests/test_api_customer.py index dda7be3..32308ea 100644 --- a/yunke-server/tests/test_api_customer.py +++ b/yunke-server/tests/test_api_customer.py @@ -255,3 +255,90 @@ async def test_filters_compose_channel_and_owner(client: AsyncClient): body = r.json() assert body["total"] == 2 assert {it["friend_id"] for it in body["items"]} == {"wx_a", "wx_b"} + + +@pytest.fixture +async def filter_client(): + """Independent in-memory client seeded for the new add_time/last_chat filters. + + Deliberately separate from the shared ``client`` fixture: that one seeds 3 + rows and four existing tests assert ``total == 3`` against it, so adding + rows there would break them. This fixture mirrors the engine + override + pattern of ``client`` but seeds rows that specifically exercise the + ``user_id`` / ``add_time_since`` / ``last_chat_since`` filters. + """ + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + SessionLocal = async_sessionmaker(engine, expire_on_commit=False) + async with SessionLocal() as session: + session.add_all( + [ + Customer( + friend_id="u1_a", + channel="wechat", + user_id="U1", + add_time=1000, + last_chat_time=500, + version="v1", + ), + Customer( + friend_id="u1_b", + channel="wechat", + user_id="U1", + add_time=2000, + last_chat_time=2000, + version="v1", + ), + Customer( + friend_id="u2_a", + channel="wechat", + user_id="U2", + add_time=3000, + last_chat_time=3000, + version="v1", + ), + ] + ) + await session.commit() + + async def _override_session(): + async with SessionLocal() as s: + yield s + + app.dependency_overrides[deps.get_session] = _override_session + try: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + yield ac + finally: + app.dependency_overrides.clear() + await engine.dispose() + + +async def test_filter_by_user_id(filter_client: AsyncClient): + """``?user_id=U1`` -> only customers owned by U1.""" + r = await filter_client.get("/api/customers", params={"user_id": "U1"}) + assert r.status_code == 200 + assert {c["friend_id"] for c in r.json()["items"]} == {"u1_a", "u1_b"} + + +async def test_filter_by_add_time_since(filter_client: AsyncClient): + """``?add_time_since=1500`` -> rows with add_time >= 1500 (u1_b, u2_a).""" + r = await filter_client.get("/api/customers", params={"add_time_since": 1500}) + assert r.status_code == 200 + ids = [c["friend_id"] for c in r.json()["items"]] + assert "u1_a" not in ids and "u1_b" in ids and "u2_a" in ids + + +async def test_filter_by_last_chat_since(filter_client: AsyncClient): + """``?last_chat_since=1500`` -> rows with last_chat_time >= 1500 (u1_a out).""" + r = await filter_client.get("/api/customers", params={"last_chat_since": 1500}) + assert r.status_code == 200 + assert "u1_a" not in [c["friend_id"] for c in r.json()["items"]]