diff --git a/app/admin/repositories/queries.py b/app/admin/repositories/queries.py index ab306d1..d7293f0 100644 --- a/app/admin/repositories/queries.py +++ b/app/admin/repositories/queries.py @@ -44,9 +44,20 @@ def list_users( phone: str | None = None, register_channel: str | None = None, status: str | None = None, + nickname: str | None = None, + created_from: datetime | None = None, + created_to: datetime | None = None, + last_login_from: datetime | None = None, + last_login_to: datetime | None = None, + sort_by: str = "id", + sort_order: str = "desc", limit: int = 20, cursor: int | None = None, ) -> tuple[list[User], int | None]: + """用户列表(admin 全量)。支持手机号前缀 / 渠道 / 状态 / 昵称模糊 / 注册·最近登录时间范围筛选, + 按 id·注册时间·最近登录排序。**offset 分页**(cursor=offset):任意列排序下游标语义统一, + 代价是翻页期间数据变动可能错位一条——admin 低频场景可接受(同 [list_all_withdraw_orders])。 + 日期入参统一转 UTC naive 比较(User 时间均为 UTC naive,见 _as_utc_naive)。""" stmt = select(User) if phone: stmt = stmt.where(User.phone.like(f"{phone}%")) # 前缀匹配 @@ -54,7 +65,33 @@ def list_users( stmt = stmt.where(User.register_channel == register_channel) if status: stmt = stmt.where(User.status == status) - return cursor_paginate(db, stmt, User.id, limit=limit, cursor=cursor) + if nickname and nickname.strip(): + stmt = stmt.where(User.nickname.ilike(f"%{nickname.strip()}%")) + if created_from is not None: + stmt = stmt.where(User.created_at >= _as_utc_naive(created_from)) + if created_to is not None: + stmt = stmt.where(User.created_at <= _as_utc_naive(created_to)) + if last_login_from is not None: + stmt = stmt.where(User.last_login_at >= _as_utc_naive(last_login_from)) + if last_login_to is not None: + stmt = stmt.where(User.last_login_at <= _as_utc_naive(last_login_to)) + + sort_cols = { + "id": User.id, + "created_at": User.created_at, + "last_login_at": User.last_login_at, + } + sort_col = sort_cols.get(sort_by, User.id) + order_fn = asc if sort_order == "asc" else desc + id_order = asc(User.id) if sort_order == "asc" else desc(User.id) + stmt = stmt.order_by(order_fn(sort_col), id_order) + + offset = max(cursor or 0, 0) + rows = list(db.execute(stmt.offset(offset).limit(limit + 1)).scalars().all()) + has_more = len(rows) > limit + items = rows[:limit] + next_cursor = offset + limit if has_more else None + return items, next_cursor def list_onboarding_devices(db: Session, *, limit: int = 500) -> list[dict]: diff --git a/app/admin/routers/users.py b/app/admin/routers/users.py index f931d7b..e992e20 100644 --- a/app/admin/routers/users.py +++ b/app/admin/routers/users.py @@ -1,6 +1,7 @@ """admin 用户管理:列表 + 360 详情(读)+ 封禁/解封 + 手动调金币(写,带审计)。""" from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -28,18 +29,27 @@ router = APIRouter( ) -@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+分页)") +@router.get("", response_model=CursorPage[AdminUserListItem], summary="用户列表(筛选+排序+分页)") def list_users( db: AdminDb, phone: Annotated[str | None, Query()] = None, register_channel: Annotated[str | None, Query()] = None, status: Annotated[str | None, Query()] = None, + nickname: Annotated[str | None, Query(max_length=100)] = None, + created_from: Annotated[datetime | None, Query()] = None, + created_to: Annotated[datetime | None, Query()] = None, + last_login_from: Annotated[datetime | None, Query()] = None, + last_login_to: Annotated[datetime | None, Query()] = None, + sort_by: Annotated[str, Query(pattern="^(id|created_at|last_login_at)$")] = "id", + sort_order: Annotated[str, Query(pattern="^(asc|desc)$")] = "desc", limit: Annotated[int, Query(ge=1, le=100)] = 20, cursor: Annotated[int | None, Query()] = None, ) -> CursorPage[AdminUserListItem]: items, next_cursor = queries.list_users( db, phone=phone, register_channel=register_channel, status=status, - limit=limit, cursor=cursor, + nickname=nickname, created_from=created_from, created_to=created_to, + last_login_from=last_login_from, last_login_to=last_login_to, + sort_by=sort_by, sort_order=sort_order, limit=limit, cursor=cursor, ) return CursorPage( items=[AdminUserListItem.model_validate(u) for u in items], diff --git a/docs/api/admin-users-list.md b/docs/api/admin-users-list.md index 287e057..f4ec2ba 100644 --- a/docs/api/admin-users-list.md +++ b/docs/api/admin-users-list.md @@ -1,4 +1,4 @@ -# GET /admin/api/users — 用户列表(筛选+分页) +# GET /admin/api/users — 用户列表(筛选+排序+分页) > 所属:Admin·用户 组(前缀 `/admin/api/users`) | 鉴权:Bearer admin_token(角色:任意已登录管理员,无 require_role) | [← 返回 API 索引](./README.md) @@ -8,8 +8,13 @@ | `phone` | string | ❌ | null | 手机号**前缀**匹配(`phone LIKE '<值>%'`) | | `register_channel` | string | ❌ | null | 注册渠道,精确匹配 | | `status` | string | ❌ | null | 用户状态,精确匹配:`active` / `disabled` / `deleted` | +| `nickname` | string | ❌ | null | 昵称**模糊**匹配(`ILIKE '%<值>%'`) | +| `created_from` / `created_to` | datetime | ❌ | null | 注册时间范围(ISO 8601;按 UTC 比较) | +| `last_login_from` / `last_login_to` | datetime | ❌ | null | 最近登录时间范围(ISO 8601;按 UTC 比较) | +| `sort_by` | string | ❌ | `id` | 排序列:`id` / `created_at` / `last_login_at` | +| `sort_order` | string | ❌ | `desc` | `asc` / `desc` | | `limit` | int | ❌ | 20 | 1–100 | -| `cursor` | int | ❌ | null | 上一页 next_cursor(按 user id 倒序,查 `id < cursor`) | +| `cursor` | int | ❌ | null | 上一页 next_cursor(**offset 偏移量**) | ## 出参 响应 `200`:`{ items: AdminUserListItem[], next_cursor: int|null }`(`next_cursor=null` 表示末页) @@ -28,10 +33,11 @@ ## 错误码 - `401` 未带/无效/过期 admin token、管理员被禁用(头带 `WWW-Authenticate: Bearer`) -- `422` `limit` 超出 1–100 范围 / 字段类型不合法 +- `422` `limit` 超出 1–100 范围 / `sort_by`·`sort_order` 不在白名单 / 日期格式不合法 / 字段类型不合法 ## 说明 -- 游标分页约定:结果按 user `id` 倒序;`cursor` 传上一页返回的 `next_cursor`;`next_cursor=null` 即末页。 -- `phone` 为前缀匹配(`LIKE '<值>%'`),`register_channel` / `status` 为精确匹配;三者可叠加。 +- **分页为 offset 偏移式**(为支持任意列排序):`cursor` 传上一页返回的 `next_cursor`(实为 offset);`next_cursor=null` 即末页。代价:翻页期间数据变动可能错位一条,admin 低频场景可接受(同提现列表)。 +- 排序:`sort_by` 限 `id`/`created_at`/`last_login_at`,非法值回落 `id`;同值再按 `id` 同向兜底,次序稳定。 +- 筛选:`phone` 前缀、`nickname` 模糊(`ILIKE`)、`register_channel`/`status` 精确、`created_*`/`last_login_*` 时间范围;均可叠加。 - 关联用户表 [user](../database/user.md)。 - 历史明细(金币流水、提现、比价、反馈等)不在本列表,走各自带 `user_id` 过滤的分页接口。 diff --git a/tests/test_admin_read.py b/tests/test_admin_read.py index c6c3faf..90005ad 100644 --- a/tests/test_admin_read.py +++ b/tests/test_admin_read.py @@ -91,6 +91,80 @@ def test_user_filter_by_status(admin_client: TestClient, admin_token: str) -> No assert all(u["status"] == "active" for u in r.json()["items"]) +def test_user_list_sort_filter_range(admin_client: TestClient, admin_token: str) -> None: + """用户列表:渠道/昵称筛选 + id 升降序 + 时间范围 + 非法 sort_by 422。""" + db = SessionLocal() + try: + u1 = user_repo.upsert_user_for_login(db, phone="13811110001", register_channel="sms") + u1.nickname = "排序测试甲" + u2 = user_repo.upsert_user_for_login(db, phone="13811110002", register_channel="wechat") + u2.nickname = "排序测试乙" + db.commit() + id1, id2 = u1.id, u2.id + finally: + db.close() + + # 渠道精确筛选 + r = admin_client.get( + "/admin/api/users", params={"register_channel": "wechat"}, headers=_auth(admin_token) + ) + assert r.status_code == 200, r.text + assert all(u["register_channel"] == "wechat" for u in r.json()["items"]) + + # 昵称模糊筛选 + r = admin_client.get( + "/admin/api/users", params={"nickname": "排序测试", "limit": 100}, headers=_auth(admin_token) + ) + ids = {u["id"] for u in r.json()["items"]} + assert id1 in ids and id2 in ids + + # id 升序 / 降序 + asc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "asc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert asc_ids == sorted(asc_ids) + desc_ids = [ + u["id"] + for u in admin_client.get( + "/admin/api/users", + params={"sort_by": "id", "sort_order": "desc", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ] + assert desc_ids == sorted(desc_ids, reverse=True) + + # 时间范围:2000 年之前无人;之后有人(验证范围条件确实生效) + assert ( + admin_client.get( + "/admin/api/users", params={"created_to": "2000-01-01T00:00:00Z"}, + headers=_auth(admin_token), + ).json()["items"] + == [] + ) + assert ( + len( + admin_client.get( + "/admin/api/users", params={"created_from": "2000-01-01T00:00:00Z", "limit": 100}, + headers=_auth(admin_token), + ).json()["items"] + ) + >= 1 + ) + + # 非法 sort_by → 422 + assert ( + admin_client.get( + "/admin/api/users", params={"sort_by": "phone"}, headers=_auth(admin_token) + ).status_code + == 422 + ) + + def test_wallet_and_withdraw_lists(admin_client: TestClient, admin_token: str) -> None: uid = _seed_user_with_data("13800000004") r = admin_client.get(