`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 账号页重构。
337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""账号全生命周期:把登录态与生命周期状态落到 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)
|