Files
live-hub-py/web/backend/migrations/versions/20261008_0053_account_leases.py

164 lines
6.3 KiB
Python
Raw Permalink 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.
"""账号租约 `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 = "20261008_0053"
down_revision: str | None = "20261008_0052"
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)