- 高价值套装精确匹配「套装-兔兔白日梦」与「套装-狂战萌兔」,排除头盔/背包/手榴弹散件干扰 - 增加 is_suit_queried_expr,未查询或 NULL 账号严禁误判为双套均可兑换 - 修复前后端序列化空值保持,完善列表与详情页准入状态和判定依据展示 - 新增头盔排除与 NULL 统计防错回归单测,全量通过 689 个用例
105 lines
5.1 KiB
Markdown
105 lines
5.1 KiB
Markdown
# 事故复盘:登录任务误删导致 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`,别在容器里找工具。
|