fix(accounts): 完善手机号必绑与账号注销等登录异常分型识别
- 补充必须绑定手机号与账号注销/封禁/冻结等错误文案的识别规则 - 增加数据修正迁移 20261008_0052,回写历史账号和登录任务的失败分型 - 增加列表查询与导出时根据错误文案自动兜底细化 failure_kind 的逻辑 - 增加并完善相关分型单元测试
This commit is contained in:
@@ -61,6 +61,12 @@ class TestAccountFailureClassificationAndExport:
|
|||||||
classify_failure("跳过手机号绑定失败: 必须绑定手机号") == "must_bind_mobile"
|
classify_failure("跳过手机号绑定失败: 必须绑定手机号") == "must_bind_mobile"
|
||||||
)
|
)
|
||||||
assert 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") == "必须绑定手机号"
|
assert failure_kind_label("must_bind_mobile") == "必须绑定手机号"
|
||||||
|
|
||||||
# 验证 must_bind_mobile 在 CK 失效时被 derive_status 推导为 manual 待人工
|
# 验证 must_bind_mobile 在 CK 失效时被 derive_status 推导为 manual 待人工
|
||||||
@@ -76,6 +82,18 @@ class TestAccountFailureClassificationAndExport:
|
|||||||
)
|
)
|
||||||
assert derive_status(acc) == STATUS_MANUAL
|
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):
|
def test_list_accounts_filter_by_failure_kind_and_serialization(self):
|
||||||
"""测试按 failure_kind 筛选与 AccountOut 序列化包含失败字段。"""
|
"""测试按 failure_kind 筛选与 AccountOut 序列化包含失败字段。"""
|
||||||
self._create_account(
|
self._create_account(
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
|
|||||||
PROJECT_ROOT = TESTS_DIR.parent
|
PROJECT_ROOT = TESTS_DIR.parent
|
||||||
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
|
||||||
|
|
||||||
HEAD_REVISION = "20261004_0051"
|
HEAD_REVISION = "20261008_0052"
|
||||||
|
|
||||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||||
EXTRA_INDEXES = (
|
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.audit_service import record_account_action, record_audit
|
||||||
from ..services.douyu_service import cookie_holding_filter
|
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=["账号管理"])
|
router = APIRouter(prefix="/api/accounts", tags=["账号管理"])
|
||||||
|
|
||||||
@@ -569,6 +569,12 @@ def list_accounts(
|
|||||||
|
|
||||||
result = []
|
result = []
|
||||||
for acc in 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
|
||||||
|
|
||||||
item = AccountOut(
|
item = AccountOut(
|
||||||
id=acc.id,
|
id=acc.id,
|
||||||
username=acc.username,
|
username=acc.username,
|
||||||
@@ -581,8 +587,8 @@ def list_accounts(
|
|||||||
status_label=STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
status_label=STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||||
status_reason=acc.status_reason or "",
|
status_reason=acc.status_reason or "",
|
||||||
auto_managed=bool(acc.auto_managed),
|
auto_managed=bool(acc.auto_managed),
|
||||||
relogin_failure_kind=acc.relogin_failure_kind or "",
|
relogin_failure_kind=eff_kind,
|
||||||
relogin_failure_label=failure_kind_label(acc.relogin_failure_kind),
|
relogin_failure_label=failure_kind_label(eff_kind),
|
||||||
relogin_last_error=acc.relogin_last_error or "",
|
relogin_last_error=acc.relogin_last_error or "",
|
||||||
# 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合
|
# 关键列:全部是 accounts 行上的字段,无 JOIN / 无聚合
|
||||||
has_cookie=bool(acc.cookie),
|
has_cookie=bool(acc.cookie),
|
||||||
@@ -784,13 +790,16 @@ def export_accounts(
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
for acc in 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(
|
writer.writerow(
|
||||||
[
|
[
|
||||||
acc.username or "",
|
acc.username or "",
|
||||||
STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
STATUS_LABELS.get(acc.status or "", acc.status or ""),
|
||||||
failure_kind_label(acc.relogin_failure_kind)
|
failure_kind_label(eff_kind) if eff_kind else "",
|
||||||
if acc.relogin_failure_kind
|
|
||||||
else "",
|
|
||||||
acc.relogin_last_error or "",
|
acc.relogin_last_error or "",
|
||||||
acc.tag or "",
|
acc.tag or "",
|
||||||
acc.assigned_user.username if acc.assigned_user else "未分配",
|
acc.assigned_user.username if acc.assigned_user else "未分配",
|
||||||
|
|||||||
@@ -50,13 +50,26 @@ def classify_failure(message: str | None) -> str:
|
|||||||
return "must_bind_mobile"
|
return "must_bind_mobile"
|
||||||
if "上限" in text or "配额" in text:
|
if "上限" in text or "配额" in text:
|
||||||
return "email_quota"
|
return "email_quota"
|
||||||
if "极验" in text or "验证" in text and "滑块" in text:
|
if "极验" in text or ("验证" in text and "滑块" in text):
|
||||||
return "captcha"
|
return "captcha"
|
||||||
if "验证码" in text:
|
if "验证码" in text or "取码" in text:
|
||||||
return "email_code"
|
return "email_code"
|
||||||
if "中断" in text or "已停止" in text or "任务已停止" in text:
|
if "中断" in text or "已停止" in text or "任务已停止" in text:
|
||||||
return "interrupted"
|
return "interrupted"
|
||||||
if "密码" in text or "账号不存在" in text or "缺少密码或邮箱" in text:
|
if any(
|
||||||
|
k in text
|
||||||
|
for k in (
|
||||||
|
"密码",
|
||||||
|
"账号不存在",
|
||||||
|
"缺少密码或邮箱",
|
||||||
|
"注销",
|
||||||
|
"已注销",
|
||||||
|
"封禁",
|
||||||
|
"冻结",
|
||||||
|
"限制登录",
|
||||||
|
"禁止登录",
|
||||||
|
)
|
||||||
|
):
|
||||||
return "credential"
|
return "credential"
|
||||||
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
|
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
|
||||||
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
|
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
|
||||||
|
|||||||
Reference in New Issue
Block a user