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:
yml2213
2026-10-07 12:29:08 +08:00
parent 281769b440
commit 18bc7be4b1
38 changed files with 5143 additions and 35 deletions
@@ -0,0 +1,163 @@
"""账号租约 `account_leases` + `accounts.role_change_available_at` + 操作人索引
配套文档:`docs/设计-账号自动分配与回收.md`(§3.2 表结构、§4 换绑冷却、§3.14 流水)。
三件事:
1. **`account_leases`** —— 归属(`assigned_to`,管理员/运营的维护责任)与
使用权(租约,客服临时占用)分离。核心是生成列 `active_account_id`
加唯一索引 `uq_lease_active_account`:一个账号同时只能有一个**有效**租约,
并发抢号由数据库裁决(撞唯一键即换候选),不需要应用层加锁。
2. **`accounts.role_change_available_at`** —— `change_role_wait_time` 是接口原文的
「剩余秒数」,落库即过期;换绑判定需要的是**绝对时刻**。电竞手册复用既有的
`esports_can_change_time`(本来就是绝对时刻),不另开副本。
3. **`ix_account_events_actor_occurred`** —— 池化后客服不再持有账号,
「某客服这段时间做了什么」只能按 `actor_id` 查;此前没有可用索引。
**可重跑**:应用启动时会自动跑迁移,所以每个动作都带存在性守卫。
**回退**:drop 表、drop 列、drop 索引。租约是运行期状态,回退即丢失 ——
但账号与任务数据不受影响(`assigned_to`、`douyu_tasks` 一律没动)。
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "20261007_0052"
down_revision: str | None = "20261004_0051"
branch_labels: str | None = None
depends_on: str | None = None
TABLE = "account_leases"
ACCOUNT_COLUMN = "role_change_available_at"
ACTOR_INDEX = "ix_account_events_actor_occurred"
#: 「一个账号同时只能有一个有效租约」的数据库级保证。
ACTIVE_ACCOUNT_EXPR = "CASE WHEN status = 'active' THEN account_id ELSE NULL END"
def _table_exists(name: str) -> bool:
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE table_schema = DATABASE() AND table_name = :name"
),
{"name": name},
).scalar()
return bool(count)
def _column_exists(table: str, name: str) -> bool:
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.columns "
"WHERE table_schema = DATABASE() "
"AND table_name = :table AND column_name = :name"
),
{"table": table, "name": name},
).scalar()
return bool(count)
def _index_exists(table: str, name: str) -> bool:
conn = op.get_bind()
count = conn.execute(
sa.text(
"SELECT COUNT(*) FROM information_schema.statistics "
"WHERE table_schema = DATABASE() "
"AND table_name = :table AND index_name = :name"
),
{"table": table, "name": name},
).scalar()
return bool(count)
def upgrade() -> None:
if not _table_exists(TABLE):
op.create_table(
TABLE,
sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("holder_user_id", sa.Integer(), nullable=False),
sa.Column(
"scope", sa.String(length=16), nullable=False, server_default="elite"
),
sa.Column(
"purpose",
sa.String(length=24),
nullable=False,
server_default="exchange",
),
sa.Column(
"commodity_id", sa.String(length=64), nullable=False, server_default=""
),
sa.Column(
"batch_id", sa.String(length=64), nullable=False, server_default=""
),
sa.Column(
"status", sa.String(length=16), nullable=False, server_default="active"
),
sa.Column("leased_at", sa.DateTime(), nullable=False),
sa.Column("expires_at", sa.DateTime(), nullable=False),
sa.Column("released_at", sa.DateTime(), nullable=True),
sa.Column(
"release_reason",
sa.String(length=64),
nullable=False,
server_default="",
),
# ⚠️ 生成列必须排在 account_id / status 之后:MySQL 只允许生成列
# 引用它前面已定义的列。
sa.Column(
"active_account_id",
sa.Integer(),
sa.Computed(ACTIVE_ACCOUNT_EXPR, persisted=True),
nullable=True,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(
["account_id"], ["accounts.id"], name="fk_lease_account"
),
sa.ForeignKeyConstraint(
["holder_user_id"], ["users.id"], name="fk_lease_holder"
),
)
if not _index_exists(TABLE, "uq_lease_active_account"):
op.create_index(
"uq_lease_active_account", TABLE, ["active_account_id"], unique=True
)
if not _index_exists(TABLE, "ix_leases_holder_status"):
op.create_index("ix_leases_holder_status", TABLE, ["holder_user_id", "status"])
if not _index_exists(TABLE, "ix_leases_status_expires"):
op.create_index("ix_leases_status_expires", TABLE, ["status", "expires_at"])
if not _index_exists(TABLE, "ix_account_leases_account_id"):
op.create_index("ix_account_leases_account_id", TABLE, ["account_id"])
if not _column_exists("accounts", ACCOUNT_COLUMN):
op.add_column(
"accounts",
sa.Column(
ACCOUNT_COLUMN,
sa.DateTime(),
nullable=True,
comment="精英宝典可换绑时刻(绝对时间);换绑冷却只记录不拦截",
),
)
if not _index_exists("account_events", ACTOR_INDEX):
op.create_index(ACTOR_INDEX, "account_events", ["actor_id", "occurred_at"])
def downgrade() -> None:
if _index_exists("account_events", ACTOR_INDEX):
op.drop_index(ACTOR_INDEX, table_name="account_events")
if _column_exists("accounts", ACCOUNT_COLUMN):
op.drop_column("accounts", ACCOUNT_COLUMN)
if _table_exists(TABLE):
op.drop_table(TABLE)