Files
live-hub-py/docs/事故复盘-CK误删与恢复.md
yml2213 44b762ea8c fix(overview): 锁定核心高价值套装并修复未查询空值误判
- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰
- 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换
- 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示
- 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
2026-10-07 15:27:30 +08:00

105 lines
5.1 KiB
Markdown
Raw Permalink 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.
# 事故复盘:登录任务误删导致 5019 个账号的 CK 丢失(2026-10-03)
## 一句话结论
**斗鱼账号的 CK 只存在 `login_tasks.cookie` 这一个地方**(`accounts` 表没有 cookie 列)。
列表上显示的「状态」是**最近一次尝试**(`attempt_status`),于是一条显示「失败」的行
完全可能仍持有有效 CK。清空失败任务时按「显示状态」删除,就把 **5019 个账号的登录态**
一起删了。已通过 binlog 全部恢复。
## 影响
| 项 | 值 |
| --- | --- |
| 误删时间窗 | 2026-10-03 **02:18:46 ~ 02:25:43 UTC**(北京时间 10:18 ~ 10:25) |
| 删除方式 | 页面上连续点击删除,每次约 150~200 条,共 24 批 |
| 删除总行数 | **5601** 行 |
| 其中持有 CK | **5019** 行(5019 个账号) |
| 无 CK 的历史行 | 582 行 |
| 恢复结果 | 5601 行全部恢复;持有 CK 的记录 8246 → **13265** |
## 根因
数据模型决定的:
```
login_tasks.cookie ← 斗鱼 CK 的唯一存放处(enc:v1:... 加密)
accounts ← 没有 cookie 列
```
`cookie_holding_filter()`(`services/douyu_service.py`)就是靠它判断账号有没有 CK:
```python
LoginTask.cookie.isnot(None) & (LoginTask.cookie != "")
```
而列表接口序列化时:
```python
status = attempt_status or t.status # 显示的是「最近一次尝试」
message = (attempt_message if attempt_status else "") or t.message
```
两者不是一回事。生产实测当时有 **752 行**处于「显示 failed、但 cookie 非空」的状态
—— 每一条都对应一个仍可用的账号登录态。
新加的「清空失败」按**显示状态**筛:
```python
or_(
and_(attempt_status != "", attempt_status == "failed"),
and_(attempt_status == "", status == "failed"),
)
```
于是这 752 行连同其它 success 行一起被选中删除。**这不是单纯的边界疏漏,是把两个
语义不同的字段当成了同一个。**
## 修复
`routers/login.py`:
- 批量删除只删 **空 cookie** 的行,持有 CK 的一律跳过,响应里报告跳过数量;
- 单条删除遇到持有 CK 的行直接 409,提示「如需清理请先处理该账号的 CK」;
- 前端确认弹窗写明「持有 CK 的记录会跳过」;
- 回归测试补 5 条,专门覆盖「显示失败但持有 CK」这个场景(`tests/test_login_tasks_pagination.py::TestCkProtection`)。
判定用 `cookie`,**不用**显示状态 —— 这是这次事故最核心的一条。
## 恢复过程(可复用)
前提:生产 MySQL 是 **ROW 格式 binlog,保留 30 天**(`binlog_expire_logs_seconds=2592000`)。
没有定期逻辑备份,binlog 是唯一退路。
1. **定位时间窗**:先用 `automation_runs` 的「剩余待检」骤降(6249 → 3729)确认发生过删除,
再用 binlog 精确定位;
2. **加速定位**:`binlog.000041` 有 279MB,从头流式解析很慢。写了
`binlog_time_index.py`:**只读每个事件的 19 字节头**(时间戳/类型/大小),
按 `event_size` 跳跃前进、不解码行数据,秒级建立「时间 → 位置」索引,
把要解析的量从 279MB 降到 7MB;
3. **提取被删行**:`extract_deletes.py` 用 `pymysqlreplication` 流式读取
`DeleteRowsEvent`,拿到全部列的原始值(含加密 CK);
4. **验证可解**:抽样用 `decrypt_value()` 确认取回的 cookie 能解密(约 1670 字符);
5. **恢复**:`restore_deletes.py` 带原始 id `INSERT IGNORE` 回写,可重复执行。
### 途中踩的坑(重要)
| 坑 | 表现 | 解法 |
| --- | --- | --- |
| **MySQL 8.4 镜像没有 `mysqlbinlog`** | `find /` 也找不到,容器里无法解 binlog | 改用 Python 的 `pymysqlreplication`(临时装进应用容器 venv,事后卸载) |
| **解出的列名是 `UNKNOWN_COL0..19`** | 第一版用 `values["cookie"]` 永远取到空,得出「**0 条持有 CK**」的**假阴性** | 按 `information_schema` 的**列序号**映射。差点据此误判「没有损失」 |
| **管道接 `tail` 吞掉进度** | 后台任务看起来一直没输出 | 去掉 `tail`,并改用独立线程定时打印进度 |
| `json.dump` 遇到 datetime / bytes 键 | `ck_check_result` 的 JSON 键是 bytes | 递归清洗 + `default=str` |
> ⚠️ **最危险的是第二条**:假阴性会让人得出「没事」的结论。凡是靠脚本判定「有没有丢数据」,
> 都必须先验证解出来的值**非空且正确**(本例中先 dump 一行原始结构就发现了)。
## 沉淀下来的规则
1. **删除登录任务 = 删除该账号的 CK**。任何按条件批量删除的入口,判定必须基于 `cookie` 字段;
2. **显示状态 ≠ 数据状态**:`attempt_status` 是「最近一次尝试」,`status`/`cookie` 才是
「当前是否持有 CK」。写筛选、统计、删除时先想清楚要哪一个;
3. **binlog 是这类事故的唯一退路**:不要关 `log_bin`、不要缩短保留期;做批量删除类功能前
先确认恢复路径可用;
4. **生产镜像缺 `mysqlbinlog`**:需要解 binlog 时直接用 `pymysqlreplication`,别在容器里找工具。