一、失败入账号一生(此前完全缺失)
生产实测:`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,对应用例均变红)。
281 lines
12 KiB
Python
281 lines
12 KiB
Python
"""账号事件流:写入与查询。
|
||
|
||
## 定位
|
||
|
||
`accounts` 存**当前状态**(积分、余额、开通标记),这张表存**发生过什么**。
|
||
账号详情页 = 状态卡(读 `accounts`)+ 时间线(读本表)。
|
||
|
||
## 唯一写入路径:直接发射
|
||
|
||
业务事实**在它发生的那一刻**由业务代码发射(`emit` 或 `business_events` 里的命名
|
||
构造函数)。曾经有过第二条路径 —— 一个作业事后扫 `douyu_tasks.result` 的 JSON、
|
||
猜字段路径反推事件。那条路已经删掉,原因是它必然漏数据:
|
||
|
||
- 靠 allowlist 认业务类型 → 新增类型没人想起维护,**静默丢**(电竞兑换、和平小店
|
||
兑换、锁单支付、电竞礼物积分因此从来没进过时间线);
|
||
- 「任务 success」不等于「业务真的发生了」(预约、已绑定、未确认到账都会被标 success);
|
||
- 作业默认关闭,生产上它一直没跑。
|
||
|
||
历史**不从 `douyu_tasks` 回填**。需要历史时按斗鱼侧权威接口同步
|
||
(`query_exchange_records` / `query_xpd_purchase_records` / `query_points` 都是现成任务类型)
|
||
—— 宁可少,不可假。
|
||
|
||
## 两条硬约束
|
||
|
||
- **发射是旁路**:`emit` 任何失败都不能影响登录/兑换主流程(内部吞异常 + 记日志,
|
||
并用 SAVEPOINT 隔离,避免污染调用方的事务);
|
||
- **幂等**:`source_ref` 唯一键兜住「收集器重跑」「事件重复发射」。没有它,重跑一次
|
||
时间线就多一份重复记录。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
from loguru import logger
|
||
from sqlalchemy import text
|
||
from sqlalchemy.exc import IntegrityError
|
||
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" # 自动重登成功
|
||
EVENT_RELOGIN_FAILED = "relogin_failed" # 自动重登失败(转人工/退避)
|
||
EVENT_BIND_OK = "bind_ok" # 绑定/换绑
|
||
EVENT_RECHARGE = "recharge" # 充值到账
|
||
#: 事件来源:从斗鱼**官方兑换记录**补录(区别于任务执行时当场记的)
|
||
OFFICIAL_SYNC_SOURCE = "official_sync"
|
||
|
||
EVENT_EXCHANGE = "exchange" # 兑换(含消耗积分)
|
||
EVENT_POINTS_CREDIT = "points_credit" # 积分到账(含到期时间)
|
||
EVENT_POINTS_EXPIRE = "points_expire" # 积分到期(资产减少)
|
||
EVENT_GOLD_SPENT = "gold_spent" # 鱼翅消耗(送礼等)
|
||
EVENT_ELITE_OPENED = "elite_opened" # 精英宝典开通
|
||
EVENT_ESPORTS_OPENED = "esports_opened" # 电竞手册开通
|
||
EVENT_ASSIGNED = "assigned" # 分配客服
|
||
EVENT_TAG_CHANGED = "tag_changed" # 标签变更
|
||
EVENT_REMARK_CHANGED = "remark_changed" # 备注变更
|
||
EVENT_STATUS_CHANGED = "status_changed" # 生命周期状态变迁(停用/归档/失效…)
|
||
|
||
# 有符号金额的事件类型(用于页面「累计充值/累计消耗」):
|
||
# 充值为正、兑换为负,其余不计入金额统计。
|
||
INCOME_TYPES = (EVENT_RECHARGE,)
|
||
OUTCOME_TYPES = (EVENT_EXCHANGE,)
|
||
|
||
# 粗分类:决定时间线怎么分组、以及哪些事件算「业务台账」。
|
||
CATEGORY_LIFECYCLE = "lifecycle" # 生命周期状态变迁
|
||
CATEGORY_AUTH = "auth" # 登录与 CK
|
||
CATEGORY_BUSINESS = "business" # 充值 / 兑换 / 积分 / 开通
|
||
CATEGORY_OPS = "ops" # 人工操作(分配 / 标签 / 停用)
|
||
|
||
|
||
def _is_duplicate_entry(exc: BaseException) -> bool:
|
||
"""是否 MySQL 唯一键冲突(1062)。
|
||
|
||
只有**确认**是撞唯一键才降级成 DEBUG;其它完整性错误(例如非空约束)
|
||
仍然是真问题,必须继续 WARNING —— 别把真错误一起静音了。
|
||
"""
|
||
code = getattr(getattr(exc, "orig", None), "args", None)
|
||
if code and code[0] == 1062:
|
||
return True
|
||
return "Duplicate entry" in str(exc)
|
||
|
||
|
||
def to_naive_utc(value: datetime) -> datetime:
|
||
"""把时间归一成 **naive UTC**(本服务所有时间列的落库形态)。
|
||
|
||
约定与 `database.UTCDateTime` 一致,也是全仓通行写法:
|
||
|
||
- **naive → 视为「已经是 UTC」,原样使用**;
|
||
- aware → 换算到 UTC 后去掉 tzinfo。
|
||
|
||
⚠️ 绝对不能直接写 `value.astimezone(UTC)`:对 naive 值,Python 会把它当作
|
||
**服务器本地时间**再换算。本机 UTC+8 时事件时间会被整整扣掉 8 小时,
|
||
而 naive 值的来源很常见 —— 用**裸 SQL** 读 DATETIME 列(如收集器读
|
||
`douyu_tasks.finished_at`)拿到的就是 naive,因为它绕过了 `UTCDateTime`
|
||
装饰器。仓库里 `scheduler._as_utc` / `yyb_service._as_utc` / `audit.py`
|
||
都遵守了这条约定,本函数与它们同口径。
|
||
"""
|
||
if value.tzinfo is None:
|
||
return value
|
||
return value.astimezone(UTC).replace(tzinfo=None)
|
||
|
||
|
||
def _now() -> datetime:
|
||
return datetime.now(UTC)
|
||
|
||
|
||
_INSERT_SQL = text(
|
||
"""
|
||
INSERT INTO account_events (
|
||
account_id, event_type, category, occurred_at, title,
|
||
amount, balance_after, subject_type, subject_key,
|
||
detail, source, actor_type, actor_id, source_ref, created_at
|
||
) VALUES (
|
||
:account_id, :event_type, :category, :occurred_at, :title,
|
||
:amount, :balance_after, :subject_type, :subject_key,
|
||
:detail, :source, :actor_type, :actor_id, :source_ref, :created_at
|
||
)
|
||
"""
|
||
)
|
||
|
||
|
||
def _event_params(
|
||
*,
|
||
account_id: int,
|
||
event_type: str,
|
||
title: str,
|
||
occurred_at: datetime | None = None,
|
||
amount: int | None = None,
|
||
balance_after: int | None = None,
|
||
detail: dict[str, Any] | None = None,
|
||
source: str = "manual",
|
||
source_ref: str | None = None,
|
||
category: str = "",
|
||
subject_type: str = "",
|
||
subject_key: str = "",
|
||
actor_type: str = "",
|
||
actor_id: int | None = None,
|
||
) -> dict[str, Any]:
|
||
"""把一次调用归一成落库参数(`emit` 与 `emit_bulk` 共用,避免 SQL 写两遍)。"""
|
||
return {
|
||
"account_id": account_id,
|
||
"event_type": event_type,
|
||
"category": (category or "")[:16],
|
||
"occurred_at": to_naive_utc(occurred_at or _now()),
|
||
"title": (title or "")[:256],
|
||
"amount": amount,
|
||
"balance_after": balance_after,
|
||
"subject_type": (subject_type or "")[:16],
|
||
"subject_key": (subject_key or "")[:96],
|
||
"detail": json.dumps(detail, ensure_ascii=False, default=str)
|
||
if detail
|
||
else None,
|
||
"source": source[:24],
|
||
"actor_type": (actor_type or "")[:16],
|
||
"actor_id": actor_id,
|
||
"source_ref": (source_ref or None),
|
||
"created_at": to_naive_utc(_now()),
|
||
}
|
||
|
||
|
||
def emit(
|
||
db: Session,
|
||
*,
|
||
account_id: int,
|
||
event_type: str,
|
||
title: str,
|
||
occurred_at: datetime | None = None,
|
||
amount: int | None = None,
|
||
balance_after: int | None = None,
|
||
detail: dict[str, Any] | None = None,
|
||
source: str = "manual",
|
||
source_ref: str | None = None,
|
||
category: str = "",
|
||
subject_type: str = "",
|
||
subject_key: str = "",
|
||
actor_type: str = "",
|
||
actor_id: int | None = None,
|
||
) -> bool:
|
||
"""记录一条账号事件。返回是否真的写入(幂等命中/异常时为 False)。
|
||
|
||
**绝不抛异常**:事件是旁路,不能因为记不上一条时间线就让登录或兑换失败。
|
||
用 SAVEPOINT 包住,失败只回滚这一条,不影响调用方已经做过的事。
|
||
|
||
`occurred_at` 是**业务发生时间**,可以是 aware,也可以是 naive
|
||
(naive 按约定视为已是 UTC,见 `to_naive_utc`)。
|
||
|
||
`category` / `subject_*` / `actor_*` 支撑台账检索:
|
||
例如「该账号该商品最近一次兑换」走
|
||
`(account_id, event_type, subject_key, occurred_at)` 复合索引。
|
||
"""
|
||
if not account_id or not event_type:
|
||
return False
|
||
|
||
params = _event_params(
|
||
account_id=account_id,
|
||
event_type=event_type,
|
||
title=title,
|
||
occurred_at=occurred_at,
|
||
amount=amount,
|
||
balance_after=balance_after,
|
||
detail=detail,
|
||
source=source,
|
||
source_ref=source_ref,
|
||
category=category,
|
||
subject_type=subject_type,
|
||
subject_key=subject_key,
|
||
actor_type=actor_type,
|
||
actor_id=actor_id,
|
||
)
|
||
|
||
try:
|
||
# SAVEPOINT:即使这条插入失败(例如 source_ref 撞唯一键),
|
||
# 也只回滚它自己,调用方事务里已完成的写入不受影响。
|
||
with db.begin_nested():
|
||
db.execute(_INSERT_SQL, params)
|
||
return True
|
||
except IntegrityError as exc:
|
||
# 撞唯一键 = **幂等命中**,是预期内的正常情况,不是故障。
|
||
#
|
||
# 典型场景:一键查询会把斗鱼官方兑换记录补成事件,而同一个账号会被
|
||
# 反复查询 —— 第二次开始每一条记录都会撞 source_ref。
|
||
# 以前这里打 WARNING 并把**整条 SQL + 参数**dump 出来,
|
||
# 一次查询就能刷几十屏,既淹掉真日志、又让人以为出错了(用户报过)。
|
||
# 所以降成 DEBUG:重复是设计的一部分。
|
||
if _is_duplicate_entry(exc):
|
||
logger.debug(
|
||
f"账号事件已存在,跳过(account={account_id} {event_type} "
|
||
f"source_ref={source_ref})"
|
||
)
|
||
return False
|
||
logger.warning(f"写入账号事件失败(account={account_id} {event_type}):{exc}")
|
||
return False
|
||
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
|
||
logger.warning(f"写入账号事件失败(account={account_id} {event_type}):{exc}")
|
||
return False
|
||
|
||
|
||
def emit_bulk(db: Session, events: list[dict[str, Any]]) -> int:
|
||
"""一次写多条事件,返回实际写入条数。
|
||
|
||
用于**批量账号操作**(批量分配 / 批量改标签):一次操作可能覆盖几千个账号,
|
||
每个账号的时间线都该各留一条「谁在什么时候改了什么」。逐条走 `emit()` 会给
|
||
每条开一个 SAVEPOINT,几千条时请求明显变慢 —— 这里改成一次 executemany,
|
||
整批包在**一个** SAVEPOINT 里。
|
||
|
||
与 `emit()` 一样**绝不抛异常**:整批失败就整批丢弃(记 warning),
|
||
不会污染调用方的事务,也不会让「批量分配」因为记不上时间线而失败。
|
||
|
||
每个 dict 的键与 `emit()` 的关键字参数同名(`account_id` / `event_type` /
|
||
`title` 必填)。
|
||
"""
|
||
rows = [
|
||
_event_params(**{**item, "occurred_at": item.get("occurred_at")})
|
||
for item in events
|
||
if item.get("account_id") and item.get("event_type")
|
||
]
|
||
if not rows:
|
||
return 0
|
||
|
||
try:
|
||
with db.begin_nested():
|
||
db.execute(_INSERT_SQL, rows)
|
||
return len(rows)
|
||
except Exception as exc: # noqa: BLE001 - 旁路,任何失败都不上抛
|
||
logger.warning(f"批量写入账号事件失败({len(rows)} 条):{exc}")
|
||
return 0
|