Files
live-hub-py/web/backend/models.py
T
yml2213 a64380fbcc feat(account): S6 bind_status 三列拆解 —— 四值枚举、唯一写入口、前端改读账号
`accounts.bind_status` 是个**语义垃圾场**:一个自由文本列,6 个文件、**29 处写入**、
20 多种取值,混了三种东西 —— 真状态(`bind_confirmed`)、动作痕迹(`goods_locked`)、
纯查询痕迹(`points_queried`)。它是**覆盖式**的,所以 `bind_confirmed` 会被之后
任意一次查询盖掉 —— 「这个账号到底绑没绑」根本查不出来。
`esports_bind_status` / `xpd_bind_status` 是同一个病。

## 收敛成四值 + 三列一义

`unknown` / `unbound` / `pending` / `bound`,每个作用域一列:

| 列 | 作用域 |
| --- | --- |
| `accounts.role_bind_status`(**新增**) | 精英宝典角色 |
| `accounts.esports_bind_status`(收敛) | 电竞手册角色 |
| `accounts.xpd_bind_status`(收敛) | 和平小店角色 |

- 新增 `services/account_binding.py`:唯一写入口 `set_bind_status()`,
  传旧自由文本直接 `ValueError`(否则垃圾会被写回去);`map_legacy_bind_status()` 负责旧值映射。
- **29 处自由文本写入全部删除或改走枚举**:真状态走枚举,动作/查询痕迹**不再写状态列**
  (动作已由 S5 的直发事件记录,查询痕迹本来就不该是状态)。
- `accounts.bind_status` 保留原值便于比对,**已无任何写入方**,待 S8 drop。
- 一条**源码扫描用例**禁止任何地方再出现 `account.bind_status = "..."` 这类写入。

## 回填不猜(设计文档 §5.5)

只映射**明确陈述了绑定关系**的取值(`bind_confirmed`/`esports_bound`/`xpd_bound` → bound,
`*_qr_*`/`*_ready` → pending,`game_not_bound`/`xpd_not_bound` → unbound),
**其余一律 unknown**。因为列是覆盖式的,「最后一件事是查询」并不代表没绑定。
迁移 SQL 与 Python 映射有一致性用例:**解析迁移源码里的 CASE 逐条比对**
(已反向验证:故意改错一条映射,用例立刻红)。

## ⚠️ 顺带救回一个差点被我自己丢掉的状态

「**电竞手册是否已开通**」以前**只寄生在 `esports_bind_status` 的 `esports_opened`
取值里**(`elite_opened` 有独立布尔列,电竞没有)。归一化会把那个取值抹成 `unknown`
→ **静默丢状态**。所以:
- 新增 `accounts.esports_opened` 布尔列(与 `elite_opened` 对称),开通任务成功时置位;
- 迁移里**必须先 `save_esports_opened()` 再归一化**,顺序用例钉住;
- 详情页「电竞手册」开通状态改读 `esports_opened`。

## 前端不再从任务日志反推账号状态

工作台原来从**当前加载的那一页任务**里挑每个账号最新的一条来推绑定状态
(`latestConfirmBindTaskByAccount` 等)。两个问题:① 支撑任务一旦翻出窗口,
**显示的状态就是错的**;② 这让任务日志变成「账号状态库」,于是它永远不敢清理。

现在状态读账号字段,任务日志只补「扫码流程中的瞬时信息」(如确认前的
`pending_role_name`)。删掉了两个不再需要的派生与对应的 `clearPending` 绕行逻辑。

## 踩到并修掉的东西

- **模型与迁移不一致**:我把模型收窄成 `String(16)`,迁移却留着 `String(32)` ——
  测试库按模型建、生产按迁移建,两边列宽不同。已让迁移也收窄,且**收窄必须在
  归一化之后**(旧取值最长 19 字符,先收窄会截断成废值)。downgrade 也补齐了反向操作,
  已实测 `downgrade -1 && upgrade head` 干净。
- **parity 用例第一版是抄了一份 SQL**(迁移改了用例也不会红,等于没测)——
  改成解析迁移源码;后来又因为列已收窄、19 字符的旧值塞不进测试库,
  干脆改成源码级解析比对(不造数据)。

## 验证

- `TZ=Asia/Shanghai`:**490 passed / 6 skipped**(S5 后 450 → 净增 40)
- `TZ=UTC`(相关文件)106 passed;迁移链 3 passed;ruff 全绿
- 前端 `tsc -b && vite build` 通过、`npm run lint` **0 errors**
- 真实开发库:迁移重放后三列均为 `varchar(16) NOT NULL DEFAULT 'unknown'`;
  12 个账号的旧值 `elite_status_queried`(纯查询痕迹)→ **unknown**(没有猜成 bound)

未做(后续):S6 的**账号页重构**(列表关键列 / 生命周期主页)保留;
`bind_status` 旧列待 S8 drop;保留策略里「状态派生类查询」现在技术上可下探到 7 天档,
保守起见留待确认(影响的是在用页面显示,不是历史)。
2026-10-04 16:28:06 +08:00

945 lines
45 KiB
Python
Raw 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.
"""ORM 模型"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .crypto_storage import EncryptedText
from .database import Base, UTCDateTime
from .huya_defaults import (
HUYA_DEFAULT_BIND_ACT_ID,
HUYA_DEFAULT_OUTER_ACT_ID,
HUYA_DEFAULT_PAY_CHANNEL,
HUYA_DEFAULT_ROOM_PID,
HUYA_DEFAULT_SID,
)
def _utcnow():
"""返回时区感知的 UTC 当前时间,替代 _utcnow()。"""
return datetime.now(UTC)
class User(Base):
"""系统用户"""
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
role: Mapped[str] = mapped_column(
String(32), nullable=False, default="support"
) # super_admin / operation / support
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
remark: Mapped[str] = mapped_column(String(256), default="")
custom_permissions: Mapped[list[str] | None] = mapped_column(
JSON, nullable=True, comment="自定义权限列表,null表示使用角色默认权限"
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
deleted_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True, index=True
)
deleted_username: Mapped[str] = mapped_column(String(64), default="")
# 客服被分配的账号
assigned_accounts: Mapped[list[Account]] = relationship(
"Account", back_populates="assigned_user", foreign_keys="Account.assigned_to"
)
huya_accounts: Mapped[list[HuyaAccount]] = relationship(
"HuyaAccount",
back_populates="assigned_user",
foreign_keys="HuyaAccount.assigned_to",
)
class Account(Base):
"""斗鱼账号"""
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(128), nullable=False)
password: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
email: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
email_password: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
email_imap_server: Mapped[str] = mapped_column(String(128), default="")
email_imap_port: Mapped[int] = mapped_column(Integer, default=993)
email_imap_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
tag: Mapped[str] = mapped_column(String(64), default="")
remark: Mapped[str] = mapped_column(String(256), default="")
uid: Mapped[str] = mapped_column(String(32), default="", index=True)
nickname: Mapped[str] = mapped_column(String(128), default="")
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
game_name: Mapped[str] = mapped_column(String(128), default="")
game_channel: Mapped[str] = mapped_column(String(128), default="")
gold_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
exchange_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
# ⚠️ 已废弃的「垃圾场」列:曾混杂状态/动作/查询三种语义,覆盖式写入导致
# 「到底绑没绑」查不出来。S6 起**不再有任何写入方**,绑定状态走下面三列。
# 计划在 S8 连同其他历史包袱一起 drop(需先做备份)。
bind_status: Mapped[str] = mapped_column(String(32), default="")
# 精英宝典角色绑定:unknown / unbound / pending / bound(见 services/account_binding)
role_bind_status: Mapped[str] = mapped_column(
String(16), default="unknown", server_default="unknown"
)
# 电竞手册是否已开通(与 elite_opened 对称)。
# 以前这个状态**只寄生在 `esports_bind_status` 里**(取值 `esports_opened`)——
# 那是「一列多义」的典型:同一个列既表示开通、又表示角色绑定、又表示查询痕迹。
# S6 拆解时差点把这个信息丢掉,所以补成独立布尔列。
esports_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
change_role_wait_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 精英宝典开通状态(getUserInfo 的 manualType / expireTime)。
# 光看积分区分不了「开通后积分花完」和「从未开通」,必须单独落库。
elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
elite_expire_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
esports_points: Mapped[int | None] = mapped_column(Integer, nullable=True)
esports_game_name: Mapped[str] = mapped_column(String(128), default="")
esports_game_channel: Mapped[str] = mapped_column(String(128), default="")
esports_bind_status: Mapped[str] = mapped_column(
String(16), default="unknown", server_default="unknown"
)
esports_change_role_wait_time: Mapped[int | None] = mapped_column(
Integer, nullable=True
)
esports_can_change_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_game_name: Mapped[str] = mapped_column(String(128), default="")
xpd_openid: Mapped[str] = mapped_column(String(128), default="")
# 自动化后门:false 时巡检与自动重登都跳过这个账号(特殊账号人工维护)。
auto_managed: Mapped[bool] = mapped_column(
Boolean, default=True, server_default="1"
)
xpd_role_id: Mapped[str] = mapped_column(String(64), default="")
xpd_plat_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_area_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_balance: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_fragments: Mapped[int | None] = mapped_column(Integer, nullable=True)
xpd_bind_status: Mapped[str] = mapped_column(
String(16), default="unknown", server_default="unknown"
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
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]
)
login_tasks: Mapped[list[LoginTask]] = relationship(
"LoginTask", back_populates="account"
)
douyu_tasks: Mapped[list[DouyuTask]] = relationship(
"DouyuTask", back_populates="account"
)
class LoginTask(Base):
"""单次登录任务"""
__tablename__ = "login_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True
) # 批次ID
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False)
status: Mapped[str] = mapped_column(
String(32), default="pending"
) # pending / running / success / failed / error
cookie: Mapped[str] = mapped_column(EncryptedText(), default="")
message: Mapped[str] = mapped_column(String(512), default="")
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
# Cookie 有效性检测结果(持久化,刷新不丢失)
ck_check_status: Mapped[str] = mapped_column(
String(16), default=""
) # valid / invalid / 空=未检测
ck_check_result: Mapped[dict | None] = mapped_column(
JSON, nullable=True
) # {fish_ball, nickname, level, message}
ck_checked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
# 最近一次登录尝试的状态。批次只写 attempt_*,只有登录成功才会把新 CK
# 写回 status/cookie/message,因此停止、失败、进程重启都不会破坏已有 CK。
attempt_status: Mapped[str] = mapped_column(
String(32), default="", server_default=""
) # pending / running / success / failed / stopped / 空=没有进行过的尝试
attempt_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
attempt_batch_id: Mapped[str] = mapped_column(
String(64), default="", server_default=""
)
attempt_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
# 自动重登(P1b)用的退避状态。P1a 的巡检不碰这几个字段。
relogin_attempts: Mapped[int] = mapped_column(
Integer, 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), default="", server_default=""
)
# 失败分型:captcha / credential 转人工,email_code / email_quota /
# interrupted / network 自动重试(见规划 §7.4.3)。空 = 没失败过。
relogin_failure_kind: Mapped[str] = mapped_column(
String(16), default="", server_default=""
)
account: Mapped[Account] = relationship("Account", back_populates="login_tasks")
class DouyuTask(Base):
"""斗鱼业务任务"""
__tablename__ = "douyu_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False)
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# 任务归属工作台;避免同一账号的精英/电竞/小店任务在前端串行展示或串弹二维码。
handbook_scope: Mapped[str] = mapped_column(
String(16), nullable=False, default="legacy", index=True
)
# 供应商直充的来源订单号,用于轮询与异步回调的幂等关联。
supplier_out_order_id: Mapped[str | None] = mapped_column(
String(64), nullable=True, unique=True, index=True
)
status: Mapped[str] = mapped_column(String(32), default="pending")
message: Mapped[str] = mapped_column(String(512), default="")
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
# 账号归属客服冗余字段,配合复合索引消除任务列表全表 filesort。
account_assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
__table_args__ = (
Index(
"ix_douyu_tasks_account_assigned_to_id",
"account_assigned_to",
"id",
),
Index(
"ix_douyu_tasks_assigned_scope_id",
"account_assigned_to",
"handbook_scope",
"id",
),
)
account: Mapped[Account] = relationship("Account", back_populates="douyu_tasks")
class DouyuWorkbenchAccount(Base):
"""用户在指定斗鱼工作台中启用的账号,跨浏览器同步。"""
__tablename__ = "douyu_workbench_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint(
"user_id", "handbook_scope", "account_id", name="uq_douyu_workbench_account"
),
)
class DouyuWorkbench(Base):
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
__tablename__ = "douyu_workbenches"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", name="uq_douyu_workbench"),
)
class YybRechargeTask(Base):
"""应用宝和平精英点券充值任务。
YYB 登录身份与斗鱼账号无关,因此任务只关联创建者,不复用 Account。
"""
__tablename__ = "yyb_recharge_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# 唯一索引(uq_yyb_recharge_tasks_task_id)已覆盖 task_id 查询,无需重复普通索引;
# 迁移 20260812_0020 也只建唯一索引,二者保持一致。
task_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
created_by: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
worker_job_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
provider: Mapped[str] = mapped_column(String(16), default="", nullable=False)
platform: Mapped[str] = mapped_column(String(16), default="android", nullable=False)
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
product_id: Mapped[str] = mapped_column(String(128), default="")
zone_id: Mapped[str] = mapped_column(String(64), default="")
zone_name: Mapped[str] = mapped_column(String(128), default="")
role_id: Mapped[str] = mapped_column(String(64), default="")
role_name: Mapped[str] = mapped_column(String(128), default="")
price_fen: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(
String(32), default="created", nullable=False, index=True
)
phase: Mapped[str] = mapped_column(String(32), default="login", nullable=False)
message: Mapped[str] = mapped_column(String(512), default="")
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
login_qr_data: Mapped[str] = mapped_column(EncryptedText(), default="")
payment_qr_data: Mapped[str] = mapped_column(EncryptedText(), default="")
payment_started_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
payment_qr_created_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
payment_last_checked_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
class DouyuConfig(Base):
"""斗鱼业务配置"""
__tablename__ = "douyu_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
manual_id: Mapped[str] = mapped_column(String(64), default="G4KA4Qnz4LDp7")
rid: Mapped[str] = mapped_column(String(64), default="9263298")
bind_act_alias: Mapped[str] = mapped_column(String(64), default="20260120QYOOB")
confirm_act_alias: Mapped[str] = mapped_column(String(64), default="20260120QYOOB")
legacy_act_alias: Mapped[str] = mapped_column(String(64), default="cjm")
room_id: Mapped[str] = mapped_column(String(64), default="9263298")
elite_amount: Mapped[int] = mapped_column(Integer, default=3000)
esports_manual_id: Mapped[str] = mapped_column(String(64), default="1W0yGkOdDbAXb")
esports_act_alias: Mapped[str] = mapped_column(String(64), default="20260226ZMGDG")
esports_amount: Mapped[int] = mapped_column(Integer, default=3000)
esports_chicken_gift_id: Mapped[str] = mapped_column(String(64), default="24766")
esports_chicken_skin_id: Mapped[str] = mapped_column(String(64), default="0")
esports_firework_gift_id: Mapped[str] = mapped_column(String(64), default="24767")
esports_firework_skin_id: Mapped[str] = mapped_column(String(64), default="3850")
xpd_act_alias: Mapped[str] = mapped_column(String(64), default="20260623KDQFH")
xpd_act_id: Mapped[str] = mapped_column(String(64), default="46195")
xpd_rid: Mapped[str] = mapped_column(String(64), default="9263298")
gold_pay_type: Mapped[int] = mapped_column(Integer, default=1)
gold_recharge_channel: Mapped[str] = mapped_column(String(16), default="wechat_qr")
gold_api_product_id: Mapped[str] = mapped_column(String(128), default="111570")
gold_api_account_template_name: Mapped[str] = mapped_column(
String(64), default="斗鱼昵称"
)
gift_id: Mapped[str] = mapped_column(String(64), default="23643")
skin_id: Mapped[str] = mapped_column(String(64), default="2942")
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class DouyuGoodsSnapshot(Base):
"""斗鱼兑换商品快照"""
__tablename__ = "douyu_goods_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
commodity_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(256), default="")
score: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(32), default="")
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class DouyuXpdGoodsSnapshot(Base):
"""斗鱼和平小店商品快照"""
__tablename__ = "douyu_xpd_goods_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
commodity_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(256), default="")
price: Mapped[int | None] = mapped_column(Integer, nullable=True)
org_price: Mapped[int | None] = mapped_column(Integer, nullable=True)
category: Mapped[str] = mapped_column(String(32), default="")
goods_left: Mapped[int | None] = mapped_column(Integer, nullable=True)
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class DouyuEsportsGoodsSnapshot(Base):
"""斗鱼电竞手册皮肤商城商品快照"""
__tablename__ = "douyu_esports_goods_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
commodity_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(256), default="")
score: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(32), default="")
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaAccount(Base):
"""虎牙账号"""
__tablename__ = "huya_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
uid: Mapped[str] = mapped_column(String(32), default="", index=True)
yyuid: Mapped[str] = mapped_column(String(32), default="", index=True)
username: Mapped[str] = mapped_column(String(128), default="")
account_password: Mapped[str] = mapped_column(EncryptedText(), default="")
nickname: Mapped[str] = mapped_column(String(128), default="")
cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False)
# App 登录原始凭据(16 字段 udb_*,不含本地随机生成的网页设备字段)。
# 网页运行 ck(cookie 列)含 guid/_qimei_uuid42 等设备快照,若被风控标记
# 可从 raw_cookie 重建;也用于审计/复核登录有效性。
raw_cookie: Mapped[str] = mapped_column(EncryptedText(), nullable=False, default="")
tag: Mapped[str] = mapped_column(String(64), default="")
remark: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(32), default="imported")
# 上次成功登录渠道: web=旧版Web协议 / app=App协议(推荐) / sms=短信; 空=仅导入Cookie
login_channel: Mapped[str] = mapped_column(String(16), default="", index=True)
points: Mapped[int | None] = mapped_column(Integer, nullable=True)
game_name: Mapped[str] = mapped_column(String(128), default="")
game_channel: Mapped[str] = mapped_column(String(64), default="")
game_phone: Mapped[str] = mapped_column(String(64), default="")
sms_url: Mapped[str] = mapped_column(Text, default="")
assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
assigned_user: Mapped[User | None] = relationship(
"User", back_populates="huya_accounts", foreign_keys=[assigned_to]
)
tasks: Mapped[list[HuyaTask]] = relationship("HuyaTask", back_populates="account")
class HuyaRegisterBatch(Base):
"""虎牙自动注册批次(持久化元数据)"""
__tablename__ = "huya_register_batches"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(
String(64), unique=True, nullable=False, index=True
)
tag: Mapped[str] = mapped_column(String(64), default="")
created_by: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
concurrency: Mapped[int] = mapped_column(Integer, default=1)
wait_seconds: Mapped[int] = mapped_column(Integer, default=180)
poll_interval: Mapped[int] = mapped_column(Integer, default=5)
password_prefix: Mapped[str] = mapped_column(String(16), default="hy")
fixed_password: Mapped[str] = mapped_column(EncryptedText(), default="")
use_proxy: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
message: Mapped[str] = mapped_column(String(512), default="")
total: Mapped[int] = mapped_column(Integer, default=0)
success_count: Mapped[int] = mapped_column(Integer, default=0)
failed_count: Mapped[int] = mapped_column(Integer, default=0)
stopped_count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
items: Mapped[list[HuyaRegisterItem]] = relationship(
"HuyaRegisterItem", back_populates="batch", cascade="all, delete-orphan"
)
class HuyaRegisterItem(Base):
"""虎牙自动注册批次内单条手机号状态"""
__tablename__ = "huya_register_items"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_db_id: Mapped[int] = mapped_column(
Integer, ForeignKey("huya_register_batches.id"), nullable=False, index=True
)
batch_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
line: Mapped[int] = mapped_column(Integer, nullable=False)
phone: Mapped[str] = mapped_column(String(64), default="", index=True)
provider: Mapped[str] = mapped_column(String(32), default="")
sms_url: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(32), default="pending", index=True)
message: Mapped[str] = mapped_column(String(512), default="")
code: Mapped[str] = mapped_column(String(32), default="")
change_code: Mapped[str] = mapped_column(String(32), default="")
attempts: Mapped[int] = mapped_column(Integer, default=0)
change_attempts: Mapped[int] = mapped_column(Integer, default=0)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("huya_accounts.id"), nullable=True
)
username: Mapped[str] = mapped_column(String(128), default="")
uid: Mapped[str] = mapped_column(String(32), default="")
password: Mapped[str] = mapped_column(EncryptedText(), default="")
password_changed: Mapped[bool] = mapped_column(Boolean, default=False)
cookie: Mapped[str] = mapped_column(EncryptedText(), default="")
started_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
batch: Mapped[HuyaRegisterBatch] = relationship(
"HuyaRegisterBatch", back_populates="items"
)
class HuyaRegisterSuccessLog(Base):
"""虎牙自动注册成功流水(只追加,供随时导出)"""
__tablename__ = "huya_register_success_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
item_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("huya_accounts.id"), nullable=True
)
phone: Mapped[str] = mapped_column(String(64), default="", index=True)
username: Mapped[str] = mapped_column(String(128), default="")
uid: Mapped[str] = mapped_column(String(32), default="")
password: Mapped[str] = mapped_column(EncryptedText(), default="")
sms_url: Mapped[str] = mapped_column(Text, default="")
tag: Mapped[str] = mapped_column(String(64), default="", index=True)
provider: Mapped[str] = mapped_column(String(32), default="")
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, index=True
)
class HuyaTask(Base):
"""虎牙业务任务"""
__tablename__ = "huya_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
batch_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("huya_accounts.id"), nullable=False
)
task_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# 任务归属工作台;legacy 保留旧虎牙任务,elite 隔离精英宝典任务。
handbook_scope: Mapped[str] = mapped_column(
String(16), nullable=False, default="legacy", index=True
)
status: Mapped[str] = mapped_column(String(32), default="pending")
message: Mapped[str] = mapped_column(String(512), default="")
result: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
account: Mapped[HuyaAccount] = relationship("HuyaAccount", back_populates="tasks")
class HuyaWorkbenchAccount(Base):
"""用户在虎牙指定工作台中启用的账号。"""
__tablename__ = "huya_workbench_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("huya_accounts.id"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
__table_args__ = (
UniqueConstraint(
"user_id",
"handbook_scope",
"account_id",
name="uq_huya_workbench_account",
),
)
class HuyaWorkbench(Base):
"""工作台配置哨兵,令空账号集合也能跨浏览器同步。"""
__tablename__ = "huya_workbenches"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id"), nullable=False, index=True
)
handbook_scope: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
__table_args__ = (
UniqueConstraint("user_id", "handbook_scope", name="uq_huya_workbench"),
)
class HuyaConfig(Base):
"""虎牙业务配置"""
__tablename__ = "huya_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
room_pid: Mapped[str] = mapped_column(String(64), default=HUYA_DEFAULT_ROOM_PID)
sid: Mapped[str] = mapped_column(String(32), default=HUYA_DEFAULT_SID)
outer_act_id: Mapped[str] = mapped_column(
String(32), default=HUYA_DEFAULT_OUTER_ACT_ID
)
bind_act_id: Mapped[str] = mapped_column(
String(32), default=HUYA_DEFAULT_BIND_ACT_ID
)
pay_channel: Mapped[str] = mapped_column(
String(16), default=HUYA_DEFAULT_PAY_CHANNEL
)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaGoodsSnapshot(Base):
"""虎牙兑换商品快照"""
__tablename__ = "huya_goods_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
sid: Mapped[int] = mapped_column(Integer, default=0, index=True)
product_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(256), default="")
price: Mapped[int | None] = mapped_column(Integer, nullable=True)
remain_text: Mapped[str] = mapped_column(String(64), default="")
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class HuyaRechargeGoodsSnapshot(Base):
"""虎牙充值商品快照"""
__tablename__ = "huya_recharge_goods_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
sid: Mapped[int] = mapped_column(Integer, default=0, index=True)
spu_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
sku_id: Mapped[str] = mapped_column(String(64), default="", index=True)
name: Mapped[str] = mapped_column(String(256), default="")
price: Mapped[int | None] = mapped_column(Integer, nullable=True)
stock: Mapped[int | None] = mapped_column(Integer, nullable=True)
buy_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
icon: Mapped[str] = mapped_column(String(512), default="")
description: Mapped[str] = mapped_column(String(512), default="")
task_id: Mapped[str] = mapped_column(String(64), default="")
task_name: Mapped[str] = mapped_column(String(256), default="")
raw: Mapped[dict | None] = mapped_column(JSON, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class ProxyConfig(Base):
"""代理配置(全局单条记录)"""
__tablename__ = "proxy_config"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
api_url: Mapped[str] = mapped_column(EncryptedText(), default="")
http: Mapped[str] = mapped_column(EncryptedText(), default="")
https: Mapped[str] = mapped_column(EncryptedText(), default="")
# 白名单
whitelist_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
whitelist_platform: Mapped[str] = mapped_column(String(32), default="xiequ")
whitelist_credentials: Mapped[dict | None] = mapped_column(JSON, nullable=True)
# 旧字段保留(向后兼容双写)
whitelist_uid: Mapped[str] = mapped_column(EncryptedText(), default="")
whitelist_ukey: Mapped[str] = mapped_column(EncryptedText(), default="")
class AuditLog(Base):
"""操作审计日志"""
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
username: Mapped[str] = mapped_column(String(64), default="")
action: Mapped[str] = mapped_column(String(128), nullable=False)
target: Mapped[str] = mapped_column(String(256), default="")
detail: Mapped[str] = mapped_column(Text, default="")
# 账号相关操作用它关联到具体账号(旧记录为 NULL)。
# target 是自由文本(用户名/批次号/整句话),不能可靠反查账号,所以单独存 id。
account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
# 旧记录未标记结果时为 NULL,避免把历史操作误判成失败。
success: Mapped[bool | None] = mapped_column(Boolean, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
class AccountEvent(Base):
"""账号事件流(append-only)。
`accounts` 存**当前状态**,这张表存**发生过什么** —— 账号详情页的时间线就读它。
状态字段都是覆盖式更新(积分、余额、开通标记),事件表补上「什么时候变成这样」。
设计约定:
- `occurred_at` 是**业务发生时间**(不是入库时间);
- `amount` 有符号(充值为正、兑换消耗为负),便于页面直接算累计;
- `source_ref` 是幂等键,可空且唯一:收集器重跑、事件重复发射都靠它兜住;
- `detail` 只存精简字段,不放原始大 JSON(那是 douyu_tasks 的包袱)。
除了「时间线」,它还承担**业务台账**(开通 / 积分 / 兑换 / 充值记录)——
查询靠 `(account_id, event_type, subject_key, occurred_at)` 复合索引,
所以**有流程的东西才另建表**(如兑换工单),「事实」一律进这里。
"""
__tablename__ = "account_events"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# 不单独建 account_id 索引:迁移建的是 (account_id, occurred_at) 复合索引,
# 前缀已覆盖按账号查,再单建一个纯属冗余。
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):
"""自动化作业定义。状态落库,进程重启不丢调度进度。"""
__tablename__ = "automation_jobs"
key: Mapped[str] = mapped_column(String(64), primary_key=True)
title: Mapped[str] = mapped_column(String(128), default="")
# 默认关闭:按「渐进上线」原则,先观察再打开。
enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="0")
interval_seconds: Mapped[int] = mapped_column(
Integer, default=21600, server_default="21600"
)
batch_size: Mapped[int] = mapped_column(Integer, default=200, server_default="200")
concurrency: Mapped[int] = mapped_column(Integer, default=5, server_default="5")
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
last_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
next_run_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
last_status: Mapped[str] = mapped_column(String(16), default="", server_default="")
last_message: Mapped[str] = mapped_column(
String(512), default="", server_default=""
)
updated_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, onupdate=_utcnow
)
class AutomationRun(Base):
"""自动化作业每一轮的执行记录,用于页面展示与排障。"""
__tablename__ = "automation_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
job_key: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
started_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
status: Mapped[str] = mapped_column(
String(16), default="running", server_default="running"
) # running / success / failed / skipped
scanned: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
valid: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
invalid: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
relogin_started: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
relogin_success: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
skipped: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
message: Mapped[str] = mapped_column(String(512), default="", server_default="")
detail: Mapped[dict | None] = mapped_column(JSON, nullable=True)
class ExternalOrderCache(Base):
"""order_site 订单快照(P0 适配层的本地缓存)。
主键是 (platform_order_id, source_order_id) 两列:同一个订单号在 order_site
里可能有两行(发货方内部行 + 真实店铺行,实测 541 组,见规划 §6.5.1),
按订单号单列存会互相覆盖。
只作快照与降级用,24 小时后重查;不参与任何写业务。
"""
__tablename__ = "external_order_cache"
platform_order_id: Mapped[str] = mapped_column(
String(64), primary_key=True, index=True
)
source_order_id: Mapped[str] = mapped_column(String(64), primary_key=True)
platform: Mapped[str] = mapped_column(String(32), default="", server_default="")
provider: Mapped[str] = mapped_column(String(32), default="", server_default="")
shop_id: Mapped[str] = mapped_column(String(64), default="", server_default="")
order_status: Mapped[str] = mapped_column(String(32), default="", server_default="")
sku_code: Mapped[str] = mapped_column(String(128), default="", server_default="")
sku_name: Mapped[str] = mapped_column(String(512), default="", server_default="")
seller_id: Mapped[str] = mapped_column(String(64), default="", server_default="")
rel_item_id: Mapped[str] = mapped_column(String(64), default="", server_default="")
rel_sku_id: Mapped[str] = mapped_column(String(64), default="", server_default="")
sku_nick: Mapped[str] = mapped_column(String(512), default="", server_default="")
item_title: Mapped[str] = mapped_column(String(512), default="", server_default="")
quantity: Mapped[int | None] = mapped_column(Integer, nullable=True)
route: Mapped[str] = mapped_column(String(32), default="", server_default="")
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
fetched_at: Mapped[datetime] = mapped_column(
UTCDateTime, default=_utcnow, index=True
)