Files
live-hub-py/tests/test_login_failure_events.py
T
yml2213 d11fa1032d feat(login-tasks): 按状态导出 CSV + 登录失败写进账号生命周期
一、失败入账号一生(此前完全缺失)
生产实测:`login_ok` 1,456 条、`relogin_failed` 73 条,但**没有 login_failed**,
而 login_tasks 里 attempt_status='failed' 有 247 条 —— 这 247 次失败在账号时间线上
一条痕迹都没有,「这账号为什么一直登录不上」在账号页上无从回答。

- 新增事件类型 login_failed,在 `_mark_attempt_failed` 里写入
- **被停止 / 被中断不写**:分型把「已停止/中断/服务重启」归到 interrupted,
  那不是账号的问题 —— 否则运营按一次停止就能给几十个账号刷满假失败
- 事件带分型(captcha/credential/email_code/email_quota/network),
  同时落进 detail.kind,以后可直接按「验证码失败」筛账号
- 幂等键 login_task:{id}:failed:{ts},与成功路径同构

二、失败分型下沉成共用 service
`classify_failure` 原在 automation/jobs/ck_relogin.py,手动登录也要用。
挪到 services/login_failure.py(job 改为 import,`ck_relogin.classify_failure`
这个名字保留,旧调用方与用例不受影响),并提供型别中文标签与
`is_account_fault` 判据 —— 与仓库既有的「service 不该依赖 job」分层一致。

三、按状态导出 CSV
- 新增 GET /api/login/tasks/export:不分页,导出当前筛选的**全部**匹配记录;
  列:账号/状态/失败原因/失败分型/批次/最近尝试时间(UTC)/标签/归属客服
- 权限与筛选与列表**完全共用**,客服只导出自己名下账号
- 带 UTF-8 BOM(不加 Excel 打开中文表头是乱码),导出动作写审计

四、修一个口径不一致的真 bug
页面「失败 N」= failed + error 之和,但筛选只认 failed ——
所以截图里「失败 389」点进去只有 386 条(差的就是那几条 error);
「清空失败」同样漏删 error。现在把状态组收敛成一份 `_apply_status_filter`,
列表 / 导出 / 批量删除三处共用。

测试:684 passed, 6 skipped(本次新增 13 个);三条关键断言做了反向验证
(去掉 stopped 判断 / 失败组去掉 error / 去掉 BOM,对应用例均变红)。
2026-10-07 12:54:57 +08:00

270 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""登录失败进账号时间线 + 登录任务按状态导出 CSV。
两条都是这次新加的,各有必须守住的东西:
- **失败入流水**:真失败要写,被停止/被中断**不能**写 ——
否则运营按一次「停止」就能给几十个账号刷满假失败;
- **导出**:口径必须和列表完全一致(尤其「失败 = failed + error」这个组),
而且要带 UTF-8 BOM,不然 Excel 打开中文乱码。
"""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime
from tests.support.db import get_test_engine, make_test_session
from web.backend.models import Account, AccountEvent, LoginTask, User
from web.backend.routers.login import LOGIN_STATUS_LABELS, export_tasks, list_tasks
from web.backend.services import account_events_service as svc
from web.backend.services.login_service import _mark_attempt_failed
NOW = datetime(2026, 10, 7, 18, 0, 0, tzinfo=UTC)
async def _read_body(response) -> str:
"""把 StreamingResponse 的内容读成字符串。"""
chunks: list[bytes] = []
async for chunk in response.body_iterator:
chunks.append(chunk if isinstance(chunk, bytes) else str(chunk).encode("utf-8"))
return b"".join(chunks).decode("utf-8")
def _body(response) -> str:
return asyncio.run(_read_body(response))
class _Base:
def setup_method(self):
self.engine = get_test_engine()
self.session = make_test_session(self.engine)
self.admin = User(username="lf-admin", password_hash="h", role="super_admin")
self.support = User(username="lf-kf", password_hash="h", role="support")
self.session.add_all([self.admin, self.support])
self.session.flush()
self.admin_id = self.admin.id
self.support_id = self.support.id
def teardown_method(self):
self.session.close()
def make_account(self, name: str, *, assigned_to=None) -> Account:
account = Account(
username=name,
password="p",
email="e@e.com",
email_password="ep",
assigned_to=assigned_to if assigned_to is not None else self.support_id,
)
self.session.add(account)
self.session.commit()
return account
def make_task(
self,
account: Account,
*,
attempt_status: str = "failed",
status: str = "failed",
message: str = "",
batch_id: str = "b1",
) -> LoginTask:
task = LoginTask(
batch_id=batch_id,
account_id=account.id,
status=status,
attempt_status=attempt_status,
attempt_message=message,
attempt_batch_id=batch_id,
message=message,
created_by=self.support_id,
)
self.session.add(task)
self.session.commit()
return task
def events(self, account_id: int) -> list[AccountEvent]:
self.session.rollback()
return (
self.session.query(AccountEvent)
.filter(
AccountEvent.account_id == account_id,
AccountEvent.event_type == svc.EVENT_LOGIN_FAILED,
)
.all()
)
class TestLoginFailureEvents(_Base):
def test_real_failure_writes_account_event(self):
account = self.make_account("lf-1")
task = self.make_task(account, message="极验验证失败: slide")
_mark_attempt_failed(task, "极验验证失败: slide")
self.session.commit()
events = self.events(account.id)
assert len(events) == 1
event = events[0]
assert event.event_type == "login_failed"
assert event.source == "login"
# 分型要落进 detail —— 以后按「验证码失败」筛账号靠它
assert event.detail["kind"] == "captcha"
assert event.detail["kind_label"] == "极验/验证码拦截"
assert "极验" in event.title
def test_stopped_does_not_write_event(self):
"""用户按「停止」不是账号的问题,不该污染账号一生。"""
account = self.make_account("lf-2")
task = self.make_task(account)
_mark_attempt_failed(task, "任务已停止,旧 Cookie 已保留")
self.session.commit()
assert self.events(account.id) == []
def test_restart_interrupted_does_not_write_event(self):
account = self.make_account("lf-3")
task = self.make_task(account)
_mark_attempt_failed(task, "登录已中断(服务重启),旧 Cookie 已保留")
self.session.commit()
assert self.events(account.id) == []
def test_credential_failure_writes_event(self):
account = self.make_account("lf-4")
task = self.make_task(account)
_mark_attempt_failed(task, "登录失败: 账号密码错误")
self.session.commit()
assert self.events(account.id)[0].detail["kind"] == "credential"
def test_same_attempt_is_idempotent(self):
"""同一次尝试重复收敛,只该留一条事件(source_ref 幂等)。"""
account = self.make_account("lf-5")
task = self.make_task(account)
_mark_attempt_failed(task, "极验验证失败: slide")
self.session.commit()
_mark_attempt_failed(task, "极验验证失败: slide")
self.session.commit()
assert len(self.events(account.id)) == 1
def test_event_does_not_break_when_task_has_no_session(self):
"""纯内存构造的任务(没有 session)不能因为写事件而炸掉主流程。"""
account = Account(
username="lf-detached",
password="p",
email="e@e.com",
email_password="ep",
)
task = LoginTask(
batch_id="b",
account_id=1,
status="failed",
attempt_status="failed",
created_by=1,
)
task.account = account
_mark_attempt_failed(task, "极验验证失败: slide") # 不应抛异常
class TestFailedGroupFilter(_Base):
def test_failed_filter_includes_error(self):
"""页面「失败 N」= failed + error;筛选也必须一致,否则数对不上。"""
ok = self.make_account("fg-ok")
bad = self.make_account("fg-bad")
err = self.make_account("fg-err")
self.make_task(ok, attempt_status="success", status="success")
self.make_task(bad, attempt_status="failed", status="failed")
self.make_task(err, attempt_status="error", status="error")
failed = list_tasks(
batch_id=None,
page=1,
page_size=50,
status="failed",
db=self.session,
current=self.admin,
)
assert failed["total"] == 2, "error 必须算进「失败」这一组"
success = list_tasks(
batch_id=None,
page=1,
page_size=50,
status="success",
db=self.session,
current=self.admin,
)
assert success["total"] == 1
class TestExportCsv(_Base):
def _export(self, *, status="", current=None, batch_id=None):
return _body(
export_tasks(
batch_id=batch_id,
status=status,
db=self.session,
current=current or self.admin,
)
)
def test_has_bom_and_header(self):
self.make_account("ex-1")
body = self._export()
assert body.startswith("\ufeff"), "没有 BOM,Excel 打开中文会乱码"
header = body.lstrip("\ufeff").splitlines()[0]
assert header.startswith("账号,状态,失败原因,失败分型,批次,最近尝试时间")
assert "标签" in header and "归属客服" in header
def test_rows_carry_account_status_reason_and_kind(self):
account = self.make_account("ex-2")
self.make_task(account, message="极验验证失败: slide")
lines = self._export(status="failed").lstrip("\ufeff").splitlines()
assert len(lines) == 2
row = lines[1].split(",")
assert row[0] == "ex-2"
assert row[1] == "失败"
assert "极验" in row[2]
assert row[3] == "极验/验证码拦截"
assert row[7] == "lf-kf" # 归属客服:运营拿去分派
def test_failed_export_includes_error_rows(self):
"""导出「失败」必须把 error 一起带上,否则运营照 CSV 补登会漏人。"""
bad = self.make_account("ex-3")
err = self.make_account("ex-4")
self.make_task(
bad, attempt_status="failed", status="failed", message="密码错误"
)
self.make_task(err, attempt_status="error", status="error", message="网络超时")
lines = self._export(status="failed").lstrip("\ufeff").splitlines()
assert len(lines) == 3
names = {line.split(",")[0] for line in lines[1:]}
assert names == {"ex-3", "ex-4"}
def test_status_is_rendered_in_chinese(self):
account = self.make_account("ex-5")
self.make_task(account, attempt_status="success", status="success")
lines = self._export(status="success").lstrip("\ufeff").splitlines()
assert lines[1].split(",")[1] == LOGIN_STATUS_LABELS["success"]
def test_kind_column_empty_for_non_failure(self):
account = self.make_account("ex-6")
self.make_task(account, attempt_status="success", status="success")
lines = self._export(status="success").lstrip("\ufeff").splitlines()
assert lines[1].split(",")[3] == ""
def test_support_only_exports_own_accounts(self):
mine = self.make_account("ex-mine", assigned_to=self.support_id)
other = self.make_account("ex-other", assigned_to=self.admin_id)
self.make_task(mine)
self.make_task(other)
body = self._export(status="failed", current=self.support)
names = {line.split(",")[0] for line in body.lstrip("\ufeff").splitlines()[1:]}
assert names == {"ex-mine"}
all_names = {
line.split(",")[0]
for line in self._export(status="failed", current=self.admin)
.lstrip("\ufeff")
.splitlines()[1:]
}
assert all_names == {"ex-mine", "ex-other"}