feat(accounts): 账号列表按列排序,默认把还能换套装的排到前面

分页在服务端,点表头按全库排,不能只排当前这一页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yml2213
2026-10-05 18:50:43 +08:00
co-authored by Cursor
parent ab4c0a5422
commit f2157f67f7
5 changed files with 363 additions and 5 deletions
+138 -5
View File
@@ -3,7 +3,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy import and_, case, func, or_, select, text
from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db
@@ -45,8 +45,13 @@ from ..services.account_events_service import (
emit_bulk,
)
from ..services.account_lifecycle import (
STATUS_ACTIVE,
STATUS_ARCHIVED,
STATUS_DISABLED,
STATUS_IMPORTED,
STATUS_INVALID,
STATUS_LABELS,
STATUS_MANUAL,
set_manual_state,
)
from ..services.account_service import (
@@ -58,6 +63,7 @@ from ..services.account_value import (
SUIT_MENGTU,
is_high_value_expr,
suit_owned_expr,
suit_sort_rank_expr,
)
from ..services.audit_service import record_account_action, record_audit
from ..services.douyu_service import cookie_holding_filter
@@ -253,6 +259,101 @@ def _selected_account_ids(
return [account_id for account_id in ids if account_id in allowed]
def _official_exchange_count_subquery():
"""每个账号的官方兑换次数。只在按「兑换记录」排序时才接上,平时列表不扫这张表。"""
return (
select(
AccountEvent.account_id.label("account_id"),
func.count(AccountEvent.id).label("exchange_count"),
)
.where(
AccountEvent.event_type == EVENT_EXCHANGE,
AccountEvent.source == OFFICIAL_SYNC_SOURCE,
)
.group_by(AccountEvent.account_id)
.subquery()
)
def _sort_dir(column, descending: bool):
return column.desc() if descending else column.asc()
def _nulls_last(column):
"""没查过的空值始终沉底,不跟 0 混在一起。"""
return column.is_(None).asc()
def _bind_rank(column):
"""已绑定 → 待确认 → 未绑定 → 未知。和列表上的颜色一致,不按字母排。"""
return case(
(column == "bound", 0),
(column == "pending", 1),
(column == "unbound", 2),
else_=3,
)
def _status_rank():
"""正常 → CK 失效 → 待人工 → 未登录 → 已停用 → 已归档。"""
return case(
(Account.status == STATUS_ACTIVE, 0),
(Account.status == STATUS_INVALID, 1),
(Account.status == STATUS_MANUAL, 2),
(Account.status == STATUS_IMPORTED, 3),
(Account.status == STATUS_DISABLED, 4),
(Account.status == STATUS_ARCHIVED, 5),
else_=6,
)
def _account_list_order(sort, order):
"""列表排序。没指定时仍按 id,别的调用方不受影响。
返回 (是否自定义排序, order_by 子句, 兑换次数子查询或 None)。
自定义排序先只取出 id,再按这个顺序装账号,避免 filesort 把 cookie 整列拖进来。
"""
sort_key = _query_str(sort)
descending = _query_str(order).lower() == "desc"
tie = Account.id.desc() if descending else Account.id.asc()
if sort_key == "username":
return True, (_sort_dir(Account.username, descending), tie), None
if sort_key == "status":
return True, (_sort_dir(_status_rank(), descending), tie), None
if sort_key == "bind":
return (
True,
(
_sort_dir(_bind_rank(Account.role_bind_status), descending),
_sort_dir(_bind_rank(Account.esports_bind_status), descending),
_sort_dir(_bind_rank(Account.xpd_bind_status), descending),
tie,
),
None,
)
if sort_key == "points":
return (
True,
(
_nulls_last(Account.points),
_sort_dir(Account.points, descending),
_nulls_last(Account.gold_balance),
_sort_dir(Account.gold_balance, descending),
tie,
),
None,
)
if sort_key == "high_value":
return True, (_sort_dir(suit_sort_rank_expr(), descending), tie), None
if sort_key == "tag":
return True, (_sort_dir(Account.tag, descending), tie), None
if sort_key == "exchange":
counts = _official_exchange_count_subquery()
count_col = func.coalesce(counts.c.exchange_count, 0)
return True, (_sort_dir(count_col, descending), Account.id.asc()), counts
return False, (Account.id.asc(),), None
def _require_account_tag_permission(current: User) -> None:
"""标签权限兼容历史上使用账号导入权限的运营人员。"""
if not (
@@ -288,6 +389,11 @@ def list_accounts(
role_bind_status: str = Query(
"", description="精英角色绑定状态: unknown/unbound/pending/bound"
),
sort: str = Query(
"",
description="排序:username/status/bind/points/high_value/exchange/tag;空=按 id",
),
order: str = Query("asc", description="asc / desc"),
page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200),
include_sensitive: bool = Query(False),
@@ -319,9 +425,12 @@ def list_accounts(
total = None
if page is not None:
total = query.order_by(None).with_entities(func.count(Account.id)).scalar() or 0
query = query.order_by(Account.id)
if page is not None:
query = query.offset((page - 1) * page_size).limit(page_size)
custom_sort, order_by, exchange_counts = _account_list_order(sort, order)
ordered = query
if exchange_counts is not None:
ordered = ordered.outerjoin(
exchange_counts, exchange_counts.c.account_id == Account.id
)
can_include_sensitive = include_sensitive and user_has_permission(
current, "account:view_full"
@@ -335,7 +444,31 @@ def list_accounts(
defer(Account.email_password),
]
)
accounts = query.options(*options).all()
if custom_sort:
# 先按排序键取出这一页的 id。套装档位、兑换次数都不是索引列,
# 全表 filesort 时不能把 cookie 一起选出来。
id_query = ordered.with_entities(Account.id).order_by(*order_by)
if page is not None:
id_query = id_query.offset((page - 1) * page_size).limit(page_size)
page_ids = [account_id for (account_id,) in id_query.all()]
if page_ids:
loaded = {
acc.id: acc
for acc in db.query(Account)
.filter(Account.id.in_(page_ids))
.options(*options)
.all()
}
accounts = [
loaded[account_id] for account_id in page_ids if account_id in loaded
]
else:
accounts = []
else:
ordered = ordered.order_by(*order_by)
if page is not None:
ordered = ordered.offset((page - 1) * page_size).limit(page_size)
accounts = ordered.options(*options).all()
# 「兑换记录」列:**每页只跑一条聚合查询**,不是每行一条(避免 N+1)。
# 数据源是账号自己的事件流(`account_events` 的 `exchange`),
+30
View File
@@ -69,6 +69,36 @@ def is_high_value_expr():
return value_tier_expr() <= HIGH_VALUE_TIER_MAX
def suit_sort_rank_expr():
"""列表「高价值套装」列的排序,和格子上看到的一致。数字越小越值钱。
0 查过、两套都还能换
1 查过、只剩一套
2 查过、两套都换过
3 还没查过(未知,不和「还能换」混在一起)
不掺积分、鱼翅。那是巡检分档的事,点这一列的人是在排套装。
"""
queried = Account.overview_queried_at.isnot(None)
owns_bairimeng = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG)
owns_mengtu = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU)
both_available = and_(queried, ~owns_bairimeng, ~owns_mengtu)
one_available = and_(
queried,
or_(
and_(~owns_bairimeng, owns_mengtu),
and_(owns_bairimeng, ~owns_mengtu),
),
)
both_owned = and_(queried, owns_bairimeng, owns_mengtu)
return case(
(both_available, 0),
(one_available, 1),
(both_owned, 2),
else_=3,
)
def high_value_recheck_seconds(interval_seconds: int) -> int:
"""高价值账号多久重检一次。不会比作业配置的间隔更长。"""
interval = max(int(interval_seconds or 0), 60)