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:
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user