diff --git a/tests/test_account_overview.py b/tests/test_account_overview.py index 765192f..0283da1 100644 --- a/tests/test_account_overview.py +++ b/tests/test_account_overview.py @@ -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 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 9cbb4f6..c6d33ad 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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`), diff --git a/web/backend/services/account_value.py b/web/backend/services/account_value.py index c69dcaa..53ef4ce 100644 --- a/web/backend/services/account_value.py +++ b/web/backend/services/account_value.py @@ -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) diff --git a/web/frontend/src/api/accounts.ts b/web/frontend/src/api/accounts.ts index 5ccfcbd..1fb1096 100644 --- a/web/frontend/src/api/accounts.ts +++ b/web/frontend/src/api/accounts.ts @@ -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>('/accounts', { params }), diff --git a/web/frontend/src/pages/AccountsPage.tsx b/web/frontend/src/pages/AccountsPage.tsx index 7b6ecb7..e6b5187 100644 --- a/web/frontend/src/pages/AccountsPage.tsx +++ b/web/frontend/src/pages/AccountsPage.tsx @@ -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([ + 'username', + 'status', + 'bind', + 'points', + 'high_value', + 'exchange', + 'tag', +]); + const STATUS_TONES: Record = { 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('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['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) => ( @@ -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,