Files
live-hub-py/web/backend/migrations/versions/20261003_0042_account_lifecycle.py
yml2213 1a8710834a 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 账号页重构。
2026-10-04 14:48:22 +08:00

337 lines
12 KiB
Python
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.
"""账号全生命周期:把登录态与生命周期状态落到 accounts,并扩展账号事件台账。
## 为什么
1. **CK 不该寄存在登录记录上**。`login_tasks.cookie` 是账号的**登录态**,不是「某次登录的
产物」。删一条历史记录就等于删掉账号的登录态 —— 这是 2026-10-03 误删事故的根因。
2. **CK 健康与重登进度** 同样挂在「碰巧最后一次登录那一行」上;一个账号有多行持 CK 时
(实测存在)谁是权威并不明确。
3. **生命周期状态根本不存在**,只能靠「有没有持 CK 的记录」反推。
4. `account_events` 除了时间线,还要承担**业务台账**(开通/积分/兑换/充值),
需要可检索的客体键与索引来支撑「365 天同皮肤」这类规则。
设计依据:`docs/设计-账号全生命周期.md`。
## 安全性(本迁移只加列、只读源表,不删不改任何旧数据)
- **分块回填**:1.4 万账号、单账号 CK 约 1.7KB,一次性 UPDATE 会长时间持锁;
- **幂等**:只在 `accounts.cookie` 为空时写,可重跑;也给回填后新写入的 CK 让路;
- **自校验 fail closed**:回填不完整就让迁移抛错(宁可起不来,也不要带着半份 CK 上线);
- **值级校验**:不只比条数 —— 条数相等仍可能张冠李戴(A 账号拿到 B 账号的 CK)。
旧列本次**全部保留**,下一次发版再 drop。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261003_0042"
down_revision: str | None = "20261003_0041"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# 一次回填多少账号。太小则往返多,太大则单事务持锁久。
BACKFILL_CHUNK = 200
_ACCOUNT_COLUMNS: tuple[tuple[str, sa.types.TypeEngine, bool], ...] = (
# ── 登录态:CK 本体 ──
("cookie", sa.Text(), False),
("cookie_updated_at", sa.DateTime(), True),
("cookie_source", sa.String(length=16), False),
# ── 登录态:CK 健康 ──
("ck_check_status", sa.String(length=16), False),
("ck_check_result", sa.JSON(), True),
("ck_checked_at", sa.DateTime(), True),
# ── 登录态:重登退避进度 ──
("relogin_attempts", sa.Integer(), False),
("relogin_next_at", sa.DateTime(), True),
("relogin_last_error", sa.String(length=512), False),
("relogin_failure_kind", sa.String(length=16), False),
# ── 生命周期状态 ──
("status", sa.String(length=16), False),
("status_reason", sa.String(length=64), False),
("status_changed_at", sa.DateTime(), True),
("disabled_at", sa.DateTime(), True),
("archived_at", sa.DateTime(), True),
)
# 列名 → server_default(NOT NULL 的列必须有,否则已有行无法满足约束)
# ⚠️ `cookie` 是 TEXT,**不能**有 server_default(MySQL 报 1101:
# BLOB/TEXT/GEOMETRY/JSON column can't have a default value)。
# 已有行由 ALTER TABLE 隐式填 '',这里不配默认值即可。
_COLUMN_DEFAULTS: dict[str, str] = {
"cookie_source": "",
"ck_check_status": "",
"relogin_attempts": "0",
"relogin_last_error": "",
"relogin_failure_kind": "",
"status": "imported",
"status_reason": "",
}
_EVENT_COLUMNS: tuple[tuple[str, sa.types.TypeEngine, bool], ...] = (
("category", sa.String(length=16), False),
("subject_type", sa.String(length=16), False),
("subject_key", sa.String(length=96), False),
("actor_type", sa.String(length=16), False),
("actor_id", sa.Integer(), True),
)
def _tables(bind) -> set[str]:
return set(sa.inspect(bind).get_table_names())
def _columns(bind, table: str) -> set[str]:
return {item["name"] for item in sa.inspect(bind).get_columns(table)}
def _indexes(bind, table: str) -> set[str]:
return {item["name"] for item in sa.inspect(bind).get_indexes(table)}
def _add_columns(bind, table: str, columns) -> None:
"""幂等加列:已存在就跳过。"""
existing = _columns(bind, table)
for name, col_type, nullable in columns:
if name in existing:
continue
default = _COLUMN_DEFAULTS.get(name)
op.add_column(
table,
sa.Column(
name,
col_type,
nullable=nullable,
server_default=default if not nullable else None,
),
)
def _add_index(bind, table: str, name: str, columns: list[str]) -> None:
if name in _indexes(bind, table):
return
op.create_index(name, table, columns)
def _backfill_login_state(bind) -> None:
"""把每个账号「最近一条持有 CK 的记录」的 CK 与状态搬到 accounts。
只读 `login_tasks`,不删不改。分块执行。
"""
from sqlalchemy import bindparam
pairs = bind.execute(
sa.text(
"""
SELECT account_id, MAX(id) AS last_id
FROM login_tasks
WHERE cookie IS NOT NULL AND cookie <> ''
GROUP BY account_id
ORDER BY account_id
"""
)
).all()
if not pairs:
return
account_ids = [int(row[0]) for row in pairs]
stmt = sa.text(
"""
UPDATE accounts a
JOIN (
SELECT account_id, cookie, finished_at, ck_check_status,
ck_check_result, ck_checked_at, relogin_attempts,
relogin_next_at, relogin_last_error, relogin_failure_kind
FROM (
SELECT lt.account_id, lt.cookie, lt.finished_at, lt.ck_check_status,
lt.ck_check_result, lt.ck_checked_at, lt.relogin_attempts,
lt.relogin_next_at, lt.relogin_last_error,
lt.relogin_failure_kind,
ROW_NUMBER() OVER (
PARTITION BY lt.account_id ORDER BY lt.id DESC
) AS rn
FROM login_tasks lt
WHERE lt.cookie IS NOT NULL AND lt.cookie <> ''
AND lt.account_id IN :ids
) ranked
WHERE ranked.rn = 1
) src ON src.account_id = a.id
SET a.cookie = src.cookie,
a.cookie_updated_at = src.finished_at,
a.ck_check_status = COALESCE(src.ck_check_status, ''),
a.ck_check_result = src.ck_check_result,
a.ck_checked_at = src.ck_checked_at,
a.relogin_attempts = COALESCE(src.relogin_attempts, 0),
a.relogin_next_at = src.relogin_next_at,
a.relogin_last_error = COALESCE(src.relogin_last_error, ''),
a.relogin_failure_kind = COALESCE(src.relogin_failure_kind, '')
WHERE a.cookie IS NULL OR a.cookie = ''
"""
).bindparams(bindparam("ids", expanding=True))
for start in range(0, len(account_ids), BACKFILL_CHUNK):
chunk = account_ids[start : start + BACKFILL_CHUNK]
bind.execute(stmt, {"ids": chunk})
def _backfill_lifecycle_status(bind) -> None:
"""按登录态推导生命周期状态。
⚠️ **这里的 CASE 必须与 `services/account_lifecycle.derive_status()` 逐字对应。**
两边规则一旦分叉,迁移刚跑完就会有一批账号的落库状态与运行时推导不一致
(表现为「回填说是 A、下一轮巡检又改成 B」)。
状态取值域 6 个:imported / active / invalid / manual / disabled / archived。
**没有 `relogin`** —— 「退避重试中」等价于 `invalid + relogin_next_at 非空`,
是 invalid 的调度细节,不单独成状态(S3 定稿时砍掉的)。
"""
bind.execute(
sa.text(
"""
UPDATE accounts
SET status = CASE
WHEN cookie IS NULL OR cookie = '' THEN 'imported'
WHEN COALESCE(ck_check_status, '') <> 'invalid' THEN 'active'
WHEN COALESCE(relogin_failure_kind, '') IN ('captcha', 'credential')
THEN 'manual'
WHEN COALESCE(relogin_attempts, 0) > 0 AND relogin_next_at IS NULL
THEN 'manual'
ELSE 'invalid'
END,
status_changed_at = COALESCE(status_changed_at, updated_at, created_at)
WHERE status = 'imported' OR status IS NULL
"""
)
)
def _verify_backfill(bind) -> None:
"""自校验:不完整 / 张冠李戴都直接抛错,让应用起不来(fail closed)。
`expected` 必须运行时算 —— 这个数在长(重登在跑)。
"""
expected = int(
bind.execute(
sa.text(
"""
SELECT COUNT(DISTINCT account_id) FROM login_tasks
WHERE cookie IS NOT NULL AND cookie <> ''
"""
)
).scalar()
or 0
)
actual = int(
bind.execute(
sa.text(
"SELECT COUNT(*) FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''"
)
).scalar()
or 0
)
if actual < expected:
raise RuntimeError(
f"CK 回填不完整:期望 {expected} 个账号,实际 {actual} 个。"
"中止迁移 —— 宁可起不来,也不要带着半份 CK 上线。"
)
mismatched = int(
bind.execute(
sa.text(
"""
SELECT COUNT(*) FROM accounts a
JOIN (
SELECT account_id, cookie,
ROW_NUMBER() OVER (
PARTITION BY account_id ORDER BY id DESC
) AS rn
FROM login_tasks
WHERE cookie IS NOT NULL AND cookie <> ''
) src ON src.account_id = a.id AND src.rn = 1
WHERE a.cookie <> src.cookie
"""
)
).scalar()
or 0
)
if mismatched:
raise RuntimeError(
f"CK 值级校验失败:{mismatched} 个账号的 CK 与其最近一条持 CK 记录不一致,"
"疑似张冠李戴。中止迁移。"
)
def upgrade() -> None:
bind = op.get_bind()
# 1. accounts 加列 + 索引
_add_columns(bind, "accounts", _ACCOUNT_COLUMNS)
_add_index(bind, "accounts", "ix_accounts_ck_checked_at", ["ck_checked_at"])
_add_index(
bind,
"accounts",
"ix_accounts_status_relogin_next",
["status", "relogin_next_at"],
)
_add_index(bind, "accounts", "ix_accounts_status", ["status"])
# 2. account_events 扩展(台账列 + 复合索引)
if "account_events" in _tables(bind):
_add_columns(bind, "account_events", _EVENT_COLUMNS)
_add_index(
bind,
"account_events",
"ix_account_events_ledger",
["account_id", "event_type", "subject_key", "occurred_at"],
)
# 3. login_tasks 补「在途判定」索引:CK 搬到账号后,「在途」只能留在登录记录上,
# 巡检/重登每轮都要按 (account_id, attempt_status) 过滤。
if "login_tasks" in _tables(bind):
_add_index(
bind,
"login_tasks",
"ix_login_tasks_account_attempt_status",
["account_id", "attempt_status"],
)
# 4. 回填 + 自校验
_backfill_login_state(bind)
_backfill_lifecycle_status(bind)
_verify_backfill(bind)
def downgrade() -> None:
bind = op.get_bind()
for name in (
"ix_login_tasks_account_attempt_status",
"ix_account_events_ledger",
"ix_accounts_status",
"ix_accounts_status_relogin_next",
"ix_accounts_ck_checked_at",
):
table = (
"login_tasks"
if name == "ix_login_tasks_account_attempt_status"
else (
"account_events" if name == "ix_account_events_ledger" else "accounts"
)
)
if table in _tables(bind) and name in _indexes(bind, table):
op.drop_index(name, table_name=table)
if "account_events" in _tables(bind):
existing = _columns(bind, "account_events")
for name, _type, _nullable in reversed(_EVENT_COLUMNS):
if name in existing:
op.drop_column("account_events", name)
existing = _columns(bind, "accounts")
for name, _type, _nullable in reversed(_ACCOUNT_COLUMNS):
if name in existing:
op.drop_column("accounts", name)