feat(weibo): 支持管理员强制转入工作台且不影响原使用人

This commit is contained in:
yml
2026-11-12 12:05:26 +08:00
parent 11b5a4d938
commit 31045e0f2e
11 changed files with 604 additions and 56 deletions
@@ -0,0 +1,106 @@
"""允许超级管理员在不影响原使用人的情况下附加微博工作台。"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261112_0077"
down_revision: str | None = "20261111_0076"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
TABLE = "weibo_workbench_accounts"
OLD_UNIQUE = "uq_weibo_workbench_account_owner"
NEW_UNIQUE = "uq_weibo_workbench_account_exclusive"
CHECK_CONSTRAINT = "ck_weibo_workbench_exclusive_slot"
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table(TABLE):
return
columns = {column["name"] for column in inspector.get_columns(TABLE)}
if "exclusive_slot" not in columns:
op.add_column(
TABLE,
sa.Column(
"exclusive_slot",
sa.Integer(),
nullable=True,
server_default="1",
),
)
constraints = {item["name"] for item in inspector.get_unique_constraints(TABLE)}
if OLD_UNIQUE in constraints:
op.drop_constraint(OLD_UNIQUE, TABLE, type_="unique")
# 迁移前超级管理员已有映射时,改为并行挂载,不能继续占用唯一槽位。
op.execute(
sa.text(
"UPDATE weibo_workbench_accounts AS w "
"JOIN users AS u ON u.id = w.user_id "
"SET w.exclusive_slot = NULL "
"WHERE u.role = 'super_admin'"
)
)
constraints = {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints(TABLE)
}
if NEW_UNIQUE not in constraints:
op.create_unique_constraint(
NEW_UNIQUE,
TABLE,
["account_id", "exclusive_slot"],
)
checks = {
item["name"] for item in sa.inspect(op.get_bind()).get_check_constraints(TABLE)
}
if CHECK_CONSTRAINT not in checks:
op.create_check_constraint(
CHECK_CONSTRAINT,
TABLE,
"exclusive_slot IS NULL OR exclusive_slot = 1",
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table(TABLE):
return
constraints = {item["name"] for item in inspector.get_unique_constraints(TABLE)}
if NEW_UNIQUE in constraints:
op.drop_constraint(NEW_UNIQUE, TABLE, type_="unique")
checks = {item["name"] for item in inspector.get_check_constraints(TABLE)}
if CHECK_CONSTRAINT in checks:
op.drop_constraint(CHECK_CONSTRAINT, TABLE, type_="check")
# 旧结构无法表达多个并行映射,降级时只保留普通用户唯一占用。
op.execute(sa.text(f"DELETE FROM {TABLE} WHERE exclusive_slot IS NULL"))
columns = {column["name"] for column in inspector.get_columns(TABLE)}
if "exclusive_slot" in columns:
op.drop_column(TABLE, "exclusive_slot")
op.execute(
sa.text(
"DELETE newer FROM weibo_workbench_accounts AS newer "
"JOIN weibo_workbench_accounts AS older "
"ON newer.account_id = older.account_id AND newer.id > older.id"
)
)
constraints = {
item["name"] for item in sa.inspect(op.get_bind()).get_unique_constraints(TABLE)
}
if OLD_UNIQUE not in constraints:
op.create_unique_constraint(
OLD_UNIQUE,
TABLE,
["account_id"],
)
+10 -1
View File
@@ -8,6 +8,7 @@ from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
Computed,
ForeignKey,
Index,
@@ -989,6 +990,9 @@ class WeiboWorkbenchAccount(Base):
nullable=False,
index=True,
)
# 普通用户固定为 1;超级管理员的并行工作台映射为 NULL。
# MySQL 唯一索引允许多个 NULL,因此管理员附加映射不会挤掉原使用人。
exclusive_slot: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
__table_args__ = (
@@ -999,7 +1003,12 @@ class WeiboWorkbenchAccount(Base):
),
UniqueConstraint(
"account_id",
name="uq_weibo_workbench_account_owner",
"exclusive_slot",
name="uq_weibo_workbench_account_exclusive",
),
CheckConstraint(
"exclusive_slot IS NULL OR exclusive_slot = 1",
name="ck_weibo_workbench_exclusive_slot",
),
)
+130 -33
View File
@@ -313,8 +313,18 @@ def list_workbench_pool(
"""查看可加入当前用户工作台、且尚未加入的账号池账号。"""
_require_weibo_perm(current, "weibo:workbench")
occupied = db.query(WeiboWorkbenchAccount.account_id)
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(occupied))
if current.role == "super_admin":
selected = db.query(WeiboWorkbenchAccount.account_id).filter(
WeiboWorkbenchAccount.user_id == current.id
)
pool_query = db.query(WeiboAccount).filter(WeiboAccount.id.not_in(selected))
else:
exclusively_occupied = db.query(WeiboWorkbenchAccount.account_id).filter(
WeiboWorkbenchAccount.exclusive_slot.is_not(None)
)
pool_query = db.query(WeiboAccount).filter(
WeiboAccount.id.not_in(exclusively_occupied)
)
if not _can_view_all_weibo_accounts(current):
pool_query = pool_query.filter(WeiboAccount.assigned_to == current.id)
query = _apply_account_filters(
@@ -339,6 +349,11 @@ def import_workbench_accounts_by_text(
"""按 UID/用户名/角色批量解析账号并加入当前用户工作台。"""
_require_weibo_perm(current, "weibo:workbench")
if req.force_add_occupied and current.role != "super_admin":
raise HTTPException(
status_code=403,
detail="仅超级管理员可以强制转入其他人的工作台账号",
)
identifiers: list[str] = []
for token in re.split(r"[\s,,;;]+", req.text.strip()):
value = token.strip()
@@ -373,18 +388,42 @@ def import_workbench_accounts_by_text(
by_nickname = grouped("nickname")
by_role = grouped("game_role_name")
account_ids = [account.id for account in accounts]
occupancy = {
account_id: user_id
for account_id, user_id in db.query(
memberships: dict[int, list[tuple[int, str, int | None]]] = {}
for account_id, user_id, username, exclusive_slot in (
db.query(
WeiboWorkbenchAccount.account_id,
WeiboWorkbenchAccount.user_id,
User.username,
WeiboWorkbenchAccount.exclusive_slot,
)
.outerjoin(User, User.id == WeiboWorkbenchAccount.user_id)
.filter(WeiboWorkbenchAccount.account_id.in_(account_ids))
.all()
):
memberships.setdefault(account_id, []).append(
(user_id, username or "", exclusive_slot)
)
current_member_ids = {
account_id
for account_id, rows in memberships.items()
if any(user_id == current.id for user_id, _username, _slot in rows)
}
exclusive_owners = {
account_id: next(
(
(user_id, username)
for user_id, username, exclusive_slot in rows
if exclusive_slot is not None
),
None,
)
for account_id, rows in memberships.items()
}
items: list[WeiboWorkbenchBatchImportItem] = []
to_add: list[WeiboAccount] = []
to_add: list[tuple[WeiboAccount, int | None]] = []
forced_accounts: list[WeiboAccount] = []
planned_accounts: list[WeiboAccount] = []
planned_ids: set[int] = set()
for identifier in identifiers:
candidates = by_uid.get(identifier) or by_nickname.get(identifier) or []
@@ -410,13 +449,19 @@ def import_workbench_accounts_by_text(
continue
account = candidates[0]
exclusive_owner = exclusive_owners.get(account.id)
base = {
"identifier": identifier,
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
"occupied_by_user_id": (
exclusive_owner[0] if exclusive_owner is not None else None
),
"occupied_by_username": (
exclusive_owner[1] if exclusive_owner is not None else ""
),
}
occupant = occupancy.get(account.id)
if not account.is_enabled:
items.append(
WeiboWorkbenchBatchImportItem(
@@ -425,22 +470,6 @@ def import_workbench_accounts_by_text(
**base,
)
)
elif occupant is not None and occupant != current.id:
items.append(
WeiboWorkbenchBatchImportItem(
status="unavailable",
message="账号已在其他人的工作台中使用",
**base,
)
)
elif occupant == current.id:
items.append(
WeiboWorkbenchBatchImportItem(
status="already_added",
message="账号已在本工作台",
**base,
)
)
elif account.id in planned_ids:
items.append(
WeiboWorkbenchBatchImportItem(
@@ -449,9 +478,51 @@ def import_workbench_accounts_by_text(
**base,
)
)
elif account.id in current_member_ids:
items.append(
WeiboWorkbenchBatchImportItem(
status="already_added",
message="账号已在本工作台",
**base,
)
)
elif exclusive_owner is not None and exclusive_owner[0] != current.id:
if req.force_add_occupied:
planned_ids.add(account.id)
to_add.append((account, None))
forced_accounts.append(account)
planned_accounts.append(account)
items.append(
WeiboWorkbenchBatchImportItem(
status="forced_added",
message=(
f"已强制转入,{exclusive_owner[1] or '原使用人'}"
"的工作台保持不变"
),
**base,
)
)
continue
items.append(
WeiboWorkbenchBatchImportItem(
status="unavailable",
message=(
f"账号已在 {exclusive_owner[1]} 的工作台中使用"
if exclusive_owner[1]
else "账号已在其他人的工作台中使用"
),
**base,
)
)
else:
planned_ids.add(account.id)
to_add.append(account)
to_add.append(
(
account,
None if current.role == "super_admin" else 1,
)
)
planned_accounts.append(account)
items.append(
WeiboWorkbenchBatchImportItem(
status="added",
@@ -460,11 +531,23 @@ def import_workbench_accounts_by_text(
)
)
forced_details = [
{
"account_id": account.id,
"existing_owner_user_id": exclusive_owners[account.id][0],
"existing_owner_username": exclusive_owners[account.id][1],
}
for account in forced_accounts
]
try:
db.add_all(
[
WeiboWorkbenchAccount(user_id=current.id, account_id=account.id)
for account in to_add
WeiboWorkbenchAccount(
user_id=current.id,
account_id=account.id,
exclusive_slot=exclusive_slot,
)
for account, exclusive_slot in to_add
]
)
db.commit()
@@ -474,24 +557,31 @@ def import_workbench_accounts_by_text(
status_code=409,
detail="部分账号刚被其他工作台占用,请刷新后重试",
) from exc
if to_add:
if planned_accounts:
_record_audit_safely(
db,
current,
action="weibo:workbench:batch_import",
target=f"weibo_user:{current.id}",
detail={"account_ids": [account.id for account in to_add]},
detail={
"account_ids": [account.id for account in planned_accounts],
"added_account_ids": [account.id for account, _slot in to_add],
"forced_added_account_ids": [account.id for account in forced_accounts],
"existing_owners": forced_details,
},
)
added = sum(item.status == "added" for item in items)
forced_added = sum(item.status == "forced_added" for item in items)
already_added = sum(item.status == "already_added" for item in items)
failed = len(items) - added - already_added
failed = len(items) - added - forced_added - already_added
return WeiboWorkbenchBatchImportOut(
requested=len(identifiers),
added=added,
forced_added=forced_added,
already_added=already_added,
failed=failed,
account_ids=[account.id for account in to_add],
account_ids=[account.id for account in planned_accounts],
items=items,
)
@@ -567,11 +657,12 @@ def add_workbench_accounts(
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
.filter(
WeiboWorkbenchAccount.user_id != current.id,
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
WeiboWorkbenchAccount.account_id.in_(account_ids),
)
.all()
}
if taken:
if taken and current.role != "super_admin":
raise HTTPException(
status_code=409,
detail="部分账号已在其他人的工作台中使用,请先让原使用人移出",
@@ -587,7 +678,11 @@ def add_workbench_accounts(
}
db.add_all(
[
WeiboWorkbenchAccount(user_id=current.id, account_id=account_id)
WeiboWorkbenchAccount(
user_id=current.id,
account_id=account_id,
exclusive_slot=(None if current.role == "super_admin" else 1),
)
for account_id in account_ids
if account_id not in selected_ids
]
@@ -763,12 +858,14 @@ def batch_assign_accounts(
previous_assignments = {account.id: account.assigned_to for account in accounts}
if target is None:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids)
WeiboWorkbenchAccount.account_id.in_(actual_ids),
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
).delete(synchronize_session=False)
else:
db.query(WeiboWorkbenchAccount).filter(
WeiboWorkbenchAccount.account_id.in_(actual_ids),
WeiboWorkbenchAccount.user_id != target.id,
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
).delete(synchronize_session=False)
for account in accounts:
account.assigned_to = target.id if target else None
+4
View File
@@ -542,6 +542,7 @@ class WeiboWorkbenchTransferToCollabOut(BaseModel):
class WeiboWorkbenchBatchImportRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=100_000)
force_add_occupied: bool = False
class WeiboWorkbenchBatchImportItem(BaseModel):
@@ -551,11 +552,14 @@ class WeiboWorkbenchBatchImportItem(BaseModel):
account_id: int | None = None
uid: str = ""
nickname: str = ""
occupied_by_user_id: int | None = None
occupied_by_username: str = ""
class WeiboWorkbenchBatchImportOut(BaseModel):
requested: int
added: int
forced_added: int = 0
already_added: int
failed: int
account_ids: list[int] = Field(default_factory=list)
+6 -3
View File
@@ -135,10 +135,13 @@ def import_weibo_cookies(
continue
owner_id = (
db.query(WeiboWorkbenchAccount.user_id)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.scalar()
.filter(
WeiboWorkbenchAccount.account_id == account.id,
WeiboWorkbenchAccount.exclusive_slot.is_not(None),
)
.first()
)
if owner_id is not None and owner_id != current_user_id:
if owner_id is not None and owner_id[0] != current_user_id:
errors.append(
WeiboImportIssue(
line=line_no,