Files
live-hub-py/web/backend/models.py
T
yml2213 73536d62c8 feat(huya): 工作台补手机号/精英宝典列与一键查询
- 手机号列:外部 CK 的 `----手机号` 本来就落在 huya_accounts.game_phone,
  列表一直没展示;现在单独成列,并纳入搜索
- 精英宝典列:显示「已开通/未开通/未查询」+ 开通时间 + 有效期
  · 开通状态 = 积分中心 getRedeemNeedGoods 的 tag5(真机对照网页:1=已开通,
    0=未开通,此时 tag7 会列出开通套餐),拿不准时为 NULL(显示「未查询」)
  · 开通时间 = revenueWebUI.queryUserOrderList 里已支付的宝典订单 pay_time
    (按 SPU 白名单 + 订单名含「精英宝典」识别,未支付订单不计,取最近一次)
  · 有效期 = PlayMallUI.queryAllocation 页面配置的本赛季节点 tag10/tag11
    (2026-10-15 抓包:精英宝典福利季丨Q4,2026-10-09 ~ 2026-12-31 23:59:59),
    不写死索引,递归找带 tag10/tag11 的节点
- 新任务 query_all「一键查询」:勾选账号一次跑完角色绑定 + 宝典开通 + 积分,
  按部分失败如实汇总(某个接口挂了不影响其它结论),并把宝典状态写回账号
- 迁移 20261015_0053:huya_accounts 新增 elite_opened / elite_open_time /
  elite_expire_time(时间存 Unix 秒,与斗鱼 elite_expire_time 同约定)
- 开通商品按页面「赠送指定礼物得积分」那一排对齐成 6 款(去掉页面已不用的
  旧版精英宝典 hy-5879340),列表接口只返回账号级快照并按 spu 去重
- 建 WSS 会话失败(积分中心端点静默不回包)时回退 HTTP cdnws,
  不再让整批任务判失败
- 列表列宽改按比例分配,避免宽屏下被拉出奇怪的宽度;最近操作不再重复状态
- 新增 tests/test_huya_elite_query.py(10 条);同步迁移头版本断言

真机验证:#65 已开通(10-13 20:41:45,有效期 12-31)、#70 新开通
(10-15 16:37:37)判定与订单/网页一致
2026-10-15 16:53:30 +08:00

988 lines
48 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"
)
# 一键查询得到的「该账号已兑换过的高价值套装名」列表(如 ["套装-兔兔白日梦"])。
#
# 为什么单开一列、而不是让列表读 `overview_snapshot`:那份 JSON 有 1~2KB,
# 账号列表是全站最热的接口之一,15000 个账号各带一份会把响应放大到几十 MB。
# 这里只有几个短字符串。
#
# 兑换**条数**不存这里 —— 列表直接从官方事件聚合,不维护第二套计数。
high_value_owned: Mapped[list | None] = mapped_column(JSON, nullable=True)
# 最近一次「一键查询」的快照(结构化结果)。存在账号上而不是只留在任务里:
# 任务会被保留策略清掉,而运营看的是"这个账号现在什么情况"。
overview_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
overview_queried_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
# 积分**到期时刻**(斗鱼在积分查询里给 `willExpireTime`,只有送礼后的那次查询才有)。
# 积分到期是账号资产的真实减少,但斗鱼不会「通知」你 —— 所以这里记下到期时刻,
# 由作业在到期后补记一条 `points_expire` 事件(否则这个减少在时间线上是空白)。
points_expire_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, nullable=True
)
# 电竞手册是否已开通(与 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
)
# 电竞手册**到期时间**(与 `elite_expire_time` 对称)。
# 以前只在 `_refresh_esports_handbook` 的返回值里带着,没落库 ——
# 于是界面上电竞只能显示"已开通"、看不到到期时间。
esports_expire_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")
# 和平小店总开关。**默认关** —— 小店目前是关闭状态,关着时一键查询
# 完全跳过它的所有请求(不再产生 100% 的 Livelink 报错噪音)。
peace_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
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="")
# 精英宝典开通状态(积分中心 getRedeemNeedGoods 的 tag5;NULL = 还没查过)。
# 光看积分区分不了「开通后花完」和「从没开通」,必须单独落库。
elite_opened: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
# 最近一次宝典订单的支付时间(Unix 秒;与斗鱼 elite_expire_time 同一约定)。
elite_open_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 宝典有效期(Unix 秒):取积分中心页面配置里本赛季的结束时间(福利季 Q4 → 12-31)。
elite_expire_time: Mapped[int | None] = mapped_column(Integer, nullable=True)
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")
# **多久跑一次**(秒)。这是调度频率,与"同一行多久重检"是两回事。
# 以前调度频率是**硬编码**的 JOB_INTERVAL_SECONDS(5 分钟),
# UI 上那个"单账号重检间隔"其实是 `interval_seconds` ——
# 两个概念挤在一个输入框里,很别扭(用户指出)。
run_interval_seconds: Mapped[int] = mapped_column(
Integer, default=300, server_default="300"
)
# 同一**行**(账号)多久重检一次(秒)—— 由作业自己的选行逻辑使用,
# **不参与调度**。别和上面那个混。
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
)