From 31045e0f2e125d2264ac670db89bc6875c48bd1b Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 12 Nov 2026 12:05:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(weibo):=20=E6=94=AF=E6=8C=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E5=BC=BA=E5=88=B6=E8=BD=AC=E5=85=A5=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E4=B8=94=E4=B8=8D=E5=BD=B1=E5=93=8D=E5=8E=9F?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../微博/微博账号池与个人工作台-2026-11-08.md | 17 +- tests/test_migrations.py | 116 ++++++++++- tests/test_weibo_accounts.py | 195 +++++++++++++++++- ...1112_0077_weibo_workbench_admin_overlay.py | 106 ++++++++++ web/backend/models.py | 11 +- web/backend/routers/weibo.py | 163 ++++++++++++--- web/backend/schemas.py | 4 + web/backend/services/weibo_service.py | 9 +- web/frontend/src/api/types.ts | 4 + web/frontend/src/api/weibo.ts | 4 +- web/frontend/src/pages/WeiboAccountsPage.tsx | 31 ++- 11 files changed, 604 insertions(+), 56 deletions(-) create mode 100644 web/backend/migrations/versions/20261112_0077_weibo_workbench_admin_overlay.py diff --git a/docs/微博/微博账号池与个人工作台-2026-11-08.md b/docs/微博/微博账号池与个人工作台-2026-11-08.md index 121da15..951c469 100644 --- a/docs/微博/微博账号池与个人工作台-2026-11-08.md +++ b/docs/微博/微博账号池与个人工作台-2026-11-08.md @@ -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,不受个人工作台选择影响。 diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 5ec9197..36af6a2 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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`。 diff --git a/tests/test_weibo_accounts.py b/tests/test_weibo_accounts.py index 74cb5df..d8c03c0 100644 --- a/tests/test_weibo_accounts.py +++ b/tests/test_weibo_accounts.py @@ -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") diff --git a/web/backend/migrations/versions/20261112_0077_weibo_workbench_admin_overlay.py b/web/backend/migrations/versions/20261112_0077_weibo_workbench_admin_overlay.py new file mode 100644 index 0000000..0e28221 --- /dev/null +++ b/web/backend/migrations/versions/20261112_0077_weibo_workbench_admin_overlay.py @@ -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"], + ) diff --git a/web/backend/models.py b/web/backend/models.py index 80be309..abd243a 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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", ), ) diff --git a/web/backend/routers/weibo.py b/web/backend/routers/weibo.py index 494b001..d638f86 100644 --- a/web/backend/routers/weibo.py +++ b/web/backend/routers/weibo.py @@ -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 diff --git a/web/backend/schemas.py b/web/backend/schemas.py index d2f86b2..f92954f 100644 --- a/web/backend/schemas.py +++ b/web/backend/schemas.py @@ -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) diff --git a/web/backend/services/weibo_service.py b/web/backend/services/weibo_service.py index 4a67bc1..06c6319 100644 --- a/web/backend/services/weibo_service.py +++ b/web/backend/services/weibo_service.py @@ -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, diff --git a/web/frontend/src/api/types.ts b/web/frontend/src/api/types.ts index 5b86932..b5bd4be 100644 --- a/web/frontend/src/api/types.ts +++ b/web/frontend/src/api/types.ts @@ -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[]; diff --git a/web/frontend/src/api/weibo.ts b/web/frontend/src/api/weibo.ts index c2bd000..cc603ec 100644 --- a/web/frontend/src/api/weibo.ts +++ b/web/frontend/src/api/weibo.ts @@ -124,10 +124,10 @@ export const weiboApi = { '/weibo/workbench/accounts', { account_ids: accountIds }, ), - batchImportWorkbenchAccounts: (text: string) => + batchImportWorkbenchAccounts: (text: string, forceAddOccupied = false) => api.post( '/weibo/workbench/accounts/batch-import', - { text }, + { text, force_add_occupied: forceAddOccupied }, ), transferWorkbenchToCollab: (accountId: number) => api.post< diff --git a/web/frontend/src/pages/WeiboAccountsPage.tsx b/web/frontend/src/pages/WeiboAccountsPage.tsx index 366d13a..d571682 100644 --- a/web/frontend/src/pages/WeiboAccountsPage.tsx +++ b/web/frontend/src/pages/WeiboAccountsPage.tsx @@ -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(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 && ( + + setForceAddOccupied(event.target.checked) + } + > + 其他人的工作台占用时,强制转入当前工作台(不影响原使用人) + + )}