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:
@@ -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)
|
||||
Reference in New Issue
Block a user