feat(weibo): 支持管理员强制转入工作台且不影响原使用人
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user