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,对应用例均变红)。
This commit is contained in:
yml2213
2026-10-07 12:54:57 +08:00
parent 281769b440
commit d11fa1032d
8 changed files with 598 additions and 49 deletions
@@ -43,6 +43,13 @@ from sqlalchemy.orm import Session
# 命名保持「名词_状态」风格,便于前端归类与筛选。
EVENT_IMPORTED = "imported" # 账号导入(读 accounts.created_at,一般不落表)
EVENT_LOGIN_OK = "login_ok" # 登录成功(含新 CK)
#: 登录/重登**失败**(手动与批量登录都走这条)。
#:
#: 2026-10-07 补:此前只有成功会写事件,失败只改 `login_tasks` 行 ——
#: 生产实测 247 次登录失败在账号时间线上**一条痕迹都没有**(成功却有 1,456 条),
#: 「这个账号为什么一直登录不上」在账号页上无从回答。
#: ⚠️ **被停止 / 被中断不算失败**,那种情况不写(见 `services/login_failure`)。
EVENT_LOGIN_FAILED = "login_failed"
EVENT_CK_INVALID = "ck_invalid" # CK 判定失效
EVENT_CK_CLEARED = "ck_cleared" # CK 被清除(人工清除 / 账号删除前的清理)
EVENT_RELOGIN_OK = "relogin_ok" # 自动重登成功
@@ -32,6 +32,7 @@ from ...account_lifecycle import (
)
from ...account_value import value_tier_expr
from ...douyu_service import cookie_holding_filter, cookie_operable_filter
from ...login_failure import classify_failure
from ...login_service import LoginBatchRunner, get_relogin_limits
JOB_KEY = "ck_relogin"
@@ -54,29 +55,6 @@ AUTO_MAX_TOTAL_TIME = 240
QUOTA_JITTER_MINUTES = 30
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 _cutoff(now: datetime) -> datetime:
return now
+68
View File
@@ -0,0 +1,68 @@
"""登录失败分型:把「失败消息」映射成稳定的型别。
## 为什么要单独一个模块
这个分类原先只活在 `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 "", "")
+42 -2
View File
@@ -12,7 +12,7 @@ from typing import cast
from loguru import logger
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, object_session
from core.douyu import DouyuLogin, create_login_api_strategy
from core.douyu.login import AccountLike
@@ -21,10 +21,16 @@ from core.douyu.proxy_fetcher import ProxyFetcher
from ..models import Account as AccountModel
from ..models import LoginTask
from ..models import ProxyConfig as ProxyConfigModel
from .account_events_service import EVENT_LOGIN_OK, EVENT_RELOGIN_OK, emit
from .account_events_service import (
EVENT_LOGIN_FAILED,
EVENT_LOGIN_OK,
EVENT_RELOGIN_OK,
emit,
)
from .account_lifecycle import on_ck_verdict, on_login_success
from .cookie_check_service import check_douyu_cookie
from .douyu_service import LOGIN_ATTEMPT_IN_FLIGHT_STATUSES
from .login_failure import classify_failure, failure_kind_label, is_account_fault
CHECK_STATUS_MESSAGES = {
"account_cancelled": "账号已注销",
@@ -121,6 +127,15 @@ def _mark_attempt_failed(
这里读的是 `task.account.cookie`(CK 自 2026-10-04 起挂在账号上),
而不是这条登录记录 —— 记录不再持有凭证。
顺带往**账号时间线**写一条失败事件(这是账号级的资产变化:这个账号登录不上)。
两条边界:
- **被停止 / 被中断不写**:`classify_failure` 把它们归到 `interrupted`,
那是人的动作或进程重启,不是账号出了状况。不挡住的话,
运营按一次「停止」就能给几十个账号的时间线刷满假失败。
- **幂等**:一次尝试一条(`source_ref` 带 task id + 尝试时刻),
失败重试会新建记录或新 attempt,因此是新的一条 —— 这是对的,那是新的事实。
"""
completed_at = datetime.now(UTC)
task.attempt_status = status if status in {"failed", "error"} else "failed"
@@ -133,6 +148,31 @@ def _mark_attempt_failed(
task.message = message
task.finished_at = completed_at
kind = classify_failure(message)
if not is_account_fault(kind):
return
db = object_session(task)
if db is None or account is None:
# 没有会话(纯内存构造的用例)或没有账号:跳过旁路事件,绝不影响主流程。
return
emit(
db,
account_id=account.id,
event_type=EVENT_LOGIN_FAILED,
title=f"登录失败:{message}",
occurred_at=completed_at,
detail={
"message": message,
"kind": kind,
"kind_label": failure_kind_label(kind),
"attempt_status": task.attempt_status,
},
source="login",
subject_type="login",
subject_key=kind,
source_ref=f"login_task:{task.id}:failed:{int(completed_at.timestamp())}",
)
def _write_login_success(
db: Session,