Files
live-hub-py/web/backend/models.py
T
yml2213 0b1fc1dee0 feat(events): 补齐资产【出项】—— 开通金额、鱼翅消耗、积分到期(C 最全)
审「哪些操作该进账号生命周期」时发现:**进项有账、出项没账** ——
充值/积分到账都有金额,而开通(花积分)、送礼(花鱼翅)几乎没记,
于是「一共充了多少」算得出来,「花哪儿了」算不出来。

按确认的范围一次做完(应用宝充值**不动** —— 它按游戏角色记账、且未启用)。

## A. 开通类事件补金额

`elite_opened` / `esports_opened` 以前只记「开通后的积分余额」,不记**花了多少**。
现在补 `amount = -(开通前后积分差)`,并用**实测余额差**而不是"开通要多少分"的常量
(活动价、折扣会让实际扣减不同)。任一侧拿不到就不写金额,不编数字。

## B. 鱼翅消耗事件(新 `gold_spent`)

送礼以前只发「积分到账」,**花掉的鱼翅没有任何记录**。现在:
- 送礼**前后各刷一次**鱼翅(原来只在送礼后刷),取**实测余额差**;
- 发 `gold_spent`,`amount` 用负数(与 `exchange` 一致);
- 余额没减少或拿不到 → **不发**:宁可少一条,也不要记一笔没发生的消耗。

## C. 积分到期(新 `points_expire`)

积分到期是资产真实减少,但**斗鱼不会通知** —— 只在积分查询里给 `willExpireTime`。
所以:
- 新增 `accounts.points_expire_at`(迁移 `0046`),送礼流程学到就写下来;
- 新增作业 `points_expire_check`:扫「已到期且积分未归零」的账号,补写事件并清标记
  (不重复记);积分已为 0 则只清标记、不发事件(避免 `-0` 噪声)。

⚠️ **金额的诚实边界**:到期那一刻**到底扣了多少,我们没亲眼看到**。
所以默认 `confirmed=False`、标题带「(预计)」,记的是「当时余额」这个**预计要归零**的数量,
**不假装它是确认过的扣减**。作业刻意不发网络请求(纯 DB 可跑、不需要 CK),
要变成已确认只能由「到期后重查、真看到余额下降」的调用方补。

## 顺带修掉一个会静默丢事件的坑

同一个任务可能产生**两条事件**(送礼:既「积分到账」又「消耗鱼翅」)。
`source_ref` 是唯一键 —— 共用 `task_ref(task_id)` 会让**后写的那条被静默丢弃**。
已让副事件带后缀(`douyu_task:{id}:gold`),主事件保持原形态(与旧收集器一致)。
有用例专门钉住「一个任务两条事件都不丢」。

## 验证

- `TZ=Asia/Shanghai`:**550 passed / 6 skipped**(+18 条)
- `TZ=UTC`(事件 + 迁移 + 绑定)95 passed;ruff 全绿;前端 build 通过、lint 0 errors
- 真实开发库:迁移 `0046` 已应用;作业烟测 → 扫到 1 个到期账号,
  写入「积分到期 -500(预计)」`confirmed=False`,标记被清、重跑不再重复

## 现在的资产流水(斗鱼侧)

| 方向 | 覆盖 |
| --- | --- |
| 进项 | 充值(二维码/供应商/回调三路径去重)✅、积分到账 ✅ |
| 出项 | 兑换 ✅、**开通 ✅(新补金额)**、**送礼鱼翅 ✅(新)**、**积分到期 ✅(新,标预计)** |
| 未动 | 应用宝充值(独立系统、按游戏角色记、当前未启用) |
2026-10-04 18:10:46 +08:00

951 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"
)
# 积分**到期时刻**(斗鱼在积分查询里给 `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
)
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
)