feat(admin/users): 用户列表支持列排序 + 渠道/昵称/时间范围筛选
list_users 由 keyset 游标改为 offset 分页以支持任意列排序:
- 排序:sort_by ∈ {id, created_at, last_login_at} + sort_order(白名单,非法回落 id;同值 id 同向兜底)
- 筛选:新增 nickname 模糊、注册/最近登录时间范围(register_channel 本就支持,这次在 UI/文档补齐)
- 文档 admin-users-list.md 与 test_admin_read 同步(排序/渠道/昵称/时间范围/非法 sort_by 422)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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]:
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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` 过滤的分页接口。
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user