Merge branch 'main' into dev: 同步分配管理优化、登录异常分型及历史数据刷正
This commit is contained in:
+159
-16
@@ -1,8 +1,8 @@
|
||||
"""账号管理路由"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import and_, case, func, or_, select, text
|
||||
from sqlalchemy.orm import Session, defer, joinedload
|
||||
|
||||
@@ -24,6 +24,7 @@ from ..schemas import (
|
||||
AccountAutomationUpdate,
|
||||
AccountBulkSelection,
|
||||
AccountBulkTag,
|
||||
AccountExportRequest,
|
||||
AccountImport,
|
||||
AccountLifecycleUpdate,
|
||||
AccountOut,
|
||||
@@ -69,6 +70,7 @@ from ..services.account_value import (
|
||||
)
|
||||
from ..services.audit_service import record_account_action, record_audit
|
||||
from ..services.douyu_service import cookie_holding_filter
|
||||
from ..services.login_failure import classify_failure, failure_kind_label
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||
|
||||
@@ -104,6 +106,10 @@ def _query_str(value) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _query_bool(value) -> bool:
|
||||
return value is True
|
||||
|
||||
|
||||
def _filter_accounts_query(
|
||||
db: Session,
|
||||
query,
|
||||
@@ -117,6 +123,7 @@ def _filter_accounts_query(
|
||||
search: str = "",
|
||||
status: str = "",
|
||||
ck_status: str = "",
|
||||
failure_kind: str = "",
|
||||
bind_status: str = "",
|
||||
high_value_filter: str = "",
|
||||
min_points: int | None = None,
|
||||
@@ -128,9 +135,9 @@ def _filter_accounts_query(
|
||||
role_bind_status: str = "",
|
||||
):
|
||||
"""复用列表筛选条件,供分页列表和全量批量操作保持一致。"""
|
||||
if has_cookie:
|
||||
query = query.filter(Account.id.in_(cookie_account_ids_query(db)))
|
||||
if assigned_only and user_has_permission(current, "account:view_all"):
|
||||
if _query_bool(has_cookie):
|
||||
query = query.filter(Account.cookie.isnot(None), Account.cookie != "")
|
||||
if _query_bool(assigned_only) and user_has_permission(current, "account:view_all"):
|
||||
query = query.filter(Account.assigned_to.isnot(None))
|
||||
# 归属筛选:未分配 / 已分配,再叠加「指定客服」。与其它条件(标签、CK、
|
||||
# 搜索…)是**同时生效**的,这正是「综合筛选」的用法。
|
||||
@@ -155,6 +162,9 @@ def _filter_accounts_query(
|
||||
ck_value = _query_str(ck_status)
|
||||
if ck_value:
|
||||
query = query.filter(Account.ck_check_status == ck_value)
|
||||
fk_value = _query_str(failure_kind)
|
||||
if fk_value:
|
||||
query = query.filter(Account.relogin_failure_kind == fk_value)
|
||||
bind_value = _query_str(bind_status)
|
||||
if bind_value:
|
||||
# 三个作用域任一命中即算(列表上「绑定」是一列,不分开筛)
|
||||
@@ -252,6 +262,7 @@ def _selected_account_ids(
|
||||
search=req.search,
|
||||
status=req.status,
|
||||
ck_status=req.ck_status,
|
||||
failure_kind=req.failure_kind,
|
||||
bind_status=req.bind_status,
|
||||
high_value_filter=req.high_value_filter,
|
||||
min_points=req.min_points,
|
||||
@@ -405,6 +416,10 @@ def list_accounts(
|
||||
ck_status: str = Query(
|
||||
"", description="按 CK 健康结论筛选:valid / invalid / error"
|
||||
),
|
||||
failure_kind: str = Query(
|
||||
"",
|
||||
description="按最近登录失败分型筛选:must_bind_mobile/captcha/credential/email_code/email_quota/network",
|
||||
),
|
||||
bind_status: str = Query(
|
||||
"", description="按角色绑定筛选:unknown/unbound/pending/bound"
|
||||
),
|
||||
@@ -445,6 +460,7 @@ def list_accounts(
|
||||
search=search,
|
||||
status=status,
|
||||
ck_status=ck_status,
|
||||
failure_kind=failure_kind,
|
||||
bind_status=bind_status,
|
||||
high_value_filter=high_value_filter,
|
||||
min_points=min_points,
|
||||
@@ -565,6 +581,12 @@ def list_accounts(
|
||||
|
||||
result = []
|
||||
for acc in accounts:
|
||||
eff_kind = acc.relogin_failure_kind or ""
|
||||
if acc.relogin_last_error:
|
||||
refined = classify_failure(acc.relogin_last_error)
|
||||
if not eff_kind or (eff_kind == "network" and refined != "network"):
|
||||
eff_kind = refined
|
||||
|
||||
item = AccountOut(
|
||||
id=acc.id,
|
||||
username=acc.username,
|
||||
@@ -577,6 +599,9 @@ def list_accounts(
|
||||
status_label=STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||
status_reason=acc.status_reason or "",
|
||||
auto_managed=bool(acc.auto_managed),
|
||||
relogin_failure_kind=eff_kind,
|
||||
relogin_failure_label=failure_kind_label(eff_kind),
|
||||
relogin_last_error=acc.relogin_last_error or "",
|
||||
# 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合
|
||||
has_cookie=bool(acc.cookie),
|
||||
ck_check_status=acc.ck_check_status or "",
|
||||
@@ -646,6 +671,19 @@ 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())
|
||||
|
||||
# 登录失败分型统计(不含空值与归档)
|
||||
failure_rows = (
|
||||
visible.filter(
|
||||
Account.relogin_failure_kind != "",
|
||||
Account.relogin_failure_kind.isnot(None),
|
||||
)
|
||||
.with_entities(Account.relogin_failure_kind, func.count(Account.id))
|
||||
.group_by(Account.relogin_failure_kind)
|
||||
.all()
|
||||
)
|
||||
login_failures = {str(row[0] or ""): int(row[1] or 0) for row in failure_rows}
|
||||
|
||||
# 高价值及核心资产分布(单次聚合,供前端快捷胶囊展示)
|
||||
bairimeng_expr = suit_owned_expr(Account.high_value_owned, SUIT_BAIRIMENG)
|
||||
mengtu_expr = suit_owned_expr(Account.high_value_owned, SUIT_MENGTU)
|
||||
@@ -681,6 +719,8 @@ def accounts_summary(
|
||||
"visible_total": visible_total,
|
||||
# 生命周期分布(键是 6 状态的枚举值)
|
||||
"by_status": by_status,
|
||||
# 登录异常分类统计
|
||||
"login_failures": login_failures,
|
||||
# 高价值与核心资产统计
|
||||
"high_value": high_value_summary,
|
||||
# 需要人关注的:CK 已被判失效的账号数(含待人工)
|
||||
@@ -702,6 +742,99 @@ def accounts_summary(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/export")
|
||||
def export_accounts(
|
||||
req: AccountExportRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current: User = Depends(get_current_user),
|
||||
):
|
||||
"""导出账号:支持纯文本账号名单(txt)或完整详情(csv)。"""
|
||||
selected_ids = _selected_account_ids(db, current, req)
|
||||
if not selected_ids:
|
||||
if req.format == "csv":
|
||||
content = "\ufeff账号,生命周期状态,登录异常分类,最新报错,标签,归属客服,鱼翅,积分,精英宝典,电竞手册,CK状态,备注\n"
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.csv"'},
|
||||
)
|
||||
return Response(
|
||||
content="",
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.txt"'},
|
||||
)
|
||||
|
||||
accounts = (
|
||||
db.query(Account)
|
||||
.options(joinedload(Account.assigned_user))
|
||||
.filter(Account.id.in_(selected_ids))
|
||||
.order_by(Account.id.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
if req.format == "txt":
|
||||
# 纯账号名字,换行分隔
|
||||
lines = [acc.username for acc in accounts if acc.username]
|
||||
text_content = "\n".join(lines)
|
||||
return Response(
|
||||
content=text_content,
|
||||
media_type="text/plain; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.txt"'},
|
||||
)
|
||||
|
||||
# 导出 CSV (带 UTF-8 BOM,防止 Excel 打开乱码)
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(
|
||||
[
|
||||
"账号",
|
||||
"生命周期状态",
|
||||
"登录异常分类",
|
||||
"最新报错",
|
||||
"标签",
|
||||
"归属客服",
|
||||
"鱼翅",
|
||||
"积分",
|
||||
"精英宝典",
|
||||
"电竞手册",
|
||||
"CK状态",
|
||||
"备注",
|
||||
]
|
||||
)
|
||||
for acc in accounts:
|
||||
eff_kind = acc.relogin_failure_kind or ""
|
||||
if acc.relogin_last_error:
|
||||
refined = classify_failure(acc.relogin_last_error)
|
||||
if not eff_kind or (eff_kind == "network" and refined != "network"):
|
||||
eff_kind = refined
|
||||
writer.writerow(
|
||||
[
|
||||
acc.username or "",
|
||||
STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||
failure_kind_label(eff_kind) if eff_kind else "",
|
||||
acc.relogin_last_error or "",
|
||||
acc.tag or "",
|
||||
acc.assigned_user.username if acc.assigned_user else "未分配",
|
||||
acc.gold_balance if acc.gold_balance is not None else "",
|
||||
acc.points if acc.points is not None else "",
|
||||
"已开通"
|
||||
if acc.elite_opened is True
|
||||
else ("未开通" if acc.elite_opened is False else "未知"),
|
||||
"已开通"
|
||||
if acc.esports_opened is True
|
||||
else ("未开通" if acc.esports_opened is False else "未知"),
|
||||
acc.ck_check_status or "",
|
||||
acc.remark or "",
|
||||
]
|
||||
)
|
||||
csv_content = "\ufeff" + output.getvalue()
|
||||
return Response(
|
||||
content=csv_content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": 'attachment; filename="accounts.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
def import_accounts(
|
||||
req: AccountImport,
|
||||
@@ -897,30 +1030,40 @@ def assignments_summary(
|
||||
current: User = Depends(require_permission("account:assign")),
|
||||
):
|
||||
"""分配概览:每个客服分配了多少账号(仅统计当前仍持有 CK 的账号)。"""
|
||||
cookie_subq = select(Account.id).where(cookie_holding_filter())
|
||||
cookie_accounts = db.query(Account).filter(Account.id.in_(cookie_subq)).subquery()
|
||||
|
||||
results = (
|
||||
db.query(
|
||||
User.id, User.username, func.count(cookie_accounts.c.id).label("count")
|
||||
db.query(User.id, User.username, func.count(Account.id).label("count"))
|
||||
.outerjoin(
|
||||
Account,
|
||||
and_(
|
||||
Account.assigned_to == User.id,
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
),
|
||||
)
|
||||
.outerjoin(cookie_accounts, cookie_accounts.c.assigned_to == User.id)
|
||||
.filter(User.role == "support")
|
||||
.group_by(User.id, User.username)
|
||||
.order_by(func.count(cookie_accounts.c.id).desc())
|
||||
.order_by(func.count(Account.id).desc())
|
||||
.all()
|
||||
)
|
||||
total_unassigned = (
|
||||
db.query(func.count(cookie_accounts.c.id))
|
||||
.filter(cookie_accounts.c.assigned_to.is_(None))
|
||||
db.query(func.count(Account.id))
|
||||
.filter(
|
||||
Account.assigned_to.is_(None),
|
||||
Account.cookie.isnot(None),
|
||||
Account.cookie != "",
|
||||
)
|
||||
.scalar()
|
||||
) or 0
|
||||
total_assigned = sum(int(cnt or 0) for _, _, cnt in results)
|
||||
total_accounts = total_assigned + total_unassigned
|
||||
return {
|
||||
"support_users": [
|
||||
{"id": uid, "username": uname, "assigned_count": cnt}
|
||||
{"id": uid, "username": uname, "assigned_count": int(cnt or 0)}
|
||||
for uid, uname, cnt in results
|
||||
],
|
||||
"unassigned_count": total_unassigned,
|
||||
"assigned_count": total_assigned,
|
||||
"total_count": total_accounts,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user