feat(accounts): 账号列表按列排序,默认把还能换套装的排到前面
分页在服务端,点表头按全库排,不能只排当前这一页。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1792,6 +1792,143 @@ class TestHighValueAccountFilters:
|
||||
assert "acc_broken" in names
|
||||
assert "acc_both_owned" not in names
|
||||
|
||||
def test_list_sorts_by_suit_availability(self):
|
||||
# 升序:两套都能换 → 只剩一套 → 都换过 → 没查过。同档按 id。
|
||||
names = [it.username for it in self._query(sort="high_value", order="asc")]
|
||||
assert names == [
|
||||
"acc_crown",
|
||||
"acc_broken",
|
||||
"acc_bairimeng_owned",
|
||||
"acc_mengtu_owned",
|
||||
"acc_both_owned",
|
||||
"acc_unqueried",
|
||||
]
|
||||
names = [it.username for it in self._query(sort="high_value", order="desc")]
|
||||
assert names == [
|
||||
"acc_unqueried",
|
||||
"acc_both_owned",
|
||||
"acc_mengtu_owned",
|
||||
"acc_bairimeng_owned",
|
||||
"acc_broken",
|
||||
"acc_crown",
|
||||
]
|
||||
|
||||
def test_list_sorts_by_official_exchange_count(self):
|
||||
from web.backend.services.account_events_service import (
|
||||
CATEGORY_BUSINESS,
|
||||
EVENT_EXCHANGE,
|
||||
OFFICIAL_SYNC_SOURCE,
|
||||
emit,
|
||||
)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
emit(
|
||||
self.session,
|
||||
account_id=self.acc_both_owned.id,
|
||||
event_type=EVENT_EXCHANGE,
|
||||
title="兑换 A",
|
||||
category=CATEGORY_BUSINESS,
|
||||
occurred_at=now,
|
||||
source=OFFICIAL_SYNC_SOURCE,
|
||||
)
|
||||
emit(
|
||||
self.session,
|
||||
account_id=self.acc_both_owned.id,
|
||||
event_type=EVENT_EXCHANGE,
|
||||
title="兑换 B",
|
||||
category=CATEGORY_BUSINESS,
|
||||
occurred_at=now,
|
||||
source=OFFICIAL_SYNC_SOURCE,
|
||||
)
|
||||
emit(
|
||||
self.session,
|
||||
account_id=self.acc_crown.id,
|
||||
event_type=EVENT_EXCHANGE,
|
||||
title="兑换 C",
|
||||
category=CATEGORY_BUSINESS,
|
||||
occurred_at=now,
|
||||
source=OFFICIAL_SYNC_SOURCE,
|
||||
)
|
||||
# 当场记的不算进列表「兑换记录」,排序也不能把它算进去
|
||||
emit(
|
||||
self.session,
|
||||
account_id=self.acc_unqueried.id,
|
||||
event_type=EVENT_EXCHANGE,
|
||||
title="兑换 当场",
|
||||
category=CATEGORY_BUSINESS,
|
||||
occurred_at=now,
|
||||
source="manual",
|
||||
)
|
||||
self.session.commit()
|
||||
|
||||
names = [it.username for it in self._query(sort="exchange", order="asc")]
|
||||
assert names == [
|
||||
"acc_bairimeng_owned",
|
||||
"acc_mengtu_owned",
|
||||
"acc_unqueried",
|
||||
"acc_broken",
|
||||
"acc_crown",
|
||||
"acc_both_owned",
|
||||
]
|
||||
names = [it.username for it in self._query(sort="exchange", order="desc")]
|
||||
assert names == [
|
||||
"acc_both_owned",
|
||||
"acc_crown",
|
||||
"acc_bairimeng_owned",
|
||||
"acc_mengtu_owned",
|
||||
"acc_unqueried",
|
||||
"acc_broken",
|
||||
]
|
||||
|
||||
def test_list_sorts_points_status_bind_and_tag(self):
|
||||
self.acc_crown.status = "active"
|
||||
self.acc_crown.role_bind_status = "bound"
|
||||
self.acc_crown.tag = "b"
|
||||
self.acc_broken.status = "invalid"
|
||||
self.acc_broken.role_bind_status = "unbound"
|
||||
self.acc_broken.tag = "a"
|
||||
self.acc_unqueried.status = "imported"
|
||||
# 积分相同,鱼翅多的在积分降序里更靠前
|
||||
self.acc_bairimeng_owned.points = 50
|
||||
self.acc_bairimeng_owned.gold_balance = 1
|
||||
self.session.commit()
|
||||
|
||||
assert [it.username for it in self._query(sort="points", order="desc")] == [
|
||||
"acc_broken",
|
||||
"acc_crown",
|
||||
"acc_mengtu_owned",
|
||||
"acc_bairimeng_owned",
|
||||
"acc_both_owned",
|
||||
"acc_unqueried",
|
||||
]
|
||||
assert [it.username for it in self._query(sort="points", order="asc")] == [
|
||||
"acc_both_owned",
|
||||
"acc_bairimeng_owned",
|
||||
"acc_mengtu_owned",
|
||||
"acc_crown",
|
||||
"acc_broken",
|
||||
"acc_unqueried",
|
||||
]
|
||||
assert [it.username for it in self._query(sort="status", order="asc")][:3] == [
|
||||
"acc_crown",
|
||||
"acc_broken",
|
||||
"acc_unqueried",
|
||||
]
|
||||
assert [it.username for it in self._query(sort="bind", order="asc")][:2] == [
|
||||
"acc_crown",
|
||||
"acc_broken",
|
||||
]
|
||||
assert [it.username for it in self._query(sort="username", order="asc")] == [
|
||||
"acc_bairimeng_owned",
|
||||
"acc_both_owned",
|
||||
"acc_broken",
|
||||
"acc_crown",
|
||||
"acc_mengtu_owned",
|
||||
"acc_unqueried",
|
||||
]
|
||||
tagged = [it.username for it in self._query(sort="tag", order="asc")]
|
||||
assert tagged[-2:] == ["acc_broken", "acc_crown"]
|
||||
|
||||
def test_summary_returns_high_value_breakdown(self):
|
||||
from web.backend.routers.accounts import accounts_summary
|
||||
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -35,6 +35,8 @@ export const accountApi = {
|
||||
elite_opened?: boolean;
|
||||
esports_opened?: boolean;
|
||||
role_bind_status?: string;
|
||||
sort?: 'username' | 'status' | 'bind' | 'points' | 'high_value' | 'exchange' | 'tag';
|
||||
order?: 'asc' | 'desc';
|
||||
},
|
||||
) =>
|
||||
api.get<PaginatedResponse<AccountItem>, PaginatedResponse<AccountItem>>('/accounts', { params }),
|
||||
|
||||
@@ -52,6 +52,25 @@ const { Text } = Typography;
|
||||
* 取值域见后端 `services/account_lifecycle.py`(6 个状态,不含 relogin ——
|
||||
* 「退避中」是 invalid 的调度细节,不单独成状态)。
|
||||
*/
|
||||
type AccountSortKey =
|
||||
| 'username'
|
||||
| 'status'
|
||||
| 'bind'
|
||||
| 'points'
|
||||
| 'high_value'
|
||||
| 'exchange'
|
||||
| 'tag';
|
||||
|
||||
const ACCOUNT_SORT_KEYS = new Set<string>([
|
||||
'username',
|
||||
'status',
|
||||
'bind',
|
||||
'points',
|
||||
'high_value',
|
||||
'exchange',
|
||||
'tag',
|
||||
]);
|
||||
|
||||
const STATUS_TONES: Record<string, string> = {
|
||||
imported: 'default',
|
||||
active: 'green',
|
||||
@@ -222,6 +241,9 @@ export default function AccountsPage() {
|
||||
return v ? Number(v) || 20 : 20;
|
||||
});
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
// 打开列表就按套装价值排:两个都还能换的在前,没查过的在后。点表头可换方向。
|
||||
const [sortKey, setSortKey] = useState<AccountSortKey | ''>('high_value');
|
||||
const [sortOrder, setSortOrder] = useState<'ascend' | 'descend' | null>('ascend');
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canViewFull = can('account:view_full');
|
||||
@@ -251,6 +273,10 @@ export default function AccountsPage() {
|
||||
if (eliteOpened !== undefined) params.elite_opened = eliteOpened;
|
||||
if (esportsOpened !== undefined) params.esports_opened = esportsOpened;
|
||||
if (roleBindStatus) params.role_bind_status = roleBindStatus;
|
||||
if (sortKey && sortOrder) {
|
||||
params.sort = sortKey;
|
||||
params.order = sortOrder === 'descend' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
const data = await accountApi.listPaged(params);
|
||||
setAccounts(data.items);
|
||||
@@ -275,6 +301,8 @@ export default function AccountsPage() {
|
||||
maxPoints,
|
||||
minGold,
|
||||
maxGold,
|
||||
sortKey,
|
||||
sortOrder,
|
||||
eliteOpened,
|
||||
esportsOpened,
|
||||
roleBindStatus,
|
||||
@@ -503,11 +531,19 @@ export default function AccountsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const sortProps = (key: AccountSortKey, tip?: string) => ({
|
||||
key,
|
||||
sorter: true,
|
||||
sortOrder: sortKey === key ? sortOrder : null,
|
||||
showSorterTooltip: tip ? { title: tip } : true,
|
||||
});
|
||||
|
||||
const columns: TableProps<AccountItem>['columns'] = [
|
||||
{
|
||||
// ID 不再单独占一列(客服不看),降到用户名下面做小字 —— 报障时还能念出来。
|
||||
title: '账号',
|
||||
dataIndex: 'username',
|
||||
...sortProps('username'),
|
||||
width: 200,
|
||||
// 用户名长短不一:定宽 + 省略号,别让它把整个表格撑开
|
||||
ellipsis: true,
|
||||
@@ -532,6 +568,7 @@ export default function AccountsPage() {
|
||||
// (检测时间、以及 `error` 这种「状态正常但检测没查明白」的情况)。
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
...sortProps('status', '正常在前'),
|
||||
width: 180,
|
||||
render: (status: string, record: AccountItem) => {
|
||||
const tone = STATUS_TONES[status] ?? 'default';
|
||||
@@ -564,6 +601,7 @@ export default function AccountsPage() {
|
||||
// 三角色绑定:三个作用域各一列,合成一格紧凑展示
|
||||
title: '绑定',
|
||||
dataIndex: 'role_bind_status',
|
||||
...sortProps('bind', '已绑定在前,先看精英,再看电竞和小店'),
|
||||
width: 150,
|
||||
render: (_, record: AccountItem) => {
|
||||
// 三个作用域全都 unknown 时不再显示「精? 电? 店?」(满屏问号只是噪声),
|
||||
@@ -602,6 +640,7 @@ export default function AccountsPage() {
|
||||
{
|
||||
title: '积分 / 鱼翅',
|
||||
dataIndex: 'points',
|
||||
...sortProps('points', '按积分排,没查过的在后;积分相同再看鱼翅'),
|
||||
width: 120,
|
||||
render: (_, record: AccountItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
@@ -619,6 +658,7 @@ export default function AccountsPage() {
|
||||
// 数据来自一键查询拉的**斗鱼官方兑换记录**。
|
||||
title: '高价值套装',
|
||||
dataIndex: 'high_value_owned',
|
||||
...sortProps('high_value', '两个都可兑换在前,未查询在后'),
|
||||
width: 170,
|
||||
render: (owned: string[] | undefined, record: AccountItem) => {
|
||||
const used = owned ?? [];
|
||||
@@ -655,6 +695,7 @@ export default function AccountsPage() {
|
||||
// 点这一格弹窗看全部(按作用域分组),不用进详情页。
|
||||
title: '兑换记录',
|
||||
dataIndex: 'exchange_count',
|
||||
...sortProps('exchange', '未兑换在前'),
|
||||
width: 170,
|
||||
render: (_, record: AccountItem) => {
|
||||
if (!record.exchange_count) {
|
||||
@@ -689,6 +730,7 @@ export default function AccountsPage() {
|
||||
{
|
||||
title: '标签',
|
||||
dataIndex: 'tag',
|
||||
...sortProps('tag'),
|
||||
width: 190,
|
||||
render: (tag: string, record: AccountItem) => {
|
||||
// 有权限时直接就地改(Select 输入新标签即保存);无权限则只读展示。
|
||||
@@ -1354,6 +1396,20 @@ export default function AccountsPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
onChange={(_pagination, _filters, sorter, extra) => {
|
||||
if (extra.action !== 'sort') return;
|
||||
const current = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
const nextOrder = current?.order ?? null;
|
||||
const nextKey = nextOrder ? String(current?.columnKey ?? '') : '';
|
||||
if (ACCOUNT_SORT_KEYS.has(nextKey)) {
|
||||
setSortKey(nextKey as AccountSortKey);
|
||||
setSortOrder(nextOrder);
|
||||
} else {
|
||||
setSortKey('');
|
||||
setSortOrder(null);
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize,
|
||||
|
||||
Reference in New Issue
Block a user