一、失败入账号一生(此前完全缺失)
生产实测:`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,对应用例均变红)。
69 lines
2.9 KiB
Python
69 lines
2.9 KiB
Python
"""登录失败分型:把「失败消息」映射成稳定的型别。
|
||
|
||
## 为什么要单独一个模块
|
||
|
||
这个分类原先只活在 `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 "", "")
|