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
@@ -18,7 +18,8 @@ status: implemented
- **分配管理**:管理员和运营把全局账号分配给客服,支持批量分配、取消分配和按客服查看。
- **微博工作台**:每个用户自己的账号子集,只展示自己导入的账号,用于绑定、充值、兑换和日常操作。
工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。同一个全局账号同一时间只能被一个用户加入工作台;当前使用人移出后,其他人才可以加入。
工作台采用斗鱼模式:**账号实体仍然全局唯一,工作台只保存用户与账号的映射**。
普通用户之间保持独占;超级管理员可以额外增加并行映射,不改变、也不阻塞原使用人。
## 2. 数据模型
@@ -33,8 +34,8 @@ weibo_accounts.assigned_to -> users.id
- `assigned_to` 为空表示未分配。
- 管理员和运营可查看全部账号并修改归属。
- 客服的账号管理、标签、工作台候选、绑定、充值、兑换、复制 CK 等操作全部只允许访问 `assigned_to = 当前用户` 的账号。
- 同一账号改分配给新客服时,旧客服工作台中的映射会自动清除,避免账号被旧工作台继续占用。
- 取消分配时清除该账号的全部工作台映射。
- 同一账号改分配给新客服时,旧客服的普通工作台映射会自动清除,避免账号被旧工作台继续占用。
- 取消分配时清除普通工作台映射;超级管理员的并行映射保留。
新增:
@@ -43,10 +44,12 @@ weibo_workbench_accounts
id
user_id -> users.id
account_id -> weibo_accounts.id
exclusive_slot -- 普通用户固定为 1,超级管理员为 NULL
created_at
UNIQUE(user_id, account_id)
UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
UNIQUE(account_id, exclusive_slot)
-- MySQL 允许多个 NULL,因此一个账号可保留普通使用人,同时被多个超级管理员并行使用
```
删除账号或用户时,对应工作台映射级联删除,不保留孤儿记录。
@@ -148,6 +151,9 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
中文逗号、分号分隔;后端先按 UID 精确匹配,再按用户名、角色名兜底解析,
重名时必须改用 UID。导入结果逐项区分:已导入、已存在、未找到、重名、
被其他工作台占用、账号停用和名单内重复。原来的「从账号池选择」页签完整保留。
超级管理员的批量粘贴默认允许强制转入被其他用户占用的账号:服务端新增一条
`exclusive_slot = NULL` 的并行映射,不修改原使用人的工作台、账号归属或 CK。
普通客服仍不能强制转入。结果会把强制转入单独计数,并记录原使用人和时间。
### 5.3 工作台转入外部单协作
@@ -159,7 +165,8 @@ UNIQUE(account_id) -- 同一账号同时只能属于一个工作台
## 6. 数据隔离边界
- 工作台列表和增删接口都按当前登录用户过滤。
- 客服的账号池候选只包含分配给自己的账号,并排除所有已被其他用户工作台占用的账号。
- 客服的账号池候选只包含分配给自己的账号,并排除所有已被其他普通用户独占的账号。
- 超级管理员的账号池候选只排除自己已经加入的账号,其他人的占用不阻塞。
- 加入/移出只操作 `weibo_workbench_accounts`,不修改账号归属或 CK。
- 账号池全量查看由 `weibo:assign` 控制;客服可以看到账号管理入口,但列表和所有详情/操作接口只返回分配给自己的账号。
- 外部单协作继续引用全局账号 ID,不受个人工作台选择影响。
+115 -1
View File
@@ -17,16 +17,18 @@ from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import Session
import web.backend.database as database_module
from tests.support.db import get_mysql_credentials, get_test_engine
from web.backend.database import Base
from web.backend.models import User, WeiboAccount
TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261111_0076"
HEAD_REVISION = "20261112_0077"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -154,6 +156,118 @@ class TestMigrationSmoke:
with admin_engine.begin() as conn:
conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;"))
def test_0077_converts_super_admin_membership_to_overlay(self):
creds = get_mysql_credentials()
mig_db = f"douyu_login_mig_{secrets.token_hex(4)}"
mig_url = (
f"mysql+pymysql://{quote_plus(creds['user'])}:"
f"{quote_plus(creds['password'])}@{creds['host']}:{creds['port']}/"
f"{mig_db}?charset=utf8mb4"
)
admin_engine = get_test_engine()
with admin_engine.begin() as conn:
conn.execute(
text(
f"CREATE DATABASE `{mig_db}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;"
)
)
original_url = database_module.DATABASE_URL
mig_engine = create_engine(mig_url)
try:
database_module.DATABASE_URL = mig_url
cfg = _migration_config()
cfg.set_main_option("sqlalchemy.url", mig_url)
command.upgrade(cfg, "20261111_0076")
with Session(mig_engine) as session:
admin = User(
username="migration-admin",
password_hash="hash",
role="super_admin",
is_active=True,
)
support = User(
username="migration-support",
password_hash="hash",
role="support",
is_active=True,
)
admin_account = WeiboAccount(
uid="4014000771",
nickname="管理员旧映射",
cookie="SCF=a; SUB=admin",
)
support_account = WeiboAccount(
uid="4014000772",
nickname="客服旧映射",
cookie="SCF=b; SUB=support",
)
session.add_all([admin, support, admin_account, support_account])
session.commit()
admin_id = admin.id
support_id = support.id
admin_account_id = admin_account.id
support_account_id = support_account.id
with mig_engine.begin() as conn:
for user_id, account_id in (
(admin_id, admin_account_id),
(support_id, support_account_id),
):
conn.execute(
text(
"INSERT INTO weibo_workbench_accounts "
"(user_id, account_id, created_at) "
"VALUES (:user_id, :account_id, UTC_TIMESTAMP())"
),
{"user_id": user_id, "account_id": account_id},
)
command.upgrade(cfg, "20261112_0077")
with mig_engine.begin() as conn:
rows = conn.execute(
text(
"SELECT user_id, account_id, exclusive_slot "
"FROM weibo_workbench_accounts ORDER BY user_id"
)
).all()
assert {
(user_id, account_id, exclusive_slot)
for user_id, account_id, exclusive_slot in rows
} == {
(admin_id, admin_account_id, None),
(support_id, support_account_id, 1),
}
# 管理员可以给同一个账号再挂一条并行映射,普通唯一槽位保持不变。
conn.execute(
text(
"INSERT INTO weibo_workbench_accounts "
"(user_id, account_id, exclusive_slot, created_at) "
"VALUES (:user_id, :account_id, NULL, UTC_TIMESTAMP())"
),
{"user_id": admin_id, "account_id": support_account_id},
)
assert (
conn.execute(
text(
"SELECT COUNT(*) FROM weibo_workbench_accounts "
"WHERE account_id = :account_id"
),
{"account_id": support_account_id},
).scalar_one()
== 2
)
finally:
database_module.DATABASE_URL = original_url
mig_engine.dispose()
with admin_engine.begin() as conn:
conn.execute(text(f"DROP DATABASE IF EXISTS `{mig_db}`;"))
class TestRollbackBackfillScript:
"""回滚前置脚本:把账号侧 CK 搬回 `login_tasks`。
+189 -6
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from dataclasses import replace
from typing import ClassVar
import pytest
@@ -277,6 +278,7 @@ def test_occupied_workbench_account_cannot_be_updated_by_another_user(db, curren
WeiboWorkbenchAccount(
user_id=owner.id,
account_id=imported.id,
exclusive_slot=1,
)
)
db.commit()
@@ -549,8 +551,16 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
db.commit()
db.add_all(
[
WeiboWorkbenchAccount(user_id=current_user.id, account_id=existing.id),
WeiboWorkbenchAccount(user_id=other_user.id, account_id=occupied.id),
WeiboWorkbenchAccount(
user_id=current_user.id,
account_id=existing.id,
exclusive_slot=1,
),
WeiboWorkbenchAccount(
user_id=other_user.id,
account_id=occupied.id,
exclusive_slot=1,
),
]
)
db.commit()
@@ -581,6 +591,11 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
"批量导入重名": "ambiguous",
"批量导入停用": "disabled",
}
occupied_item = next(
item for item in result["items"] if item["identifier"] == occupied.uid
)
assert occupied_item["occupied_by_user_id"] == other_user.id
assert occupied_item["occupied_by_username"] == other_user.username
workbench_ids = {
account_id
for (account_id,) in db.query(WeiboWorkbenchAccount.account_id)
@@ -590,6 +605,151 @@ def test_batch_import_workbench_accounts_reports_each_identifier(db, current_use
assert workbench_ids == {existing.id, added.id}
def test_super_admin_force_adds_account_without_removing_original_owner(
db, current_user
):
other_user = User(
username="weibo-batch-import-owner",
password_hash="hash",
role="support",
is_active=True,
)
db.add(other_user)
db.commit()
account = WeiboAccount(
uid="4014000911",
nickname="用户8477791859",
cookie="SCF=a; SUB=force-add",
assigned_to=other_user.id,
)
db.add(account)
db.commit()
db.add(
WeiboWorkbenchAccount(
user_id=other_user.id,
account_id=account.id,
exclusive_slot=1,
)
)
db.commit()
admin_pool = list_workbench_pool(page=1, page_size=20, db=db, current=current_user)
assert [item.id for item in admin_pool["items"]] == [account.id]
result = import_workbench_accounts_by_text(
WeiboWorkbenchBatchImportRequest(
text=account.nickname,
force_add_occupied=True,
),
db=db,
current=current_user,
).model_dump()
assert result["requested"] == 1
assert result["added"] == 0
assert result["forced_added"] == 1
assert result["already_added"] == 0
assert result["failed"] == 0
assert result["account_ids"] == [account.id]
assert result["items"] == [
{
"identifier": account.nickname,
"status": "forced_added",
"message": (f"已强制转入,{other_user.username}的工作台保持不变"),
"account_id": account.id,
"uid": account.uid,
"nickname": account.nickname,
"occupied_by_user_id": other_user.id,
"occupied_by_username": other_user.username,
}
]
db.expire_all()
memberships = (
db.query(WeiboWorkbenchAccount)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.order_by(WeiboWorkbenchAccount.user_id)
.all()
)
assert {
(membership.user_id, membership.exclusive_slot) for membership in memberships
} == {
(other_user.id, 1),
(current_user.id, None),
}
assert (
list_workbench_accounts(page=1, page_size=20, db=db, current=other_user)[
"total"
]
== 1
)
assert (
list_workbench_accounts(page=1, page_size=20, db=db, current=current_user)[
"total"
]
== 1
)
StubWeiboClient.snapshot = replace(StubWeiboClient.snapshot, uid=account.uid)
updated = import_cookies(
WeiboCookieImport(
text="SCF=updated; SUB=owner-still-works",
update_existing=True,
),
db=db,
current=other_user,
)
assert updated.count == 1
assert updated.errors == []
db.refresh(account)
assert "SUB=owner-still-works" in account.cookie
remove_workbench_accounts(
WeiboWorkbenchAccountsRequest(account_ids=[account.id]),
db=db,
current=current_user,
)
assert (
list_workbench_accounts(page=1, page_size=20, db=db, current=other_user)[
"total"
]
== 1
)
audit = (
db.query(AuditLog)
.filter(AuditLog.action == "weibo:workbench:batch_import")
.one()
)
assert f'"forced_added_account_ids":[{account.id}]' in audit.detail
assert f'"existing_owner_user_id":{other_user.id}' in audit.detail
assert f'"existing_owner_username":"{other_user.username}"' in audit.detail
@pytest.mark.parametrize("role", ["operation", "support"])
def test_non_super_admin_cannot_force_add_occupied_workbench_account(db, role):
user = User(
username=f"weibo-force-add-{role}",
password_hash="hash",
role=role,
is_active=True,
)
db.add(user)
db.commit()
with pytest.raises(HTTPException) as excinfo:
import_workbench_accounts_by_text(
WeiboWorkbenchBatchImportRequest(
text="用户8477791859",
force_add_occupied=True,
),
db=db,
current=user,
)
assert excinfo.value.status_code == 403
assert "仅超级管理员" in str(excinfo.value.detail)
def test_operation_can_transfer_workbench_account_to_collab(db):
operator = User(
username="weibo-transfer-operator",
@@ -612,6 +772,7 @@ def test_operation_can_transfer_workbench_account_to_collab(db):
WeiboWorkbenchAccount(
user_id=operator.id,
account_id=account.id,
exclusive_slot=1,
)
)
db.commit()
@@ -830,7 +991,13 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
role="support",
is_active=True,
)
db.add_all([operator, old_support, new_support])
admin = User(
username="weibo-assign-admin",
password_hash="hash",
role="super_admin",
is_active=True,
)
db.add_all([operator, old_support, new_support, admin])
db.commit()
account = WeiboAccount(
uid="4014000311",
@@ -845,6 +1012,14 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
db=db,
current=old_support,
)
db.add(
WeiboWorkbenchAccount(
user_id=admin.id,
account_id=account.id,
exclusive_slot=None,
)
)
db.commit()
result = batch_assign_accounts(
WeiboBatchAssignRequest(
@@ -858,17 +1033,25 @@ def test_weibo_batch_assign_cleans_previous_workbench(db):
assert result["updated"] == 1
db.refresh(account)
assert account.assigned_to == new_support.id
assert (
memberships = (
db.query(WeiboWorkbenchAccount)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.count()
== 0
.all()
)
assert {
(membership.user_id, membership.exclusive_slot) for membership in memberships
} == {(admin.id, None)}
add_workbench_accounts(
WeiboWorkbenchAccountsRequest(account_ids=[account.id]),
db=db,
current=new_support,
)
assert (
db.query(WeiboWorkbenchAccount)
.filter(WeiboWorkbenchAccount.account_id == account.id)
.count()
== 2
)
audit = (
db.query(AuditLog)
.filter(AuditLog.action == "weibo:account:assign")
@@ -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,
+4
View File
@@ -826,6 +826,7 @@ export interface WeiboWorkbenchBatchImportItem {
identifier: string;
status:
| 'added'
| 'forced_added'
| 'already_added'
| 'not_found'
| 'ambiguous'
@@ -836,11 +837,14 @@ export interface WeiboWorkbenchBatchImportItem {
account_id: number | null;
uid: string;
nickname: string;
occupied_by_user_id: number | null;
occupied_by_username: string;
}
export interface WeiboWorkbenchBatchImportResult {
requested: number;
added: number;
forced_added: number;
already_added: number;
failed: number;
account_ids: number[];
+2 -2
View File
@@ -124,10 +124,10 @@ export const weiboApi = {
'/weibo/workbench/accounts',
{ account_ids: accountIds },
),
batchImportWorkbenchAccounts: (text: string) =>
batchImportWorkbenchAccounts: (text: string, forceAddOccupied = false) =>
api.post<WeiboWorkbenchBatchImportResult, WeiboWorkbenchBatchImportResult>(
'/weibo/workbench/accounts/batch-import',
{ text },
{ text, force_add_occupied: forceAddOccupied },
),
transferWorkbenchToCollab: (accountId: number) =>
api.post<
+26 -5
View File
@@ -83,6 +83,7 @@ const WORKBENCH_IMPORT_STATUS: Record<
{ label: string; color: string }
> = {
added: { label: '已导入', color: 'success' },
forced_added: { label: '强制转入', color: 'orange' },
already_added: { label: '已存在', color: 'blue' },
not_found: { label: '未找到', color: 'error' },
ambiguous: { label: '重名', color: 'warning' },
@@ -161,8 +162,9 @@ export default function WeiboAccountsPage({
mode?: WeiboAccountsPageMode;
}) {
const { token } = theme.useToken();
const { can } = usePermissions();
const { can, user } = usePermissions();
const navigate = useNavigate();
const isSuperAdmin = user?.role === 'super_admin';
const isWorkbench = mode === 'workbench';
const canImport = can('weibo:import');
const canManage = can('weibo:manage');
@@ -206,6 +208,7 @@ export default function WeiboAccountsPage({
'paste',
);
const [batchImportText, setBatchImportText] = useState('');
const [forceAddOccupied, setForceAddOccupied] = useState(false);
const [batchImportLoading, setBatchImportLoading] = useState(false);
const [batchImportResult, setBatchImportResult] =
useState<WeiboWorkbenchBatchImportResult | null>(null);
@@ -400,6 +403,7 @@ export default function WeiboAccountsPage({
setPoolSelectedIds([]);
setWorkbenchImportTab('paste');
setBatchImportText('');
setForceAddOccupied(isSuperAdmin);
setBatchImportResult(null);
setPoolOpen(true);
void loadWorkbenchPool(1, poolPageSize, '');
@@ -414,17 +418,24 @@ export default function WeiboAccountsPage({
setBatchImportLoading(true);
setBatchImportResult(null);
try {
const result = await weiboApi.batchImportWorkbenchAccounts(text);
const result = await weiboApi.batchImportWorkbenchAccounts(
text,
isSuperAdmin && forceAddOccupied,
);
setBatchImportResult(result);
await loadAccounts();
await loadKnownTags();
const forceAddSummary =
result.forced_added > 0
? `,强制转入 ${result.forced_added} 个`
: '';
if (result.failed > 0) {
message.warning(
`批量导入完成:新增 ${result.added} 个,已存在 ${result.already_added} 个,失败 ${result.failed} 个`,
`批量导入完成:新增 ${result.added} 个${forceAddSummary},已存在 ${result.already_added} 个,失败 ${result.failed} 个`,
);
} else {
message.success(
`批量导入完成:新增 ${result.added} 个,已存在 ${result.already_added} 个`,
`批量导入完成:新增 ${result.added} 个${forceAddSummary},已存在 ${result.already_added} 个`,
);
}
} catch (error) {
@@ -2589,6 +2600,16 @@ export default function WeiboAccountsPage({
title="支持多行、空格、逗号分隔"
description="优先按 UID 精确匹配;也支持微博用户名和角色名。重名时请改用 UID。"
/>
{isSuperAdmin && (
<Checkbox
checked={forceAddOccupied}
onChange={(event) =>
setForceAddOccupied(event.target.checked)
}
>
其他人的工作台占用时,强制转入当前工作台(不影响原使用人)
</Checkbox>
)}
<TextArea
rows={8}
value={batchImportText}
@@ -2606,7 +2627,7 @@ export default function WeiboAccountsPage({
<Alert
type={batchImportResult.failed > 0 ? 'warning' : 'success'}
showIcon
title={`解析 ${batchImportResult.requested} 项:新增 ${batchImportResult.added},已存在 ${batchImportResult.already_added},失败 ${batchImportResult.failed}`}
title={`解析 ${batchImportResult.requested} 项:新增 ${batchImportResult.added},强制转入 ${batchImportResult.forced_added},已存在 ${batchImportResult.already_added},失败 ${batchImportResult.failed}`}
/>
<div
style={{