Files
live-hub-py/web/backend/services/login_failure.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

69 lines
2.9 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.
"""登录失败分型:把「失败消息」映射成稳定的型别。
## 为什么要单独一个模块
这个分类原先只活在 `automation/jobs/ck_relogin.py`(自动重登作业)里。但
**手动/批量登录**同样需要它:
1. 账号时间线要写「登录失败」事件,写的时候得带型别,否则以后想筛
「验证码失败的账号」只能去猜中文;
2. 导出 CSV 要给运营一列可直接筛选的型别。
放 service 而不是留在 job 里,是沿用仓库已有的分层决定
(`MANUAL_KINDS` 当初就是从 job 挪到 `account_lifecycle.py` 的,
注释写着「service 不该反过来依赖 job」)。
## 一条重要推论:`interrupted` ≠ 失败
分型把「已停止 / 被中断 / 服务重启」都归到 `interrupted`。**这不是账号的失败**:
调用方(`_mark_attempt_failed`)据此决定**不往账号时间线写失败事件** ——
否则运营按一次停止就能把几十个账号的时间线刷满假失败。
"""
from __future__ import annotations
#: 型别 → 中文标签(导出 CSV 与页面上用同一份,避免两处各写一遍漂移)。
FAILURE_KIND_LABELS: dict[str, str] = {
"captcha": "极验/验证码拦截",
"credential": "账号凭证问题",
"email_code": "邮箱取码失败",
"email_quota": "邮箱配额已满",
"network": "网络/未知异常",
"interrupted": "被中断(停止/重启)",
}
#: 不写进账号时间线的型别:这些是**人的动作或进程重启**,不是账号状况。
NOT_AN_ACCOUNT_FAULT: frozenset[str] = frozenset({"interrupted"})
def classify_failure(message: str | None) -> str:
"""把重登/登录失败消息映射成分型(见规划 §7.4.3)。
顺序很重要:「邮箱验证码发送次数已达上限」既含「验证码」也含「上限」,
必须先在 ``email_quota`` 命中,否则会被归到可重试的 ``email_code`` 上,
导致次日才该重试的账号被按小时反复撞配额。
"""
text = message or ""
if "上限" in text or "配额" in text:
return "email_quota"
if "极验" in text or "验证" in text and "滑块" in text:
return "captcha"
if "验证码" 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:
return "credential"
# 请求失败/超时/连接异常,以及未知文案:都按可重试处理。
# 宁可多给一次机会(有每日上限兜底),也不要把可能只是抖动的账号推给人工。
return "network"
def is_account_fault(kind: str | None) -> bool:
"""这个型别算不算「账号出了问题」——不算就不该写进账号一生。"""
return (kind or "") not in NOT_AN_ACCOUNT_FAULT
def failure_kind_label(kind: str | None) -> str:
return FAILURE_KIND_LABELS.get(kind or "", "")