feat(account): S3 生命周期状态收口 —— 6 状态状态机、唯一推导入口、停用/归档闭环
`status` 列从「空壳」变成可信状态:S1 建了列、S2 让账号独立,本提交让状态真正
反映账号处境,并把「哪些账号要人处理」变成可筛选的事实。
## 状态机(7 → 6 个状态)
`imported` / `active` / `invalid` / `manual` / `disabled` / `archived`
**砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`,
独立成状态就多出一个必须与 `relogin_next_at` 同步的字段 —— 正是要消灭的「双份真相」。
## 唯一推导入口(防「双份真相」)
新增 `services/account_lifecycle.py`:
- `derive_status(account)` —— **纯函数**,按 cookie / ck_check_status / relogin_*
推导状态;人工态(disabled/archived)优先级最高,不被覆盖;
- `sync_status(db, account)` —— 推导 + 变了才写,**幂等**(没变则不写库、不发事件,
否则巡检每轮都往时间线刷一条);
- `set_manual_state(db, account, state)` —— 停用/归档/`auto`(恢复自动推导);
- `on_login_success(db, account)` —— **「登录成功对账号意味着什么」只定义一次**:
作废本轮重登退避痕迹 + 重算状态。于是**「待人工」的账号被人工登录救回来时状态
会自动恢复**,而不是卡在 manual。
- `MANUAL_KINDS` 从 `jobs/ck_relogin.py` 挪到这里(service 不该反向依赖 job)。
**9 处底层判据写入点全部接线**:登录成功、CK 清除(单个/批量)、ck_health 巡检、
人工 CK 检测、工作台执行前检测、重登复检、重登结算(成功/失败退避)。
## 消费端:补上一个真缺口 + 删掉两处临时派生
- ⚠️ **缺口**:`ck_health` / `ck_relogin` / `count_pending_relogin` 此前只按底层判据
选行,**没排除 disabled/archived** —— S2 提交里已标注,状态一上线就会变成
「停用的账号照样被自动重登」。现已全部排除。
- `automation.manual_queue` 原本用 4 个条件自己拼「需要人工」,现在一句
`status == 'manual'`。同一判据从「3 处各写一遍」收敛成「1 处推导」。
## 接口与前端
- `PUT /api/accounts/{id}/lifecycle`(disabled/archived/auto + 原因,写审计)
- `PUT /api/accounts/{id}/automation`(**接上死开关**:`auto_managed` 此前没有任何
接口/前端能改,永远是默认 True)
- 新增权限点 `account:manage`(运营默认拥有)
- 列表支持 `?status=` 筛选;**归档默认不出现**(要看需显式筛选)
- `AccountOut` / 概览接口补 status / status_label / status_reason / auto_managed
- 前端:账号列表加「状态」列与状态筛选;详情页状态卡 + 停用/归档/恢复/自动化开关
## 两个自查出来的问题(都已修)
1. **幂等键反而丢事件**:状态事件一开始用 `status:{id}:{target}:{秒级时间戳}` 作
`source_ref`,结果同一账号在同一秒内两次切到同一状态就撞唯一键,`emit` 静默吞掉
—— **状态改了、时间线却少一条**,正是本模块要消灭的「悄悄不一致」。
改为不传 `source_ref`:`sync_status` 本身已幂等,不存在重复写入路径。已加用例。
2. **迁移与运行时规则分叉**:迁移 0042 的状态回填 CASE 原本会写出已被砍掉的
`relogin`,且判据与 `derive_status` 不一致(次数用尽的会回填成 invalid 而非 manual)。
已改成逐字对应,并加一条用例**真跑那段 SQL 与纯函数逐条比对**(已验证:
临时改回旧 CASE 该用例立刻红)。
## 验证
- `TZ=Asia/Shanghai`:**404 passed / 6 skipped**(S2 后 373 → 新增 31 条)
- 迁移链 3 passed;ruff check/format 全绿;前端 `tsc -b && vite build` 通过
- 真实开发库核对:落库状态分布 active 4 / imported 8,**与 derive_status 逐账号比对无漂移**
- 新增用例覆盖:derive 真值表(每状态入口 + 人工态优先)、sync 幂等、
人工态切换、**全生命周期走查**(导入→登录→失效→重登→转人工→人工救回→清 CK→停用)、
自动化排除 disabled/archived、列表状态筛选、接口
未做(后续阶段):S4 事件发射点补齐、S5 业务事实直发事件、S6 账号页重构。
This commit is contained in:
+51
-28
@@ -159,6 +159,9 @@
|
||||
|
||||
### 3.4 生命周期状态机
|
||||
|
||||
> **S3 已落地(2026-10-04)**,实现见 `web/backend/services/account_lifecycle.py`。
|
||||
> 落地时把原来的 7 个状态**收敛成 6 个**:砍掉了 `relogin`(理由见下)。
|
||||
|
||||
```
|
||||
导入
|
||||
│
|
||||
@@ -166,42 +169,63 @@
|
||||
┌─────────┐ 登录成功 ┌────────┐ 巡检判定失效 ┌─────────┐
|
||||
│imported │────────────▶│ active │───────────────▶│ invalid │
|
||||
└─────────┘ └────────┘ └────┬────┘
|
||||
│ ▲ ▲ │ 自动重登(可退避)
|
||||
│ 人工补 CK / 手动登录 │ │ 重登成功 ▼
|
||||
└────────────────────┘ │ ┌──────────┐
|
||||
│ │relogin │
|
||||
│ └────┬─────┘
|
||||
│ │ 失败转人工 / 连续失败达上限
|
||||
▲ ▲ ▲ │ 自动重登(可退避)
|
||||
│ 清空 CK │ │ 重登成功/人工登录救回 │
|
||||
└────────────────────┘ │ ▼
|
||||
│ ┌─────────────────┐
|
||||
│ │ 极验/凭证问题 │
|
||||
│ │ 或重试次数用尽 │
|
||||
│ └────────┬────────┘
|
||||
│ ▼
|
||||
│ ┌─────────┐
|
||||
└──────────────────│ manual │
|
||||
人工处理成功 └────┬────┘
|
||||
│ 人工停用
|
||||
▼
|
||||
┌──────────┐ 归档 ┌──────────┐
|
||||
│ disabled │───────▶│ archived │
|
||||
└──────────┘ └──────────┘
|
||||
│ 人工停用 / 归档
|
||||
▼
|
||||
┌──────────┐ ┌──────────┐
|
||||
│ disabled │◀──────▶│ archived │
|
||||
└──────────┘ └──────────┘
|
||||
两者可互换,也可 restore 回自动态
|
||||
```
|
||||
|
||||
| 状态 | 含义 | 能否跑业务 | 自动化是否覆盖 |
|
||||
| 状态 | 判据(`derive_status`) | 能否跑业务 | 自动化 |
|
||||
| --- | --- | --- | --- |
|
||||
| `imported` | 已导入,从未拿到 CK | ✗ | 不巡检(没 CK 可检) |
|
||||
| `active` | 有 CK,且未被判失效 | ✅ | 参与巡检 |
|
||||
| `invalid` | CK 已判失效,等待自动重登 | ✗ | 参与重登 |
|
||||
| `relogin` | 正在退避重试中 | ✗ | 参与重登 |
|
||||
| `manual` | 重登失败已转人工(极验/凭证) | ✗ | **不参与**(避免刷屏) |
|
||||
| `disabled` | 人工停用 | ✗ | 不参与 |
|
||||
| `archived` | 归档,默认列表不显示 | ✗ | 不参与 |
|
||||
| `imported` | 没有 CK | ✗ | 不巡检(没 CK 可检) |
|
||||
| `active` | 有 CK,且 `ck_check_status != 'invalid'` | ✅ | 参与巡检 |
|
||||
| `invalid` | 有 CK 但判失效,**还会自动救** | ✗ | 参与重登 |
|
||||
| `manual` | 判失效且**已放弃自动救**(极验/凭证,或试过却没排下次) | ✗ | **不参与**(避免刷屏) |
|
||||
| `disabled` | 人工停用(最高优先级,不被推导覆盖) | ✗ | 不参与 |
|
||||
| `archived` | 人工归档,默认列表不显示(同上) | ✗ | 不参与 |
|
||||
|
||||
**为什么砍掉 `relogin`**:它完全等价于 `invalid + relogin_next_at is not None`。
|
||||
独立成状态就多出一个**必须和 `relogin_next_at` 同步**的字段 —— 正是下面要消灭的
|
||||
「双份真相」。想筛「正在退避重试的」直接查那个字段即可。
|
||||
|
||||
**状态与判据的分工(重要)**:
|
||||
|
||||
- `status` 是**人可读的当前阶段**,落库、可筛选、可统计;
|
||||
- `status` **不替代**底层判据:能不能跑业务仍然要看 `cookie != '' AND ck_status != 'invalid'`;
|
||||
- **写入收口**:所有状态变迁统一走一个服务(`account_lifecycle.set_status()`),
|
||||
禁止各处直接赋值 `account.status = ...` —— 这是 `bind_status` 崩掉的直接教训。
|
||||
- `status` 是**人可读的当前阶段**,落库、可索引筛选、可统计;
|
||||
- `status` **不替代**底层判据:能不能跑业务仍然要看 `cookie != '' AND ck_check_status != 'invalid'`;
|
||||
- **写入收口**:状态只由 `account_lifecycle` 写 —— `sync_status()`(按判据推导)
|
||||
与 `set_manual_state()`(人工态)。**禁止各处直接赋值 `account.status = ...`**,
|
||||
这是 `bind_status` 崩掉的直接教训。
|
||||
|
||||
> `auto_managed` 保留,但它与 `disabled` 的区别要写进 UI:`disabled` = 账号不可用;
|
||||
> `auto_managed=false` = 账号可用,只是不自动跑(人工维护的特殊账号)。
|
||||
> `auto_managed` 保留,与 `disabled` 的分工写进了 UI 与接口注释:
|
||||
> `disabled` = 账号不可用;`auto_managed=false` = 账号可用,只是不自动跑。
|
||||
> **S3 之前 `auto_managed` 是个死开关**(没有任何接口/前端能改它),本次一并接上。
|
||||
|
||||
### 3.4.1 防漂移:三层防线
|
||||
|
||||
`status` 落库意味着**两份真相**(底层判据 + 摘要),谁忘了同步页面就撒谎。防线:
|
||||
|
||||
1. **收口**:推导逻辑只有 `derive_status()` 一份纯函数;所有写底层判据的地方
|
||||
(`cookie` / `ck_check_status` / `relogin_*`,共 **9 处**)在其后调用 `sync_status()`;
|
||||
2. **幂等**:状态没变时 `sync_status()` 返回 `False`,**不写库、不发事件**
|
||||
—— 否则巡检每轮都会给时间线刷一条;
|
||||
3. **迁移与运行时同规则**:迁移 `0042` 的回填 SQL 与 `derive_status()` 必须一致;
|
||||
有一条用例**真跑那段 SQL 与纯函数逐条比对**。分叉了不会报错,只会表现为
|
||||
「迁移刚跑完是 A、下轮巡检又改成 B」。
|
||||
|
||||
另有 `warn_if_drift()` 供巡检顺手观测 —— 但**真正的修复是补上 `sync_status`,不是硬改**。
|
||||
|
||||
### 3.5 `account_events`(时间线 + 业务台账)
|
||||
|
||||
@@ -235,8 +259,7 @@ CREATE INDEX ix_account_events_ledger
|
||||
| category | event_type | 含义 | amount |
|
||||
| --- | --- | --- | --- |
|
||||
| lifecycle | `imported` | 导入 | — |
|
||||
| lifecycle | `status_changed` | 生命周期状态变迁(detail 里带 from/to) | — |
|
||||
| lifecycle | `disabled` / `archived` | 停用 / 归档 | — |
|
||||
| lifecycle | `status_changed` | **所有**状态变迁(detail 带 from/to/reason),停用与归档不再单列事件类型 | — |
|
||||
| auth | `login_ok` | 登录成功(拿到新 CK) | — |
|
||||
| auth | `ck_invalid` | 巡检判定 CK 失效 | — |
|
||||
| auth | `relogin_ok` / `relogin_failed` | 自动重登结果 | — |
|
||||
@@ -405,7 +428,7 @@ WHERE a.cookie <> src.cookie;
|
||||
| --- | --- | --- | --- |
|
||||
| **S1** | 迁移 0042:加列 + 回填 + 自校验;`accounts` 成为状态载体 | 账号能独立描述登录态 | — |
|
||||
| **S2** | 读写一次切换:12 个文件、24 读点 / 10 写点全部改 `LoginTask.*` → `Account.*`;批次行复用逻辑重写;在途子查询 + 索引 | 登录记录与 CK 彻底解耦 | S1 |
|
||||
| **S3** | 状态收口:`account_lifecycle.set_status()` 唯一写入口;`ck_health` / `ck_relogin` / 登录批次全部改走它;生命周期状态机上线 | `status` 可信 | S2 |
|
||||
| **S3** ✅ | 状态收口(**已完成 2026-10-04**):`account_lifecycle` 提供 `derive_status()` / `sync_status()` / `set_manual_state()` / `on_login_success()`;9 处判据写入点全部接线;6 状态状态机上线;自动化选行排除 `disabled`/`archived`;新增 `account:manage` 权限与停用/归档/恢复接口;前端列表加状态列与筛选、详情页加状态卡与操作入口 | `status` 可信 | S2 |
|
||||
| **S4** | `account_events` 扩展(4 列 + 索引)+ 事件发射点补齐(含 `audit_logs.account_id` 打通) | 时间线完整 | S2 |
|
||||
| **S5** | 业务事实直发事件:开通 / 充值 / 积分 / 兑换 / 绑定在任务成功时写事件;`douyu_tasks` 加保留策略 | 台账可用,687MB 可控 | S4 |
|
||||
| **S6** | 账号页重构:列表加状态与关键列;详情页做成「生命周期主页」(状态卡 + 台账 + 时间线 + 操作);`bind_status` 三列拆解落地 | 账号管理真正独立 | S3、S5 |
|
||||
|
||||
Reference in New Issue
Block a user