Files
live-hub-py/web/backend/models.py
T
yml2213 18bc7be4b1 feat(pool): 账号自动分配与回收(租约 + 补给链 + 独立工作台)
在既有「管理员分配 → 客服导入工作台 → 手动选号」之外**新增**一条
「客服选皮肤 + 数量 → 系统分配账号 + 按需补积分 → 用完自动收回」的路径。
旧路径只做并集与可选参数扩展,行为不变(有用例守着)。

- 归属与使用权分离:新增 account_leases(生成列 + 唯一索引,并发抢号由数据库裁决),
  assigned_to 收窄为管理员/运营的维护责任;客服可见范围扩为「名下 ∪ 租用中」
- account_pool 服务:候选筛选、两梯队排序(先花快过期的 / 先选充值少的)、
  供给与成本预估、租约领取/释放/回收
- 补给链:查余额 → 充鱼翅(单次 ≤800 元,超限拆单)→ 赠精英令 → 兑换;
  运行期两道闸门:_skip_when(余额已够就别再花钱)、_depends_on(前置未成功就跳过)
- 转人工:鱼翅支付等 10 分钟未到账转 manual_required,租约先不还;
  account_lease_reaper 兜 TTL 到期 / 漏释放 / 孤儿批次
- 运营流水:业务事件补 actor_id = task.created_by;账号详情页展示操作人、渠道
  与租赁记录
- 新工作台:/douyu/pool + 菜单「账号分配台」(新建,不改造旧工作台)
- 修复 D1:官方补录的兑换事件漏写 subject_key(10,612 条主键为空),
  会让 365 天同皮肤规则漏判 87% 的历史;补代码 + 回填脚本
- 迁移 20261007_0052;权限 account:pool_use / account:pool_admin

测试:766 passed, 6 skipped(改动前基线 727);ruff 与 tsc -b 干净。
设计稿与实施记录:docs/设计-账号自动分配与回收.md
2026-10-07 12:29:08 +08:00

1069 lines
52 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,
Computed,
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)
# 精英宝典**可换绑时刻**(绝对时间)。`change_role_wait_time` 是接口原文的
# 「剩余秒数」,落库那一刻就开始过期 —— 过几天再读它,既不知道是不是还在冷却,
# 也不知道什么时候能换绑。所以每次写入时同时记下 `now + wait_time`。
# 不同活动的换绑冷却不同,因此**按作用域分列**(电竞手册用既有的
# `esports_can_change_time`,它本来就是绝对时刻,不另开副本)。
role_change_available_at: Mapped[datetime | None] = mapped_column(
UTCDateTime, 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="")
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",
),
# 「某个客服这段时间做了什么」——池化后客服不再持有账号,
# 这条是管理侧追责与对账的主查询路径,此前没有任何索引可用。
Index("ix_account_events_actor_occurred", "actor_id", "occurred_at"),
)
class AccountLease(Base):
"""账号租约:谁在**此刻**可以使用这个账号。
## 为什么要有它
以前「能不能操作某账号」的唯一判据是 `accounts.assigned_to == 我`。
账号因此被**永久独占**:客服名下的账号积分花完了也不会流向别人,
管理员只能人工调拨。池化之后这层隐式互斥消失,必须有一个显式的
「临时使用权」——就是这张表。
## 归属与使用权分离
- `accounts.assigned_to` 收窄为**维护责任**(谁负责重登、开通、进货),
由管理员/运营承担,客服名下不再持有账号;
- `account_leases` 表示**此刻谁在用**,用完即释放。
## 一个账号同时只能有一个有效租约
靠生成列 `active_account_id` + 唯一索引在**数据库层**保证:非 active 时为 NULL,
MySQL 唯一索引允许多个 NULL,所以历史记录不受限。并发抢号由数据库裁决 ——
两个事务抢同一账号必有一个撞唯一键,捕获后换下一个候选即可,不需要应用层加锁。
⚠️ `active_account_id` 必须声明在 `account_id` 与 `status` **之后**:
MySQL 要求生成列只能引用它前面已定义的列。
"""
__tablename__ = "account_leases"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
account_id: Mapped[int] = mapped_column(
ForeignKey("accounts.id"), nullable=False, index=True
)
holder_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
# 作用域:elite / esports / peace(与 douyu_tasks.handbook_scope 同域)
scope: Mapped[str] = mapped_column(String(16), nullable=False, default="elite")
purpose: Mapped[str] = mapped_column(
String(24), nullable=False, default="exchange", server_default="exchange"
)
commodity_id: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
batch_id: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
# active / released / expired
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="active", server_default="active"
)
leased_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
# TTL 兜底:进程重启、页面关掉都能靠它把租约收回来。
expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False)
released_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
release_reason: Mapped[str] = mapped_column(
String(64), nullable=False, default="", server_default=""
)
# 生成列:active 时等于 account_id,其余为 NULL。唯一索引因此只约束「有效租约」。
active_account_id: Mapped[int | None] = mapped_column(
Integer,
Computed(
"CASE WHEN status = 'active' THEN account_id ELSE NULL END",
persisted=True,
),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
__table_args__ = (
Index("uq_lease_active_account", "active_account_id", unique=True),
Index("ix_leases_holder_status", "holder_user_id", "status"),
Index("ix_leases_status_expires", "status", "expires_at"),
)
account: Mapped[Account] = relationship("Account")
holder: Mapped[User] = relationship("User")
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
)