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],
|
||||
|
||||
Reference in New Issue
Block a user