diff --git a/tests/test_account_failure_and_export.py b/tests/test_account_failure_and_export.py index 00fdaec..16baff8 100644 --- a/tests/test_account_failure_and_export.py +++ b/tests/test_account_failure_and_export.py @@ -61,6 +61,12 @@ class TestAccountFailureClassificationAndExport: classify_failure("跳过手机号绑定失败: 必须绑定手机号") == "must_bind_mobile" ) assert classify_failure("登录失败: 必须绑定手机号") == "must_bind_mobile" + assert ( + classify_failure( + "重新登录失败: 跳过手机号绑定失败: 必须绑定手机号(旧 Cookie 已保留)" + ) + == "must_bind_mobile" + ) assert failure_kind_label("must_bind_mobile") == "必须绑定手机号" # 验证 must_bind_mobile 在 CK 失效时被 derive_status 推导为 manual 待人工 @@ -76,6 +82,18 @@ class TestAccountFailureClassificationAndExport: ) assert derive_status(acc) == STATUS_MANUAL + def test_classify_account_canceled_or_banned(self): + """测试账号已注销/封禁/冻结归入凭证/账号异常问题。""" + assert ( + classify_failure( + "重新登录失败: 第二次登录失败: 账号已注销(旧 Cookie 已保留)" + ) + == "credential" + ) + assert classify_failure("第二次登录失败: 账号已注销") == "credential" + assert classify_failure("登录失败: 该账号已被封禁") == "credential" + assert classify_failure("账号已冻结,请联系客服") == "credential" + def test_list_accounts_filter_by_failure_kind_and_serialization(self): """测试按 failure_kind 筛选与 AccountOut 序列化包含失败字段。""" self._create_account( diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 690d69f..e350d78 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent PROJECT_ROOT = TESTS_DIR.parent VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions" -HEAD_REVISION = "20261004_0051" +HEAD_REVISION = "20261008_0052" # 迁移新增、但不声明在模型里的复合查询索引。 EXTRA_INDEXES = ( diff --git a/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py b/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py new file mode 100644 index 0000000..27d32d2 --- /dev/null +++ b/web/backend/migrations/versions/20261008_0052_relogin_failure_kind_sync.py @@ -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 diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 3a5c0f6..eb49722 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -69,7 +69,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 failure_kind_label +from ..services.login_failure import classify_failure, failure_kind_label router = APIRouter(prefix="/api/accounts", tags=["账号管理"]) @@ -569,6 +569,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, @@ -581,8 +587,8 @@ 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=acc.relogin_failure_kind or "", - relogin_failure_label=failure_kind_label(acc.relogin_failure_kind), + 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), @@ -784,13 +790,16 @@ def export_accounts( ] ) 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(acc.relogin_failure_kind) - if acc.relogin_failure_kind - else "", + 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 "未分配", diff --git a/web/backend/services/login_failure.py b/web/backend/services/login_failure.py index 3a9f6b9..d98b097 100644 --- a/web/backend/services/login_failure.py +++ b/web/backend/services/login_failure.py @@ -50,13 +50,26 @@ def classify_failure(message: str | None) -> str: 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" # 请求失败/超时/连接异常,以及未知文案:都按可重试处理。 # 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。