"""登录失败进账号时间线 + 登录任务按状态导出 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"}