feat(accounts): 增加高价值资产一键筛选与高级自定义筛选功能

1. 后端新增高价值套装状态筛选(双套均可换/可换白日梦/可换萌兔/已拥有/高价值待救/未查资料),兼容 MySQL 原生 JSON 与 SQLite unicode 转义存储;
2. 后端新增多维资产与状态区间筛选(积分范围、鱼翅范围、宝典开通、电竞开通、角色绑定状态),并支持与批量打标签/删除联动;
3. 统计接口 accounts_summary 新增 high_value 聚合数据,毫秒级返回各类高价值资产数量;
4. 前端账号管理页面新增「高价值一键筛选」胶囊区(带数量徽标与激活状态,即点即滤);
5. 前端新增可折叠「高级自定义筛选」面板,支持套装状态下拉、积分快捷区间(≥300、≥600)、鱼翅快捷区间(>0、≥6)、宝典与角色绑定状态多条件组合;
6. 补充 6 个高价值筛选与资产区间单元测试,全量用例通过。
This commit is contained in:
yml2213
2026-10-05 11:23:48 +08:00
parent 19f1cbceba
commit 512ea3db06
7 changed files with 967 additions and 18 deletions
+171 -1
View File
@@ -3,7 +3,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, or_, select, text
from sqlalchemy import String, and_, cast, func, or_, select, text
from sqlalchemy.orm import Session, defer, joinedload
from ..database import get_db
@@ -79,6 +79,17 @@ def _query_str(value) -> str:
return value.strip() if isinstance(value, str) else ""
def _high_value_contains_expr(column, text: str):
"""匹配 JSON 字段中是否包含特定商品名(同时兼容 MySQL 与 SQLite 字符存储)。"""
import json
escaped = json.dumps(text).strip('"')
return or_(
cast(column, String).like(f"%{text}%"),
cast(column, String).like(f"%{escaped}%"),
)
def _filter_accounts_query(
db: Session,
query,
@@ -91,6 +102,14 @@ def _filter_accounts_query(
status: str = "",
ck_status: str = "",
bind_status: str = "",
high_value_filter: str = "",
min_points: int | None = None,
max_points: int | None = None,
min_gold: int | None = None,
max_gold: int | None = None,
elite_opened: bool | None = None,
esports_opened: bool | None = None,
role_bind_status: str = "",
):
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
if has_cookie:
@@ -120,6 +139,70 @@ def _filter_accounts_query(
Account.xpd_bind_status == bind_value,
)
)
role_bind = _query_str(role_bind_status)
if role_bind:
query = query.filter(Account.role_bind_status == role_bind)
# 高价值套装筛选
hv = _query_str(high_value_filter)
if hv:
bairimeng_expr = _high_value_contains_expr(
Account.high_value_owned, "兔兔白日梦"
)
mengtu_expr = _high_value_contains_expr(Account.high_value_owned, "狂战萌兔")
if hv == "both_available":
query = query.filter(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
~mengtu_expr,
)
elif hv == "can_bairimeng":
query = query.filter(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
)
elif hv == "can_mengtu":
query = query.filter(
Account.overview_queried_at.isnot(None),
~mengtu_expr,
)
elif hv == "owned_bairimeng":
query = query.filter(bairimeng_expr)
elif hv == "owned_mengtu":
query = query.filter(mengtu_expr)
elif hv == "both_owned":
query = query.filter(bairimeng_expr, mengtu_expr)
elif hv == "unqueried":
query = query.filter(Account.overview_queried_at.is_(None))
elif hv == "high_value_broken":
query = query.filter(
or_(
and_(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
~mengtu_expr,
),
Account.points >= 300,
),
Account.ck_check_status.in_(["invalid", "error"]),
)
# 资产数值区间过滤
if isinstance(min_points, int):
query = query.filter(Account.points >= min_points)
if isinstance(max_points, int):
query = query.filter(Account.points <= max_points)
if isinstance(min_gold, int):
query = query.filter(Account.gold_balance >= min_gold)
if isinstance(max_gold, int):
query = query.filter(Account.gold_balance <= max_gold)
# 开通状态过滤
if isinstance(elite_opened, bool):
query = query.filter(Account.elite_opened.is_(elite_opened))
if isinstance(esports_opened, bool):
query = query.filter(Account.esports_opened.is_(esports_opened))
search_text = _query_str(search)
if search_text:
pattern = f"%{search_text}%"
@@ -147,6 +230,17 @@ def _selected_account_ids(
tag=req.tag,
has_cookie=req.has_cookie,
search=req.search,
status=req.status,
ck_status=req.ck_status,
bind_status=req.bind_status,
high_value_filter=req.high_value_filter,
min_points=req.min_points,
max_points=req.max_points,
min_gold=req.min_gold,
max_gold=req.max_gold,
elite_opened=req.elite_opened,
esports_opened=req.esports_opened,
role_bind_status=req.role_bind_status,
)
.with_entities(Account.id)
.order_by(None)
@@ -195,6 +289,19 @@ def list_accounts(
bind_status: str = Query(
"", description="按角色绑定筛选:unknown/unbound/pending/bound"
),
high_value_filter: str = Query(
"",
description="按高价值套装状态筛选:both_available/can_bairimeng/can_mengtu/owned_bairimeng/owned_mengtu/both_owned/unqueried/high_value_broken",
),
min_points: int | None = Query(None, description="最低积分"),
max_points: int | None = Query(None, description="最高积分"),
min_gold: int | None = Query(None, description="最低鱼翅"),
max_gold: int | None = Query(None, description="最高鱼翅"),
elite_opened: bool | None = Query(None, description="是否开通精英宝典"),
esports_opened: bool | None = Query(None, description="是否开通电竞手册"),
role_bind_status: str = Query(
"", description="精英角色绑定状态: unknown/unbound/pending/bound"
),
page: int | None = Query(None, ge=1),
page_size: int = Query(20, ge=1, le=200),
include_sensitive: bool = Query(False),
@@ -213,6 +320,14 @@ def list_accounts(
status=status,
ck_status=ck_status,
bind_status=bind_status,
high_value_filter=high_value_filter,
min_points=min_points,
max_points=max_points,
min_gold=min_gold,
max_gold=max_gold,
elite_opened=elite_opened,
esports_opened=esports_opened,
role_bind_status=role_bind_status,
)
total = None
@@ -378,11 +493,66 @@ def accounts_summary(
)
by_status = {str(row[0] or ""): int(row[1] or 0) for row in status_rows}
visible_total = sum(by_status.values())
# 高价值及核心资产分布(单次聚合,供前端快捷胶囊展示)
bairimeng_expr = _high_value_contains_expr(Account.high_value_owned, "兔兔白日梦")
mengtu_expr = _high_value_contains_expr(Account.high_value_owned, "狂战萌兔")
high_value_broken_expr = and_(
or_(
and_(
Account.overview_queried_at.isnot(None),
~bairimeng_expr,
~mengtu_expr,
),
Account.points >= 300,
),
Account.ck_check_status.in_(["invalid", "error"]),
)
hv_row = visible.with_entities(
func.count(
func.nullif(
Account.overview_queried_at.isnot(None)
& ~bairimeng_expr
& ~mengtu_expr,
False,
)
),
func.count(
func.nullif(
Account.overview_queried_at.isnot(None) & ~bairimeng_expr,
False,
)
),
func.count(
func.nullif(
Account.overview_queried_at.isnot(None) & ~mengtu_expr,
False,
)
),
func.count(func.nullif(Account.overview_queried_at.is_(None), False)),
func.count(func.nullif(Account.points >= 300, False)),
func.count(func.nullif(Account.gold_balance > 0, False)),
func.count(func.nullif(Account.elite_opened.is_(True), False)),
func.count(func.nullif(high_value_broken_expr, False)),
).first()
high_value_summary = {
"both_available": int(hv_row[0] or 0) if hv_row else 0,
"can_bairimeng": int(hv_row[1] or 0) if hv_row else 0,
"can_mengtu": int(hv_row[2] or 0) if hv_row else 0,
"unqueried": int(hv_row[3] or 0) if hv_row else 0,
"high_points": int(hv_row[4] or 0) if hv_row else 0,
"has_gold": int(hv_row[5] or 0) if hv_row else 0,
"elite_opened": int(hv_row[6] or 0) if hv_row else 0,
"high_value_broken": int(hv_row[7] or 0) if hv_row else 0,
}
return {
"total": total,
"visible_total": visible_total,
# 生命周期分布(键是 6 状态的枚举值)
"by_status": by_status,
# 高价值与核心资产统计
"high_value": high_value_summary,
# 需要人关注的:CK 已被判失效的账号数(含待人工)
"ck_invalid_count": int(
visible.filter(Account.ck_check_status == "invalid")
+11
View File
@@ -137,6 +137,17 @@ class AccountBulkSelection(BaseModel):
tag: str = ""
assigned_only: bool = False
has_cookie: bool = False
status: str = ""
ck_status: str = ""
bind_status: str = ""
high_value_filter: str = ""
min_points: int | None = None
max_points: int | None = None
min_gold: int | None = None
max_gold: int | None = None
elite_opened: bool | None = None
esports_opened: bool | None = None
role_bind_status: str = ""
class AccountBulkTag(AccountBulkSelection):