一、失败入账号一生(此前完全缺失)
生产实测:`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,对应用例均变红)。
270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""登录失败进账号时间线 + 登录任务按状态导出 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"}
|