Merge branch 'main' into dev: 同步分配管理优化、登录异常分型及历史数据刷正

This commit is contained in:
yml2213
2026-10-08 11:38:48 +08:00
25 changed files with 3031 additions and 1403 deletions
+159 -16
View File
@@ -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,
}