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
This commit is contained in:
@@ -8,6 +8,7 @@ from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
Computed,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
@@ -130,6 +131,14 @@ class Account(Base):
|
||||
# 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)
|
||||
@@ -885,9 +894,88 @@ class AccountEvent(Base):
|
||||
"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):
|
||||
"""自动化作业定义。状态落库,进程重启不丢调度进度。"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user