feat(db): S1 迁移 0042 —— 账号全生命周期落库(登录态 + 状态机 + 事件台账)
账号不再依赖登录记录的第一步:把「账号自己的东西」搬到账号上。本提交**只加列、 只读源表**,不改任何行为,单独部署安全。 accounts 新增 15 列: - 登录态:cookie / cookie_updated_at / cookie_source - CK 健康:ck_check_status / ck_check_result / ck_checked_at - 重登进度:relogin_attempts / relogin_next_at / relogin_last_error / relogin_failure_kind - 生命周期:status / status_reason / status_changed_at / disabled_at / archived_at - 索引:ck_checked_at、status、与 (status, relogin_next_at) account_events 新增 5 列(category / subject_type / subject_key / actor_type / actor_id) 与台账复合索引 (account_id, event_type, subject_key, occurred_at) —— 支撑 「365 天同皮肤」这类需要检索客体的规则,让事件表同时承担业务台账。 login_tasks 补 (account_id, attempt_status) 索引:CK 搬到账号后,「在途尝试」只能留在 登录记录上,巡检/重登每轮都要按它过滤。 回填做成生产级: - 只读 login_tasks、分块 200 账号、幂等(只在 accounts.cookie 为空时写); - 窗口函数取「最近一条持 CK 的行」,不用 GROUP_CONCAT(受 max_len 限制会静默截断); - 自校验 fail closed:回填不完整直接抛错让应用起不来,宁可起不来也不带半份 CK 上线; - 外加值级校验,防张冠李戴(条数相等仍可能 A 账号拿到 B 账号的 CK)。 踩坑留档:cookie 是 TEXT,**不能有 server_default**(MySQL 1101: BLOB/TEXT/GEOMETRY/JSON column can not have a default value),模型与迁移两侧都要去掉。 验证: - 空库整链 upgrade head + 模型对齐 + 索引存在 → 通过; - 本机开发库实跑 0041→0042:记录侧 4 个持 CK 账号 = 账号侧 4 个,值级不一致 0, CK 仍是密文(enc:v1: 前缀),状态分布 active 4 / imported 8; - downgrade 到 0041 再 upgrade:数据完整还原(这是 CK 丢失场景的安全网); - 全量 pytest 364 passed / 6 skipped。
This commit is contained in:
@@ -127,6 +127,66 @@ class Account(Base):
|
||||
UTCDateTime, default=_utcnow, onupdate=_utcnow
|
||||
)
|
||||
|
||||
# ── 登录态:CK 本体(账号自己的凭证,不再寄存在 login_tasks 上)──────────
|
||||
# 历史问题:CK 曾只存在于 login_tasks.cookie,删一条登录历史就等于删掉账号的
|
||||
# 登录态(2026-10-03 误删事故的根因)。CK 是**账号级凭证**,必须挂在账号上。
|
||||
# 注意:**不能给这列加 server_default** —— MySQL 不允许 TEXT/BLOB/JSON 列有默认值
|
||||
# (错误 1101)。已有行由 ALTER TABLE 隐式填 '',新建行由 Python 侧 default 兜。
|
||||
cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False, default="")
|
||||
cookie_updated_at: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime, nullable=True
|
||||
)
|
||||
# 这份 CK 是怎么来的:web / app / sms / import(空 = 历史回填,来源未知)
|
||||
cookie_source: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="", server_default=""
|
||||
)
|
||||
|
||||
# ── 登录态:CK 健康(巡检/人工检测的当前结论)──────────────────────────
|
||||
ck_check_status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="", server_default=""
|
||||
) # valid / invalid / error / 空 = 未检测
|
||||
ck_check_result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
ck_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
|
||||
# ── 登录态:自动重登的退避进度 ─────────────────────────────────────────
|
||||
relogin_attempts: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, default=0, server_default="0"
|
||||
)
|
||||
relogin_next_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
relogin_last_error: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False, default="", server_default=""
|
||||
)
|
||||
# 失败分型:captcha / credential 转人工,email_code / email_quota /
|
||||
# interrupted / network 自动重试。空 = 没失败过。
|
||||
relogin_failure_kind: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="", server_default=""
|
||||
)
|
||||
|
||||
# ── 生命周期状态 ───────────────────────────────────────────────────────
|
||||
# 取值域见 docs/设计-账号全生命周期.md §3.4。**写入必须收口到
|
||||
# services/account_lifecycle.py**,禁止各处直接赋值 —— accounts.bind_status
|
||||
# 就是「谁都能写、语义没人管」的反面教材(36 种取值互相覆盖)。
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="imported", server_default="imported"
|
||||
)
|
||||
status_reason: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, default="", server_default=""
|
||||
)
|
||||
status_changed_at: Mapped[datetime | None] = mapped_column(
|
||||
UTCDateTime, nullable=True
|
||||
)
|
||||
disabled_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
archived_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
# 巡检按「最久没查的优先」选行
|
||||
Index("ix_accounts_ck_checked_at", "ck_checked_at"),
|
||||
# 重登按状态 + 退避时间选行
|
||||
Index("ix_accounts_status_relogin_next", "status", "relogin_next_at"),
|
||||
# 列表/统计按生命周期状态筛选
|
||||
Index("ix_accounts_status", "status"),
|
||||
)
|
||||
|
||||
assigned_user: Mapped[User | None] = relationship(
|
||||
"User", back_populates="assigned_accounts", foreign_keys=[assigned_to]
|
||||
)
|
||||
@@ -729,6 +789,10 @@ class AccountEvent(Base):
|
||||
- `amount` 有符号(充值为正、兑换消耗为负),便于页面直接算累计;
|
||||
- `source_ref` 是幂等键,可空且唯一:收集器重跑、事件重复发射都靠它兜住;
|
||||
- `detail` 只存精简字段,不放原始大 JSON(那是 douyu_tasks 的包袱)。
|
||||
|
||||
除了「时间线」,它还承担**业务台账**(开通 / 积分 / 兑换 / 充值记录)——
|
||||
查询靠 `(account_id, event_type, subject_key, occurred_at)` 复合索引,
|
||||
所以**有流程的东西才另建表**(如兑换工单),「事实」一律进这里。
|
||||
"""
|
||||
|
||||
__tablename__ = "account_events"
|
||||
@@ -738,18 +802,48 @@ class AccountEvent(Base):
|
||||
# 前缀已覆盖按账号查,再单建一个纯属冗余。
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
# 粗分类:lifecycle(状态变迁)/ auth(登录与 CK)/ business(业务)/ ops(人工)
|
||||
category: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="", server_default=""
|
||||
)
|
||||
occurred_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(256), default="", server_default="")
|
||||
amount: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
balance_after: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 台账检索键:subject_type = commodity / skin / order / role,
|
||||
# subject_key = 斗鱼 commodity_id / 归一后的 skin_key / 订单号。
|
||||
# 只放**需要检索**的键,其余细节进 detail —— 别把这里变成第二个 detail。
|
||||
subject_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="", server_default=""
|
||||
)
|
||||
subject_key: Mapped[str] = mapped_column(
|
||||
String(96), nullable=False, default="", server_default=""
|
||||
)
|
||||
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(24), default="", server_default="")
|
||||
# 谁触发的:user / system / public(收集链接)
|
||||
actor_type: Mapped[str] = mapped_column(
|
||||
String(16), nullable=False, default="", server_default=""
|
||||
)
|
||||
actor_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# 幂等键:可空且唯一(MySQL 允许多个 NULL,便于「没有幂等键」的事件共存)
|
||||
source_ref: Mapped[str | None] = mapped_column(
|
||||
String(96), nullable=True, index=True, unique=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
# 台账查询:「该账号该事件类型该客体最近一次」——365 天同皮肤规则靠它。
|
||||
# 跨账号反查(「这件皮肤被谁兑过」)吃 event_type + subject_key 前缀。
|
||||
Index(
|
||||
"ix_account_events_ledger",
|
||||
"account_id",
|
||||
"event_type",
|
||||
"subject_key",
|
||||
"occurred_at",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AutomationJob(Base):
|
||||
"""自动化作业定义。状态落库,进程重启不丢调度进度。"""
|
||||
|
||||
Reference in New Issue
Block a user