fix(accounts): 完善手机号必绑与账号注销等登录异常分型识别
- 补充必须绑定手机号与账号注销/封禁/冻结等错误文案的识别规则 - 增加数据修正迁移 20261008_0052,回写历史账号和登录任务的失败分型 - 增加列表查询与导出时根据错误文案自动兜底细化 failure_kind 的逻辑 - 增加并完善相关分型单元测试
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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
|
||||
@@ -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 "未分配",
|
||||
|
||||
@@ -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"
|
||||
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
|
||||
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
|
||||
|
||||
Reference in New Issue
Block a user