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
@@ -0,0 +1,72 @@
"""同步历史重登失败分型:根据最新 classify_failure 规则刷正 accounts 和 login_tasks
## 背景
新增了「必须绑定手机号」与「账号注销/封禁」分型,历史已存在报错文案的账号需要回写正确的 failure_kind,
使「登录异常一键筛选」与列表角标统计准确。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from web.backend.services.login_failure import classify_failure
revision: str = "20261008_0052"
down_revision: str | None = "20261004_0051"
branch_labels: str | None = None
depends_on: str | None = None
def upgrade() -> None:
conn = op.get_bind()
# 1. 查询 accounts 中有 relogin_last_error 的记录
rows = conn.execute(
sa.text(
"SELECT id, relogin_last_error, relogin_failure_kind "
"FROM accounts "
"WHERE relogin_last_error IS NOT NULL AND relogin_last_error != ''"
)
).fetchall()
for row in rows:
acc_id, err_text, current_kind = row[0], row[1], row[2]
new_kind = classify_failure(err_text)
if new_kind and new_kind != current_kind:
conn.execute(
sa.text("UPDATE accounts SET relogin_failure_kind = :k WHERE id = :id"),
{"k": new_kind, "id": acc_id},
)
# 2. 查询 login_tasks 中有 message 的记录
# 检查 login_tasks 是否有 relogin_failure_kind 列
col_exists = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
"AND table_name = 'login_tasks' AND column_name = 'relogin_failure_kind'"
)
).scalar()
if col_exists:
task_rows = conn.execute(
sa.text(
"SELECT id, message, relogin_failure_kind "
"FROM login_tasks "
"WHERE message IS NOT NULL AND message != ''"
)
).fetchall()
for row in task_rows:
task_id, msg, current_kind = row[0], row[1], row[2]
new_kind = classify_failure(msg)
if new_kind and new_kind != current_kind:
conn.execute(
sa.text(
"UPDATE login_tasks SET relogin_failure_kind = :k WHERE id = :id"
),
{"k": new_kind, "id": task_id},
)
def downgrade() -> None:
pass
@@ -25,8 +25,8 @@ from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261007_0052"
down_revision: str | None = "20261004_0051"
revision: str = "20261008_0053"
down_revision: str | None = "20261008_0052"
branch_labels: str | None = None
depends_on: str | None = None
+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,
}
+14
View File
@@ -138,6 +138,7 @@ class AccountBulkSelection(BaseModel):
has_cookie: bool = False
status: str = ""
ck_status: str = ""
failure_kind: str = ""
bind_status: str = ""
high_value_filter: str = ""
min_points: int | None = None
@@ -149,6 +150,12 @@ class AccountBulkSelection(BaseModel):
role_bind_status: str = ""
class AccountExportRequest(AccountBulkSelection):
"""账号导出请求:支持纯账号名单(txt)或结构化明细(csv)。"""
format: str = Field("txt", pattern="^(txt|csv)$")
class BatchAssign(AccountBulkSelection):
"""批量分配 / 取消分配。
@@ -206,6 +213,10 @@ class AccountOut(BaseModel):
status_label: str = ""
status_reason: str = ""
auto_managed: bool = True
# 登录失败分型(2026-10-07):客服排查重登/登录异常
relogin_failure_kind: str = ""
relogin_failure_label: str = ""
relogin_last_error: str = ""
# 列表页的「关键列」:全部直接来自 accounts 行,不需要任何 JOIN 或聚合
# (客服一眼要能看出「这账号能不能用、要不要我处理」)。
has_cookie: bool = False
@@ -250,6 +261,9 @@ class AccountOut(BaseModel):
"status_label": self.status_label,
"status_reason": self.status_reason,
"auto_managed": self.auto_managed,
"relogin_failure_kind": self.relogin_failure_kind,
"relogin_failure_label": self.relogin_failure_label,
"relogin_last_error": self.relogin_last_error,
"has_cookie": self.has_cookie,
"ck_check_status": self.ck_check_status,
"ck_checked_at": _isoformat(self.ck_checked_at),
+1 -1
View File
@@ -71,7 +71,7 @@ AUTOMATABLE_STATES: tuple[str, ...] = DERIVED_STATES
# 重登失败分型 → 需要人工(自动重试只会加重风控 / 重试多少次都一样)。
# 放在这里而不是 job 里:`derive_status` 要用它,service 不该反过来依赖 job。
MANUAL_KINDS = frozenset({"captcha", "credential"})
MANUAL_KINDS = frozenset({"captcha", "credential", "must_bind_mobile"})
STATUS_LABELS = {
STATUS_IMPORTED: "未登录",
+23 -6
View File
@@ -24,6 +24,7 @@ from __future__ import annotations
#: 型别 → 中文标签(导出 CSV 与页面上用同一份,避免两处各写一遍漂移)。
FAILURE_KIND_LABELS: dict[str, str] = {
"must_bind_mobile": "必须绑定手机号",
"captcha": "极验/验证码拦截",
"credential": "账号凭证问题",
"email_code": "邮箱取码失败",
@@ -39,20 +40,36 @@ NOT_AN_ACCOUNT_FAULT: frozenset[str] = frozenset({"interrupted"})
def classify_failure(message: str | None) -> str:
"""把重登/登录失败消息映射成分型(见规划 §7.4.3)。
顺序很重要:「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,
必须先在 ``email_quota`` 命中,否则会被归到可重试的 ``email_code`` 上,
导致次日才该重试的账号被按小时反复撞配额。
顺序很重要:
1. 「必须绑定手机号 / 跳过手机号绑定失败」优先归入 ``must_bind_mobile``;
2. 「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,必须先在 ``email_quota`` 命中;
3. 极验滑块命中 ``captcha``。
"""
text = message or ""
if "必须绑定手机号" in text or "跳过手机号绑定失败" in text or "绑定手机" in text:
return "must_bind_mobile"
if "上限" in text or "配额" in text:
return "email_quota"
if "极验" in text or "验证" in text and "滑块" in text:
if "极验" in text or ("验证" in text and "滑块" in text):
return "captcha"
if "验证码" in text:
if "验证码" in text or "取码" in text:
return "email_code"
if "中断" in text or "已停止" in text or "任务已停止" in text:
return "interrupted"
if "密码" in text or "账号不存在" in text or "缺少密码或邮箱" in text:
if any(
k in text
for k in (
"密码",
"账号不存在",
"缺少密码或邮箱",
"注销",
"已注销",
"封禁",
"冻结",
"限制登录",
"禁止登录",
)
):
return "credential"
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
+4 -1
View File
@@ -27,7 +27,7 @@ from .account_events_service import (
EVENT_RELOGIN_OK,
emit,
)
from .account_lifecycle import on_ck_verdict, on_login_success
from .account_lifecycle import on_ck_verdict, on_login_success, sync_status
from .cookie_check_service import check_douyu_cookie
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
from .login_failure import classify_failure, failure_kind_label, is_account_fault
@@ -155,6 +155,9 @@ def _mark_attempt_failed(
if db is None or account is None:
# 没有会话(纯内存构造的用例)或没有账号:跳过旁路事件,绝不影响主流程。
return
account.relogin_failure_kind = kind
account.relogin_last_error = (message or "")[:512]
sync_status(db, account, occurred_at=completed_at)
emit(
db,
account_id=account.id,